mirror of
https://github.com/cline/cline.git
synced 2026-08-28 19:48:08 +08:00
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:
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
: {}),
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user