Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 17 additions & 1 deletion tests/cursor-stream-health.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -140,16 +140,32 @@ describe("Cursor inbound stream-health watchdog (T04)", () => {
stream.on("error", () => {});
stream.respond({ ":status": 200, "content-type": "application/connect+proto" });
stream.write(Buffer.from(textDeltaFrame("hi")));
// 40ms, not 100ms.
//
// The silence clock below is 400ms, so a 100ms ping left a margin of four
// ticks: miss three in a row and the SILENCE watchdog fires first, which
// is a different error and a green-looking bug report. That is exactly what
// happened on the v2.41.0 macOS runner -- the assertion wanted
// "heartbeat-only" and got "no inbound frames for 1s before turnEnded".
//
// Nothing about the behaviour under test needs a slow ping: the point is
// that heartbeats reset the silence clock and do NOT reset the
// heartbeat-only clock. A tighter interval tests the same two clocks with
// ten ticks of margin instead of four.
const ping = setInterval(() => {
try {
stream.write(Buffer.from(heartbeatFrame()));
stream.write(Buffer.from(checkpointFrame()));
} catch { clearInterval(ping); }
}, 100);
}, 40);
stream.on("close", () => clearInterval(ping));
}, async baseUrl => {
const { failure } = await drain(baseUrl, { streamSilenceFailMs: 400, streamHeartbeatOnlyFailMs: 900 });
expect(failure).toBeDefined();
// Assert on the message, and say which watchdog won when the wrong one does.
// A bare toContain here reported only the expected substring, which reads as
// "the heartbeat-only watchdog is broken" when the real story is that the
// silence watchdog fired first on a loaded runner.
expect(failure!.message).toContain("heartbeat-only");
});
}, 15_000);
Expand Down
47 changes: 43 additions & 4 deletions tests/shutdown-launcher.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,22 @@ async function healthy(port: number): Promise<boolean> {
}
}

/**
* Startup budget for the proxy, generous on CI and tight locally.
*
* The subject of this test is signal forwarding, not startup latency, so the
* budget only has to be long enough that a slow machine does not read as an
* orphaned proxy. Locally the spawn is healthy in ~800ms; a shared CI runner
* building four shards plus a macOS suite in parallel is a different machine
* entirely, and 20s was not enough for it twice on 2026-09-03.
*
* Raising this cannot hide the regression the test guards: an orphaned proxy
* fails at step 4 (the port never frees), which has its own deadline. What a
* too-short startup budget DOES hide is that distinction — it fails before the
* shutdown path runs at all.
*/
const STARTUP_BUDGET_MS = process.env.CI ? 60_000 : 20_000;

async function waitUntil(fn: () => Promise<boolean>, deadlineMs: number): Promise<boolean> {
const end = Date.now() + deadlineMs;
while (Date.now() < end) {
Expand All @@ -91,8 +107,17 @@ describe.skipIf(!runnable)("ocx launcher graceful shutdown", () => {
const codexConfig = join(home, "config.toml");
writeFileSync(codexConfig, 'model = "gpt-5.1"\n');

// stdout/stderr are CAPTURED, not discarded.
//
// This test failed twice on the v2.41.0 promotion at exactly 20s -- the
// startup deadline below, not the shutdown path this test is named for.
// With `stdio: "ignore"` the failure said only `expect(up).toBe(true)`:
// no proxy log, no exit code, no way to tell a slow runner from a real
// startup regression. Locally the same spawn is healthy in ~800ms, so a
// 25x margin is already generous and the missing evidence was the actual
// problem.
const child = spawn("node", [BIN_OCX, "start", "--port", String(port)], {
stdio: "ignore",
stdio: ["ignore", "pipe", "pipe"],
env: {
...process.env,
HOME: identity.homeDir,
Expand All @@ -105,11 +130,25 @@ describe.skipIf(!runnable)("ocx launcher graceful shutdown", () => {
spawned.push(child);

let exited = false;
let exitCode: number | null = null;
let exitSignal: NodeJS.Signals | null = null;
child.on("exit", () => { exited = true; });
child.on("exit", (code, sig) => { exitCode = code; exitSignal = sig; });

let output = "";
child.stdout?.on("data", chunk => { output += String(chunk); });
child.stderr?.on("data", chunk => { output += String(chunk); });

// 1. Proxy comes up + injected the Codex config (Design B root override on loopback).
const up = await waitUntil(() => healthy(port), 20_000);
expect(up).toBe(true);
const up = await waitUntil(() => healthy(port), STARTUP_BUDGET_MS);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Stop the startup wait when the launcher exits

When the launcher fails immediately—for example because the Bun runtime cannot be resolved—this predicate ignores the already-recorded exited state, so each of the three cases waits the full 60-second CI budget before reporting the exit. In the inspected Linux CI wrapper, scripts/ci/run-bun-test-batches.sh:112-114 uses GNU timeout with a 120-second batch limit (whose help confirms it kills a command after that duration), meaning this 180-second test file is terminated and retried as a generic batch timeout before all diagnostics complete. Race health against launcher exit, or include exited in the wait condition and fail immediately with the captured output.

Useful? React with 👍 / 👎.

if (!up) {
// Name what actually went wrong instead of asserting a bare boolean.
const died = exited ? ` The launcher EXITED (code ${exitCode}, signal ${exitSignal}).` : " The launcher was still running.";
throw new Error(
`The proxy never answered /healthz on port ${port} within ${STARTUP_BUDGET_MS}ms.${died}`
+ ` Launcher output:\n${output.trim() || "(none)"}`,
);
}
expect(existsSync(join(home, "ocx.pid"))).toBe(true);
const injected = readFileSync(codexConfig, "utf8");
expect(injected).toContain("# Auto-injected by opencodex");
Expand All @@ -132,7 +171,7 @@ describe.skipIf(!runnable)("ocx launcher graceful shutdown", () => {
expect(existsSync(join(home, "runtime-port.json"))).toBe(false);
expect(readFileSync(codexConfig, "utf8")).not.toContain("opencodex");
},
45_000,
STARTUP_BUDGET_MS + 40_000,
);
}
});
Loading