Skip to content

feat(server): WS load-test harness, e2e suite, and Bun runtime support - #4977

Closed
evanpelle wants to merge 1 commit into
mainfrom
perf/ws-load-test-and-bun-runtime
Closed

feat(server): WS load-test harness, e2e suite, and Bun runtime support#4977
evanpelle wants to merge 1 commit into
mainfrom
perf/ws-load-test-and-bun-runtime

Conversation

@evanpelle

Copy link
Copy Markdown
Collaborator

Summary

Investigates a Node → Bun migration for the game server, starting from a new WebSocket load-test harness, and lands the harness, an e2e suite, Bun runtime support, and two lifecycle fixes. Recommendation: keep Node as the production runtime — data below.

What's in here

  • tests/load/ — WS load-test harness (npm run perf:server). Boots the real server (selectable runtime via --runtime node|bun), creates private games, connects hundreds of WS clients, and measures intent→turn-broadcast RTT, tick jitter, join latency, throughput, and server CPU/RSS. RTT is measured with zero server instrumentation by embedding Date.now() in the troops field of relayed attack intents. Methodology + full results in tests/load/README.md.
  • tests/e2e/ — e2e suite (npm run test:e2e, npm run test:e2e:bun) that boots the real master + cluster workers and drives the full flow over HTTP+WS: create → join → start → 100ms turn relay → server-stamped intent relay → rejoin catch-up → garbage-message kick → wrong-worker rejection, plus a master-death → workers-exit regression test.
  • src/server/Worker.ts — workers exit when the master's IPC channel closes. Under Bun, cluster workers otherwise outlive a dead master and keep serving stale state on their SO_REUSEPORT-bound ports next to a restarted server. No-op on Node (verified by running the regression test without the fix).
  • src/server/WorkerLobbyService.tssendToMaster no longer throws on a closed IPC channel (Bun throws where Node returns false).
  • package.jsonstart:server:bun, start:server-dev:bun, dev:bun, test:e2e, test:e2e:bun, perf:server.

Load results (Linux x64, 20 cores, Node 26.5 / Bun 1.3.14)

Heavy profile: 400 clients, 2 intents/s each, ~4,400 msgs/s outbound, 60s.

Node (tsx) Bun
RTT p50 / p99 50 / 100 ms 54 / 103 ms
Turn jitter p99 (target 100ms) 104 ms 106 ms
Server avg CPU 11.6% 70.1%
Peak RSS 661 MB 384 MB

Latency is identical (tick-dominated), but Bun burns ~4–6× the CPU. Transport microbenchmarks rule out the fixable suspects: Bun's builtin ws shim (3.8%) ≈ native Bun.serve() (4.1%) vs Node+ws (2.2%) on a minimal broadcast server — so a Bun-native transport rewrite would not close the gap. Per-op JSON+Zod is faster on Bun in isolation; the overhead is diffuse in-situ runtime cost (timer-context sends, GC pressure, ~5%/process idle floor). CPU profile shows ~38% of worker time inside native websocket send at ~10× the per-send cost the same shim achieves in the minimal bench.

Test plan

  • vitest run tests/server — 305 passed
  • npm run test:e2e (Node) — 13 passed
  • npm run test:e2e:bun (Bun 1.3.14) — 13 passed
  • Full unit suite — 2,594 + 305 passed (needs NODE_OPTIONS=--no-experimental-webstorage on Node 26, pre-existing issue)
  • tsc --noEmit, npm run lint, prettier clean

🤖 Generated with Claude Code

Adds a WebSocket load-test harness (npm run perf:server) that drives the
real server (create games, join clients, start, intent traffic) and
measures intent->turn-broadcast RTT, tick jitter, and server CPU/RSS, with
a selectable server runtime (--runtime node|bun).

Makes the server run correctly under Bun and fixes two real lifecycle gaps
found doing so:
- cluster workers now exit when the master's IPC channel closes; under Bun
  they otherwise outlive a dead master and keep serving stale state on
  their SO_REUSEPORT-bound ports (no-op on Node, verified)
- WorkerLobbyService.sendToMaster no longer throws on a closed IPC channel
  (Bun throws where Node returns false)

Adds an e2e suite (npm run test:e2e / test:e2e:bun) that boots the real
master+workers and exercises the full flow over HTTP+WS: create, join,
start, 100ms turn relay, server-stamped intents, rejoin catch-up, kick,
wrong-worker rejection, and a master-death worker-exit regression test.

Load results (tests/load/README.md): latency identical on both runtimes
(tick-dominated), but Bun burns ~4-6x CPU for the same relay workload —
transport benchmarks show a Bun.serve()-native rewrite would not close the
gap. Recommendation: keep Node as the production runtime; Bun scripts
(start:server:bun, dev:bun) stay for experimentation.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@coderabbitai

coderabbitai Bot commented Aug 12, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

Walkthrough

Added Bun runtime commands, worker shutdown handling, dedicated E2E infrastructure, comprehensive game-server E2E coverage, and a configurable WebSocket load-test harness with metrics and result output.

Changes

Server validation

Layer / File(s) Summary
Runtime commands and worker lifecycle
package.json, src/server/Worker.ts, src/server/WorkerLobbyService.ts
Added Bun startup, development, E2E, and performance scripts. Workers now exit after IPC disconnects, and IPC send failures are logged.
End-to-end test infrastructure
tsconfig.json, vite.config.ts, vitest.e2e.config.ts, tests/e2e/util.ts
Added dedicated E2E configuration and utilities for server processes, HTTP requests, and WebSocket clients.
Game and worker lifecycle coverage
tests/e2e/GameFlow.test.ts, tests/e2e/WorkerLifecycle.test.ts
Added tests for game flow, permissions, messaging, reconnects, worker routing, malformed messages, and worker shutdown.
WebSocket load testing
tests/load/loadtest.mjs, tests/load/README.md, .gitignore, eslint.config.js
Added configurable Node and Bun load testing with latency, throughput, resource, error, and result reporting. Documented usage and ignored generated results.

Estimated code review effort: 4 (Complex) | ~45 minutes

Possibly related PRs

Suggested reviewers: wraith4081, celant

Poem

Bun starts bright, workers heed the call,
WebSockets gather, measuring all.
Games begin and turns flow through,
Tests guard lifecycles, old and new.
Results settle in JSON light.

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly summarizes the main changes: the load-test harness, E2E suite, and Bun runtime support.
Description check ✅ Passed The description directly explains the load tests, E2E coverage, Bun support, lifecycle fixes, results, and recommendation.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches 💡 1
🛠️ Fix failing CI checks 💡
  • Create stacked PR
  • Commit on current branch

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 6

🧹 Nitpick comments (2)
tests/e2e/WorkerLifecycle.test.ts (1)

44-48: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Consider asserting the shutdown reason, not only the exit.

The test proves the workers are gone. It does not prove they left through the new disconnect path in src/server/Worker.ts. A worker that crashes for an unrelated reason also passes. TestServer already captures the server output, so you can check for the log line that the new handler writes.

♻️ Proposed addition
     await waitFor(
       () => workerPids.every((pid) => !pidAlive(pid)),
       10_000,
       `workers ${workerPids.join(",")} to exit after master SIGKILL`,
     );
+
+    // The workers must leave through the IPC-disconnect path, not by crashing.
+    const shutdownLogs = server.logs.filter((l) =>
+      l.includes("IPC channel to master closed"),
+    );
+    expect(shutdownLogs.length).toBeGreaterThanOrEqual(workerPids.length);
   });
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@tests/e2e/WorkerLifecycle.test.ts` around lines 44 - 48, Extend the worker
shutdown assertion in WorkerLifecycle.test.ts to verify TestServer’s captured
output contains the disconnect-handler log emitted by Worker.ts, in addition to
confirming all worker PIDs have exited. Assert the expected log entry for each
relevant worker so unrelated crashes cannot satisfy the test.
vite.config.ts (1)

256-258: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Keep Vitest’s default excludes and add tests/e2e/**.

Import configDefaults from vitest/config and use [...configDefaults.exclude, "tests/e2e/**"]. The current array removes Vitest’s defaults, including .git and .cache.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@vite.config.ts` around lines 256 - 258, Update the Vitest configuration’s
exclude option near the E2E test settings to import and reuse configDefaults
from vitest/config, preserving Vitest’s default exclusions while adding
tests/e2e/**. Replace the custom exclusion list rather than duplicating or
removing the existing defaults.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@tests/e2e/GameFlow.test.ts`:
- Around line 113-120: Remove the upper-bound assertion on gained in the “server
broadcasts turns at the 100ms tick” test; retain the lower-bound check that
verifies broadcasting continues, without constraining scheduler timing from
sleep(1200).

In `@tests/e2e/util.ts`:
- Around line 106-123: Update the output-handling logic that populates logs so
stdout and stderr chunks are buffered across writes, split into complete lines,
and only complete lines are stored. Ensure workerPids() and masterPid() continue
matching against reconstructed lines, including when a log message is divided
across chunk boundaries, while retaining any incomplete trailing fragment for
the next chunk.
- Around line 79-103: Update the server startup flow around the spawned process
and health wait to attach an error listener, track early exit or spawn failure,
and stop polling when the process is no longer running. When startup fails,
include the captured stdout/stderr logs in the reported error so missing
binaries and early server crashes are diagnosable; preserve the existing
successful health-check behavior.
- Around line 134-153: Update the stop() cleanup flow around the existing
waitFor call so that after the SIGKILL fallback in its catch handler, it waits
again for all ports from MASTER_PORT through workerPort(NUM_WORKERS - 1) to stop
serving HTTP before returning. Preserve the current immediate cleanup for
already-released ports and keep the final this.proc reset after the post-kill
verification.

In `@tests/load/loadtest.mjs`:
- Around line 583-590: Reset or snapshot the throughput counters msgsReceived,
bytesReceived, and intentsSent immediately before setting metrics.measuring =
true, after the warmup sleep. Ensure the measurement window and subsequent
DURATION_S-based calculations use only traffic generated during the measured
interval.
- Around line 156-159: Update the SIGINT shutdown handler around stopServer to
handle both SIGINT and SIGTERM, ensuring either signal stops the detached server
before exiting with status 130.

---

Nitpick comments:
In `@tests/e2e/WorkerLifecycle.test.ts`:
- Around line 44-48: Extend the worker shutdown assertion in
WorkerLifecycle.test.ts to verify TestServer’s captured output contains the
disconnect-handler log emitted by Worker.ts, in addition to confirming all
worker PIDs have exited. Assert the expected log entry for each relevant worker
so unrelated crashes cannot satisfy the test.

In `@vite.config.ts`:
- Around line 256-258: Update the Vitest configuration’s exclude option near the
E2E test settings to import and reuse configDefaults from vitest/config,
preserving Vitest’s default exclusions while adding tests/e2e/**. Replace the
custom exclusion list rather than duplicating or removing the existing defaults.
🪄 Autofix

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: b309cccb-7ff1-4dd8-9eaa-b3907213654d

📥 Commits

Reviewing files that changed from the base of the PR and between c5c7d74 and 8c2b48e.

📒 Files selected for processing (13)
  • .gitignore
  • eslint.config.js
  • package.json
  • src/server/Worker.ts
  • src/server/WorkerLobbyService.ts
  • tests/e2e/GameFlow.test.ts
  • tests/e2e/WorkerLifecycle.test.ts
  • tests/e2e/util.ts
  • tests/load/README.md
  • tests/load/loadtest.mjs
  • tsconfig.json
  • vite.config.ts
  • vitest.e2e.config.ts

Comment on lines +113 to +120
test("server broadcasts turns at the 100ms tick", async () => {
const before = creator.turns().length;
await sleep(1200);
const after = creator.turns().length;
const gained = after - before;
// ~12 expected; allow generous slack for CI jitter.
expect(gained).toBeGreaterThanOrEqual(8);
expect(gained).toBeLessThanOrEqual(16);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Drop the upper bound on the turn count.

The lower bound checks the real property: the server keeps broadcasting turns. The upper bound checks how accurately this test process wakes from sleep(1200). Under CI load the sleep can overrun by hundreds of milliseconds while the server keeps ticking, so gained exceeds 16 and the test fails for a reason unrelated to the server. Measure the elapsed time and derive the expectation from it, or assert only the lower bound.

💚 Proposed fix
   test("server broadcasts turns at the 100ms tick", async () => {
     const before = creator.turns().length;
+    const t0 = Date.now();
     await sleep(1200);
+    const elapsed = Date.now() - t0;
     const after = creator.turns().length;
     const gained = after - before;
-    // ~12 expected; allow generous slack for CI jitter.
-    expect(gained).toBeGreaterThanOrEqual(8);
-    expect(gained).toBeLessThanOrEqual(16);
+    // ~1 turn per 100ms. Scale the bound to the time we actually slept, so a
+    // slow test process does not fail the server.
+    expect(gained).toBeGreaterThanOrEqual(8);
+    expect(gained).toBeLessThanOrEqual(Math.ceil(elapsed / 100) + 4);
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
test("server broadcasts turns at the 100ms tick", async () => {
const before = creator.turns().length;
await sleep(1200);
const after = creator.turns().length;
const gained = after - before;
// ~12 expected; allow generous slack for CI jitter.
expect(gained).toBeGreaterThanOrEqual(8);
expect(gained).toBeLessThanOrEqual(16);
test("server broadcasts turns at the 100ms tick", async () => {
const before = creator.turns().length;
const t0 = Date.now();
await sleep(1200);
const elapsed = Date.now() - t0;
const after = creator.turns().length;
const gained = after - before;
// ~1 turn per 100ms. Scale the bound to the time we actually slept, so a
// slow test process does not fail the server.
expect(gained).toBeGreaterThanOrEqual(8);
expect(gained).toBeLessThanOrEqual(Math.ceil(elapsed / 100) + 4);
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@tests/e2e/GameFlow.test.ts` around lines 113 - 120, Remove the upper-bound
assertion on gained in the “server broadcasts turns at the 100ms tick” test;
retain the lower-bound check that verifies broadcasting continues, without
constraining scheduler timing from sleep(1200).

Comment thread tests/e2e/util.ts
Comment on lines +79 to +103
const [cmd, args] = serverCommand();
this.proc = spawn(cmd, args, {
cwd: repoRoot,
env: serverEnv,
detached: true, // own process group so stop() can kill the whole tree
stdio: ["ignore", "pipe", "pipe"],
});
this.proc.stdout!.on("data", (d) => this.logs.push(String(d)));
this.proc.stderr!.on("data", (d) => this.logs.push(String(d)));

await waitFor(
async () => {
try {
const res = await fetch(
`http://127.0.0.1:${MASTER_PORT}/api/health`,
{ signal: AbortSignal.timeout(1000) },
);
return res.ok;
} catch {
return false;
}
},
60_000,
"server health",
);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 Stability & Availability | 🟡 Minor | ⚡ Quick win

Handle spawn failure and early server exit.

spawn emits an error event when the command is missing. bun is an external binary and may not be installed. No error listener is attached, so Node re-throws the event as an unhandled error in the test process. If the server instead starts and then crashes, start() waits the full 60 seconds and fails with "server health" only. The captured logs are never shown, so the cause stays hidden.

Track the exit state and include the logs in the failure.

🛠️ Proposed fix
     const [cmd, args] = serverCommand();
     this.proc = spawn(cmd, args, {
       cwd: repoRoot,
       env: serverEnv,
       detached: true, // own process group so stop() can kill the whole tree
       stdio: ["ignore", "pipe", "pipe"],
     });
     this.proc.stdout!.on("data", (d) => this.logs.push(String(d)));
     this.proc.stderr!.on("data", (d) => this.logs.push(String(d)));
+    let exited: string | null = null;
+    // Without an `error` listener Node throws the event at the test process.
+    this.proc.on("error", (err) => {
+      exited = `failed to spawn ${cmd}: ${err.message}`;
+    });
+    this.proc.on("exit", (code, signal) => {
+      exited ??= `server exited early (code ${code}, signal ${signal})`;
+    });
 
-    await waitFor(
-      async () => {
-        try {
-          const res = await fetch(
-            `http://127.0.0.1:${MASTER_PORT}/api/health`,
-            { signal: AbortSignal.timeout(1000) },
-          );
-          return res.ok;
-        } catch {
-          return false;
-        }
-      },
-      60_000,
-      "server health",
-    );
+    try {
+      await waitFor(
+        async () => {
+          if (exited !== null) throw new Error(exited);
+          try {
+            const res = await fetch(
+              `http://127.0.0.1:${MASTER_PORT}/api/health`,
+              { signal: AbortSignal.timeout(1000) },
+            );
+            return res.ok;
+          } catch {
+            return false;
+          }
+        },
+        60_000,
+        "server health",
+      );
+    } catch (err) {
+      throw new Error(`${err}\n--- server output ---\n${this.logs.join("")}`);
+    }
   }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
const [cmd, args] = serverCommand();
this.proc = spawn(cmd, args, {
cwd: repoRoot,
env: serverEnv,
detached: true, // own process group so stop() can kill the whole tree
stdio: ["ignore", "pipe", "pipe"],
});
this.proc.stdout!.on("data", (d) => this.logs.push(String(d)));
this.proc.stderr!.on("data", (d) => this.logs.push(String(d)));
await waitFor(
async () => {
try {
const res = await fetch(
`http://127.0.0.1:${MASTER_PORT}/api/health`,
{ signal: AbortSignal.timeout(1000) },
);
return res.ok;
} catch {
return false;
}
},
60_000,
"server health",
);
const [cmd, args] = serverCommand();
this.proc = spawn(cmd, args, {
cwd: repoRoot,
env: serverEnv,
detached: true, // own process group so stop() can kill the whole tree
stdio: ["ignore", "pipe", "pipe"],
});
this.proc.stdout!.on("data", (d) => this.logs.push(String(d)));
this.proc.stderr!.on("data", (d) => this.logs.push(String(d)));
let exited: string | null = null;
// Without an `error` listener Node throws the event at the test process.
this.proc.on("error", (err) => {
exited = `failed to spawn ${cmd}: ${err.message}`;
});
this.proc.on("exit", (code, signal) => {
exited ??= `server exited early (code ${code}, signal ${signal})`;
});
try {
await waitFor(
async () => {
if (exited !== null) throw new Error(exited);
try {
const res = await fetch(
`http://127.0.0.1:${MASTER_PORT}/api/health`,
{ signal: AbortSignal.timeout(1000) },
);
return res.ok;
} catch {
return false;
}
},
60_000,
"server health",
);
} catch (err) {
throw new Error(`${err}\n--- server output ---\n${this.logs.join("")}`);
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@tests/e2e/util.ts` around lines 79 - 103, Update the server startup flow
around the spawned process and health wait to attach an error listener, track
early exit or spawn failure, and stop polling when the process is no longer
running. When startup fails, include the captured stdout/stderr logs in the
reported error so missing binaries and early server crashes are diagnosable;
preserve the existing successful health-check behavior.

Comment thread tests/e2e/util.ts
Comment on lines +106 to +123
// PIDs (from the server's own logs) of the cluster worker processes.
workerPids(): number[] {
const pids: number[] = [];
for (const line of this.logs) {
for (const m of line.matchAll(/Started worker \d+ \(PID: (\d+)\)/g)) {
pids.push(Number(m[1]));
}
}
return pids;
}

masterPid(): number | null {
for (const line of this.logs) {
const m = line.match(/Primary (\d+) is running/);
if (m) return Number(m[1]);
}
return null;
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 Stability & Availability | 🟡 Minor | ⚡ Quick win

Split captured output into lines before matching.

Lines 86-87 push raw stdout and stderr chunks into logs. A chunk boundary can cut a log line in two. Started worker 1 (PID: 12345) can then arrive as Started worker 1 (PI plus D: 12345), and neither part matches the regex. WorkerLifecycle.test.ts asserts workerPids.length is at least 2, so this makes that test flaky.

Buffer the output and store complete lines.

🛠️ Proposed fix
 export class TestServer {
   proc: ChildProcess | null = null;
   logs: string[] = [];
+  private pending = "";
+
+  // stdout arrives in chunks that can cut a log line in half, so keep the
+  // tail until its newline shows up.
+  private capture(chunk: string): void {
+    this.pending += chunk;
+    const lines = this.pending.split("\n");
+    this.pending = lines.pop() ?? "";
+    this.logs.push(...lines);
+  }
-    this.proc.stdout!.on("data", (d) => this.logs.push(String(d)));
-    this.proc.stderr!.on("data", (d) => this.logs.push(String(d)));
+    this.proc.stdout!.on("data", (d) => this.capture(String(d)));
+    this.proc.stderr!.on("data", (d) => this.capture(String(d)));
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@tests/e2e/util.ts` around lines 106 - 123, Update the output-handling logic
that populates logs so stdout and stderr chunks are buffered across writes,
split into complete lines, and only complete lines are stored. Ensure
workerPids() and masterPid() continue matching against reconstructed lines,
including when a log message is divided across chunk boundaries, while retaining
any incomplete trailing fragment for the next chunk.

Comment thread tests/e2e/util.ts
Comment on lines +134 to +153
await waitFor(
async () => {
for (let p = MASTER_PORT; p <= workerPort(NUM_WORKERS - 1); p++) {
if (await portServesHttp(p)) return false;
}
return true;
},
15_000,
"server ports released",
).catch(() => {
// Last resort: SIGKILL the group.
if (this.proc?.pid) {
try {
process.kill(-this.proc.pid, "SIGKILL");
} catch {
// group already gone
}
}
});
this.proc = null;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 Stability & Availability | 🟡 Minor | ⚡ Quick win

Wait for the ports again after the SIGKILL fallback.

The catch handler sends SIGKILL and then stop() returns at once. It never confirms that the ports are free. fileParallelism is false, so the next suite starts right after. Its start() pre-flight can still see port 3000 serving HTTP and throw "kill strays first", which fails an unrelated suite.

Wait for release after the SIGKILL.

🛠️ Proposed fix
+  private portsFree = async (): Promise<boolean> => {
+    for (let p = MASTER_PORT; p <= workerPort(NUM_WORKERS - 1); p++) {
+      if (await portServesHttp(p)) return false;
+    }
+    return true;
+  };
+
   async stop(): Promise<void> {
     if (this.proc?.pid) {
       try {
         process.kill(-this.proc.pid, "SIGTERM");
       } catch {
         // already dead
       }
     }
     // Wait until every port is released so the next suite can bind.
-    await waitFor(
-      async () => {
-        for (let p = MASTER_PORT; p <= workerPort(NUM_WORKERS - 1); p++) {
-          if (await portServesHttp(p)) return false;
-        }
-        return true;
-      },
-      15_000,
-      "server ports released",
-    ).catch(() => {
-      // Last resort: SIGKILL the group.
-      if (this.proc?.pid) {
-        try {
-          process.kill(-this.proc.pid, "SIGKILL");
-        } catch {
-          // group already gone
-        }
-      }
-    });
+    try {
+      await waitFor(this.portsFree, 15_000, "server ports released");
+    } catch {
+      // Last resort: SIGKILL the group, then confirm the ports really go.
+      if (this.proc?.pid) {
+        try {
+          process.kill(-this.proc.pid, "SIGKILL");
+        } catch {
+          // group already gone
+        }
+      }
+      await waitFor(this.portsFree, 10_000, "server ports released after kill");
+    }
     this.proc = null;
   }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
await waitFor(
async () => {
for (let p = MASTER_PORT; p <= workerPort(NUM_WORKERS - 1); p++) {
if (await portServesHttp(p)) return false;
}
return true;
},
15_000,
"server ports released",
).catch(() => {
// Last resort: SIGKILL the group.
if (this.proc?.pid) {
try {
process.kill(-this.proc.pid, "SIGKILL");
} catch {
// group already gone
}
}
});
this.proc = null;
private portsFree = async (): Promise<boolean> => {
for (let p = MASTER_PORT; p <= workerPort(NUM_WORKERS - 1); p++) {
if (await portServesHttp(p)) return false;
}
return true;
};
async stop(): Promise<void> {
if (this.proc?.pid) {
try {
process.kill(-this.proc.pid, "SIGTERM");
} catch {
// already dead
}
}
// Wait until every port is released so the next suite can bind.
try {
await waitFor(this.portsFree, 15_000, "server ports released");
} catch {
// Last resort: SIGKILL the group, then confirm the ports really go.
if (this.proc?.pid) {
try {
process.kill(-this.proc.pid, "SIGKILL");
} catch {
// group already gone
}
}
await waitFor(this.portsFree, 10_000, "server ports released after kill");
}
this.proc = null;
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@tests/e2e/util.ts` around lines 134 - 153, Update the stop() cleanup flow
around the existing waitFor call so that after the SIGKILL fallback in its catch
handler, it waits again for all ports from MASTER_PORT through
workerPort(NUM_WORKERS - 1) to stop serving HTTP before returning. Preserve the
current immediate cleanup for already-released ports and keep the final
this.proc reset after the post-kill verification.

Comment thread tests/load/loadtest.mjs
Comment on lines +156 to +159
process.on("SIGINT", () => {
stopServer();
process.exit(130);
});

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail
rg -n -C 4 'detached: true|process\.on\("SIG(INT|TERM)"|function stopServer' tests/load/loadtest.mjs

Repository: openfrontio/OpenFrontIO

Length of output: 842


🏁 Script executed:

#!/bin/bash
set -euo pipefail
sed -n '100,175p' tests/load/loadtest.mjs

node - <<'JS'
const { spawn } = require("node:child_process");
const fs = require("node:fs");
const path = require("node:path");

const file = path.join("/tmp", "signal-handler-probe.mjs");
fs.writeFileSync(file, `
process.on("SIGINT", () => process.stdout.write("SIGINT handled\\n"));
setInterval(() => {}, 1000);
`);

const child = spawn(process.execPath, [file], { stdio: ["ignore", "pipe", "inherit"] });
let output = "";
child.stdout.on("data", data => { output += data; });
setTimeout(() => child.kill("SIGTERM"), 100);
child.on("exit", (code, signal) => {
  console.log(JSON.stringify({ code, signal, output }));
});
JS

Repository: openfrontio/OpenFrontIO

Length of output: 2348


Stop the detached server on SIGTERM.

When CI sends SIGTERM to the harness, the SIGINT handler does not run. The detached server process group can remain alive and retain its ports. Use one shutdown handler for both signals.

Proposed fix
-process.on("SIGINT", () => {
+function exitWithServerStopped(code) {
   stopServer();
-  process.exit(130);
-});
+  process.exit(code);
+}
+
+process.on("SIGINT", () => exitWithServerStopped(130));
+process.on("SIGTERM", () => exitWithServerStopped(143));
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
process.on("SIGINT", () => {
stopServer();
process.exit(130);
});
function exitWithServerStopped(code) {
stopServer();
process.exit(code);
}
process.on("SIGINT", () => exitWithServerStopped(130));
process.on("SIGTERM", () => exitWithServerStopped(143));
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@tests/load/loadtest.mjs` around lines 156 - 159, Update the SIGINT shutdown
handler around stopServer to handle both SIGINT and SIGTERM, ensuring either
signal stops the detached server before exiting with status 130.

Comment thread tests/load/loadtest.mjs
Comment on lines +583 to +590
for (const g of games) for (const c of g.clients) c.startTraffic();
await sleep(5000); // warmup: JIT, connection settling

console.log(`measuring for ${DURATION_S}s...`);
const windowStart = Date.now();
metrics.measuring = true;
await sleep(DURATION_S * 1000);
metrics.measuring = false;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Start throughput counters at the measurement boundary.

Lines 583-584 start traffic before the warmup. msgsReceived, bytesReceived, and intentsSent include warmup traffic, but Lines 604-606 divide them by DURATION_S only. This overstates reported throughput.

Reset or snapshot these counters immediately before metrics.measuring = true.

Proposed fix
   console.log(`measuring for ${DURATION_S}s...`);
+  for (const g of games) {
+    for (const c of g.clients) {
+      c.msgsReceived = 0;
+      c.bytesReceived = 0;
+      c.intentsSent = 0;
+    }
+  }
   const windowStart = Date.now();
   metrics.measuring = true;
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
for (const g of games) for (const c of g.clients) c.startTraffic();
await sleep(5000); // warmup: JIT, connection settling
console.log(`measuring for ${DURATION_S}s...`);
const windowStart = Date.now();
metrics.measuring = true;
await sleep(DURATION_S * 1000);
metrics.measuring = false;
for (const g of games) for (const c of g.clients) c.startTraffic();
await sleep(5000); // warmup: JIT, connection settling
console.log(`measuring for ${DURATION_S}s...`);
for (const g of games) {
for (const c of g.clients) {
c.msgsReceived = 0;
c.bytesReceived = 0;
c.intentsSent = 0;
}
}
const windowStart = Date.now();
metrics.measuring = true;
await sleep(DURATION_S * 1000);
metrics.measuring = false;
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@tests/load/loadtest.mjs` around lines 583 - 590, Reset or snapshot the
throughput counters msgsReceived, bytesReceived, and intentsSent immediately
before setting metrics.measuring = true, after the warmup sleep. Ensure the
measurement window and subsequent DURATION_S-based calculations use only traffic
generated during the measured interval.

@github-project-automation github-project-automation Bot moved this from Triage to Development in OpenFront Release Management Aug 12, 2026
@evanpelle evanpelle closed this Aug 12, 2026
@github-project-automation github-project-automation Bot moved this from Development to Complete in OpenFront Release Management Aug 12, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

Status: Complete

Development

Successfully merging this pull request may close these issues.

1 participant