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
20 changes: 19 additions & 1 deletion proxy/server.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -619,13 +619,31 @@ if (invokedAsScript) {
process.exit(1);
});

// A supervised stop is a SUCCESS, however it ends. server.close() waits for
// in-flight requests, and a live Claude Code session always has one (the
// streaming /v1/messages response), so the graceful path alone never
// resolves — the watchdog is the normal exit under systemd, not the
// exception. Exiting 1 there made every `systemctl stop` log
// "status=1/FAILURE", which (a) makes a crash and a clean stop
// indistinguishable in the journal and (b) trips Restart=on-failure on a
// deliberate stop. Force the laggards, report the forcing on stderr, exit 0.
const shutdown = () => {
if (!active) {
process.exit(0);
return;
}
active.close().finally(() => process.exit(0));
setTimeout(() => process.exit(1), 5000).unref();
setTimeout(() => {
process.stderr.write(
"[cache-fix] shutdown: in-flight connections still open after 5s — forcing close\n",
);
// Node >=18.2; package.json engines allows 18.0/18.1, where the
// pre-existing behavior (exit without forcing) is the only option.
if (typeof active.server.closeAllConnections === "function") {
active.server.closeAllConnections();
}
process.exit(0);
}, 5000).unref();
};
process.on("SIGTERM", shutdown);
process.on("SIGINT", shutdown);
Expand Down
81 changes: 81 additions & 0 deletions test/shutdown-exit-code.test.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
import { describe, it } from "node:test";
import assert from "node:assert/strict";
import net from "node:net";
import { spawn } from "node:child_process";

// A supervised stop must exit 0 whichever path it takes. server.close() waits
// for in-flight requests, and a live session always has one (the streaming
// /v1/messages response), so the 5s watchdog is the NORMAL exit under systemd.
// It used to exit(1) there, which made `systemctl stop` log status=1/FAILURE —
// a clean stop and a crash became indistinguishable, and Restart=on-failure
// fired on deliberate stops.

function startProxy() {
const proc = spawn(process.execPath, ["proxy/server.mjs"], {
env: { ...process.env, CACHE_FIX_PROXY_PORT: "0" },
stdio: ["pipe", "pipe", "pipe"],
});
const port = new Promise((resolve, reject) => {
let out = "";
proc.stdout.on("data", (c) => {
out += c.toString();
const m = out.match(/listening on [\d.]+:(\d+)/);
if (m) resolve(parseInt(m[1], 10));
});
proc.on("exit", (code) => reject(new Error(`Proxy exited ${code}`)));
setTimeout(() => reject(new Error("Proxy start timeout")), 5000);
});
let stderr = "";
proc.stderr.on("data", (c) => (stderr += c.toString()));
return { proc, port, stderr: () => stderr };
}

function exitOf(proc) {
return new Promise((resolve) => {
proc.on("exit", (code, signal) => resolve({ code, signal }));
});
}

describe("SIGTERM exit code", () => {
it("exits 0 when nothing is in flight", async () => {
const { proc, port } = startProxy();
await port;
const exited = exitOf(proc);
proc.kill("SIGTERM");
const { code } = await exited;
assert.equal(code, 0, "clean shutdown must exit 0");
});

it("exits 0 via the watchdog when a request is still in flight", async () => {
const { proc, port, stderr } = startProxy();
const p = await port;

// Announce a body we never finish sending: the request stays in flight,
// so server.close() cannot resolve and the watchdog path is taken.
const sock = net.createConnection(p, "127.0.0.1");
await new Promise((resolve) => sock.on("connect", resolve));
sock.write(
"POST /v1/messages HTTP/1.1\r\nHost: 127.0.0.1\r\n" +
"Content-Length: 5000\r\n\r\npartial",
);
await new Promise((r) => setTimeout(r, 300));

const exited = exitOf(proc);
const started = Date.now();
proc.kill("SIGTERM");
const { code } = await exited;
const elapsed = Date.now() - started;

assert.equal(code, 0, "watchdog shutdown must exit 0, not 1");
assert.ok(
elapsed >= 4500,
`expected the 5s watchdog path, exited after ${elapsed}ms`,
);
assert.match(
stderr(),
/forcing close/,
"the forced path must stay visible on stderr",
);
sock.destroy();
});
});