fix(hub): flush the /shutdown 202 before daemon teardown

The /shutdown handler queued teardown on a microtask, which runs before
the event loop's write phase, so the daemon could process.exit() before
the accepted 202 was handed to the socket. Unix masked it (uv_try_write
lands small loopback writes synchronously); Windows has no such fast
path and lost the race regularly — the recurring shutdown.e2e.test.ts
'socket hang up' failures on windows-latest. Start teardown from the
response's write callback instead, with an idempotent 1s fallback so a
client that vanishes mid-write cannot strand the daemon, and send
Connection: close so the client gets a FIN rather than an abort.

Since the flakiness this compensated for is fixed at the source, restore
maxWorkers: 2 for the Windows core suite (serializing it cost ~3 min of
CI per run), and raise the e2e daemon discovery hang guard 10s→30s —
it guards against hangs, not runner speed.
This commit is contained in:
Saoud Rizwan
2026-08-24 15:42:21 -07:00
parent a5c3181b78
commit 0cfc901589
3 changed files with 43 additions and 14 deletions
@@ -86,7 +86,10 @@ async function waitForDiscovery(
childExit: Promise<{ code: number | null; signal: NodeJS.Signals | null }>,
readStderr: () => string,
): Promise<ReadyDaemon["discovery"]> {
const deadline = Date.now() + 10_000;
// A hang guard, not a timing assertion: spawning a real bun daemon on a
// 2-core hosted Windows runner regularly needs more than 10s under load, and
// failing a publish on runner speed is worse than waiting.
const deadline = Date.now() + 30_000;
while (Date.now() < deadline) {
try {
const parsed = JSON.parse(
@@ -9,10 +9,7 @@ import {
} from "@cline/shared";
import { WebSocketServer } from "ws";
import corePackage from "../../../package.json";
import {
rememberRecoverableLocalHubUrl,
verifyHubConnection,
} from "../client";
import { rememberRecoverableLocalHubUrl, verifyHubConnection } from "../client";
import { hubHasLiveSessions, retireDiscoveredHub } from "../daemon";
import {
clearHubDiscovery,
@@ -592,10 +589,19 @@ export async function startHubWebSocketServer(
res.end("Unauthorized");
return;
}
res.statusCode = 202;
res.setHeader("content-type", "application/json");
res.end(JSON.stringify({ ok: true }));
queueMicrotask(() => {
// This response races the teardown it triggers: shutdown ends in
// process.exit(), which does not flush pending socket writes. Scheduling
// teardown on a microtask ran it before the event loop ever reached its
// write phase, so the accepted 202 could be lost and the caller saw a
// socket hang up. Unix hid this because uv_try_write lands small loopback
// writes in the kernel synchronously; Windows has no such fast path and
// lost the race regularly.
let teardownStarted = false;
const startTeardown = (): void => {
if (teardownStarted) {
return;
}
teardownStarted = true;
try {
void Promise.resolve(options.onShutdownRequested?.()).catch(
() => undefined,
@@ -611,6 +617,24 @@ export async function startHubWebSocketServer(
// must not take the daemon's unhandledRejection fatal path.
closeServer().catch(() => undefined);
}
};
res.statusCode = 202;
res.setHeader("content-type", "application/json");
// Ask for a clean close so the client gets a FIN after the body rather
// than an abort from the imminent exit.
res.setHeader("connection", "close");
// A caller that vanishes mid-write must never strand the daemon: the
// write callback can then go unfired, so a timer starts the same
// (idempotent) teardown regardless. The request was already accepted.
const teardownFallback = setTimeout(startTeardown, 1_000);
teardownFallback.unref?.();
res.end(JSON.stringify({ ok: true }), () => {
// `end`'s callback fires once the body has been handed to the socket;
// setImmediate then yields a loop turn so the write actually drains.
setImmediate(() => {
clearTimeout(teardownFallback);
startTeardown();
});
});
return;
}
+7 -5
View File
@@ -18,11 +18,13 @@ export default defineConfig({
pool: "forks",
...(process.env.CI && process.platform === "win32"
? {
// The full core suite can exhaust a hosted Windows runner when two
// fork workers overlap process-heavy and SQLite-heavy test files.
// Run it serially there to prevent unexplained worker termination.
fileParallelism: false,
maxWorkers: 1,
// Two workers, not one: serializing the whole suite here cost ~3
// minutes of Windows CI per run. The worker terminations that
// motivated serializing were timing fragility in the hub suites
// (a daemon shutdown that could exit before its HTTP response
// flushed, plus hang guards tight enough to trip on runner speed),
// which are fixed at the source rather than papered over here.
maxWorkers: 2,
}
: {}),
},