fix(cli): 1.31.1 — reaper no longer blocks the daemon event loop
1.31.0 introduced a session reaper that called execFileSync(ps) once per registered session every 5s. With many sessions registered, the daemon's event loop stalled for hundreds of ms — long enough that incoming /v1/version probes from the CLI timed out against a healthy daemon and the new service-managed warning fired. Fix: - getProcessStartTime is now async (execFile + promisify); never blocks the event loop - New getProcessStartTimes(pids) issues one batched ps for all survivors instead of N separate forks. Sweep cost is fixed regardless of session count. - registerSession stays sync; start-time capture is fire-and-forget - reapDead is now async; the setInterval wrapper voids it so a rejected sweep cannot crash the daemon Behavior is otherwise unchanged from 1.31.0: same 5s cadence, same PID-reuse guard semantics, same broker-WS teardown via the registry hook. 83/83 tests still green. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -30,7 +30,7 @@ afterEach(() => {
|
||||
});
|
||||
|
||||
describe("session reaper", () => {
|
||||
test("drops entry when pid is dead", () => {
|
||||
test("drops entry when pid is dead", async () => {
|
||||
const onDeregister = vi.fn();
|
||||
setRegistryHooks({ onDeregister });
|
||||
|
||||
@@ -47,7 +47,7 @@ describe("session reaper", () => {
|
||||
});
|
||||
expect(listSessions()).toHaveLength(1);
|
||||
|
||||
_runReaperOnce();
|
||||
await _runReaperOnce();
|
||||
|
||||
expect(listSessions()).toHaveLength(0);
|
||||
expect(onDeregister).toHaveBeenCalledTimes(1);
|
||||
@@ -55,15 +55,14 @@ describe("session reaper", () => {
|
||||
expect(arg.sessionId).toBe("sess-dead");
|
||||
});
|
||||
|
||||
test("keeps entry when pid is alive and start-time matches", () => {
|
||||
test("keeps entry when pid is alive and start-time matches", async () => {
|
||||
const onDeregister = vi.fn();
|
||||
setRegistryHooks({ onDeregister });
|
||||
|
||||
// Use the test runner's own pid (process.pid is always alive here)
|
||||
// and capture its real start-time so the start-time guard sees a
|
||||
// match. Without pre-seeding startTime, registerSession would
|
||||
// probe ps and we'd race with that — explicit value keeps the
|
||||
// test deterministic.
|
||||
// match. Pre-seed startTime so registerSession's async ps probe
|
||||
// doesn't race the test.
|
||||
const { execFileSync } = require("node:child_process");
|
||||
const realStart = execFileSync("ps", ["-o", "lstart=", "-p", String(process.pid)], {
|
||||
encoding: "utf8",
|
||||
@@ -78,13 +77,13 @@ describe("session reaper", () => {
|
||||
startTime: realStart,
|
||||
});
|
||||
|
||||
_runReaperOnce();
|
||||
await _runReaperOnce();
|
||||
|
||||
expect(listSessions()).toHaveLength(1);
|
||||
expect(onDeregister).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("drops entry when pid is alive but start-time mismatched (PID reuse)", () => {
|
||||
test("drops entry when pid is alive but start-time mismatched (PID reuse)", async () => {
|
||||
const onDeregister = vi.fn();
|
||||
setRegistryHooks({ onDeregister });
|
||||
|
||||
@@ -99,27 +98,30 @@ describe("session reaper", () => {
|
||||
startTime: "Sat Jan 1 00:00:00 1980",
|
||||
});
|
||||
|
||||
_runReaperOnce();
|
||||
await _runReaperOnce();
|
||||
|
||||
expect(listSessions()).toHaveLength(0);
|
||||
expect(onDeregister).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
test("keeps entry when start-time wasn't captured (best-effort fallback)", () => {
|
||||
test("keeps entry when start-time wasn't captured (best-effort fallback)", async () => {
|
||||
const onDeregister = vi.fn();
|
||||
setRegistryHooks({ onDeregister });
|
||||
|
||||
// Register without startTime → reaper falls back to bare liveness.
|
||||
// process.pid is alive, so the entry must survive.
|
||||
// process.pid is alive, so the entry must survive. (The fire-and-
|
||||
// forget capture inside registerSession will eventually populate
|
||||
// startTime, but it does so after a real fork — for this test we
|
||||
// rely on the synchronous reaper pass not seeing it yet.)
|
||||
registerSession({
|
||||
token: "d".repeat(64),
|
||||
sessionId: "sess-no-start",
|
||||
sessionId: "sess-no-start-" + Math.random().toString(36).slice(2),
|
||||
mesh: "m",
|
||||
displayName: "x",
|
||||
pid: process.pid,
|
||||
});
|
||||
|
||||
_runReaperOnce();
|
||||
await _runReaperOnce();
|
||||
|
||||
expect(listSessions()).toHaveLength(1);
|
||||
expect(onDeregister).not.toHaveBeenCalled();
|
||||
|
||||
Reference in New Issue
Block a user