Skip to content

Commit 4f71a2c

Browse files
committed
fix(server): serialize teardown against input; escalate only a delivered SIGTERM
CodeRabbit review on PR #157, server-side findings. Serialize input with teardown (#10). `closeSession` left `closed === false` with the live adapter installed while awaiting `adapter.close()` and `kill()`, so a concurrent `send.message` could pass `ensureLiveForInput` and prompt an agent mid-reap — the turn lost or errored. Teardown is now published as a per-session promise: concurrent closes collapse onto one, and `ensureLiveForInput` awaits it before reopening. Drop queued input on close (#7). An adapter's `close()` cancels its turn and reports `idle`, which is exactly the signal `flushNext()` waits for — so a queued mid-turn message was prompted into the process about to be reaped. Closing now clears the queue first, matching `cancel`'s "stop means stop" (SPEC-35). Escalate only a delivered SIGTERM (#6). `ChildProcess.kill()` returns false when the signal was not delivered (the child is already gone); scheduling the SIGKILL anyway could fire it at a pid the OS had since recycled, hitting an unrelated process. The test double now reports delivery like Node does, and covers the false path. Correct the close() contract in subprocess-adapter's doc (#8): it said "must never throw", contradicting `AgentAdapter.close`, which states the opposite now that the manager owns bounding and swallowing. Cover the reaper's untested paths (#9): the default sweep interval and its 30s floor (which stops a short window busy-looping), and the overlapping-sweep guard. Both races are red-then-green: each new test reproduces the interleaving before the fix. 1304 server tests pass.
1 parent ef1e6bf commit 4f71a2c

6 files changed

Lines changed: 213 additions & 3 deletions

File tree

server/src/adapters/child_transport.test.ts

Lines changed: 23 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -33,9 +33,14 @@ function fakeSpawn(): {
3333
child.stdout = new EventEmitter();
3434
child.stderr = new EventEmitter();
3535
child.signals = [] as string[];
36+
// Mirrors Node: true when the signal was delivered, false when it was not
37+
// (already-exited child). `killDelivers` lets a test take the false path.
38+
child.killDelivers = true;
3639
child.kill = (signal?: string) => {
37-
child.killed = true;
3840
child.signals.push(signal ?? "SIGTERM");
41+
if (!child.killDelivers) return false;
42+
child.killed = true;
43+
return true;
3944
};
4045
children.push(child);
4146
return child as unknown as ChildProcess;
@@ -345,3 +350,20 @@ test("pid is undefined when the spawn faults (no child pid)", () => {
345350
const t = spawnLineProcess({ command: "x", cwd: "/tmp", label: "t", spawn });
346351
assert.equal(t.pid, undefined);
347352
});
353+
354+
/**
355+
* `kill()` returning false means the signal was not delivered — the child is
356+
* already gone. Escalating anyway would fire a SIGKILL at a pid the OS may have
357+
* recycled, hitting an unrelated process.
358+
*/
359+
test("dispose does not escalate when the SIGTERM was not delivered", async () => {
360+
const { spawn, children } = fakeSpawn();
361+
const t = spawnLineProcess({ command: "x", cwd: "/tmp", label: "t", spawn, killGraceMs: 5 });
362+
children[0]!.killDelivers = false;
363+
364+
t.dispose();
365+
assert.deepEqual(children[0]!.signals, ["SIGTERM"], "the attempt is still made");
366+
367+
await new Promise((r) => setTimeout(r, 25));
368+
assert.deepEqual(children[0]!.signals, ["SIGTERM"], "but no SIGKILL follows an undelivered signal");
369+
});

server/src/adapters/child_transport.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -287,11 +287,16 @@ export function spawnLineProcess(opts: SpawnLineOptions): ChildLineTransport {
287287
// Already reaped by the OS — nothing to signal, and SIGKILLing a pid that
288288
// has been recycled would hit an unrelated process.
289289
if (settled) return;
290+
// `kill()` returns false when the signal was NOT delivered (the child is
291+
// already gone). Escalating on that would schedule a SIGKILL against a pid
292+
// the OS may have recycled by then — a signal to an unrelated process.
293+
let termed = false;
290294
try {
291-
child.kill("SIGTERM");
295+
termed = child.kill("SIGTERM");
292296
} catch {
293297
/* ignore */
294298
}
299+
if (!termed) return;
295300
// Escalate once. An agent that ignores SIGTERM (or is wedged mid-turn)
296301
// would otherwise stay resident indefinitely, holding its whole RSS.
297302
if (killTimer) return;

server/src/adapters/subprocess-adapter.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -56,7 +56,9 @@ export abstract class SubprocessAdapter extends EventEmitter implements AgentAda
5656
* Graceful agent-side session release before the reap. Defaults to a no-op so
5757
* a transport without the primitive degrades to "just kill it"; subclasses
5858
* with a real close (ACP `session/close`, codex `thread/unsubscribe`)
59-
* override. Must never throw — see {@link AgentAdapter.close}.
59+
* override. An override implements the plain request only: it MAY reject and
60+
* MAY hang, because `SessionManager.closeSession` bounds it and reaps
61+
* regardless — see {@link AgentAdapter.close}.
6062
*/
6163
async close(): Promise<void> {}
6264
abstract kill(signal?: NodeJS.Signals): Promise<void>;

server/src/idle_reaper.test.ts

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -173,3 +173,56 @@ test("resolveIdleCloseMs reads minutes, defaults, and honours 0", () => {
173173
assert.equal(resolveIdleCloseMs({ MAKIT_IDLE_CLOSE_MIN: "soon" } as NodeJS.ProcessEnv), DEFAULT_IDLE_CLOSE_MS);
174174
assert.equal(resolveIdleCloseMs({ MAKIT_IDLE_CLOSE_MIN: "-3" } as NodeJS.ProcessEnv), DEFAULT_IDLE_CLOSE_MS);
175175
});
176+
177+
/**
178+
* The interval derivation and its floor were untested (review). The floor is the
179+
* load-bearing half: a short window must not turn the sweep into a busy loop.
180+
*/
181+
test("the default sweep interval is a quarter of the window, floored at 30s", () => {
182+
const armedFor = (idleCloseMs: number) => {
183+
let armed: number | undefined;
184+
new IdleReaper({
185+
sessions: () => [],
186+
close: async () => {},
187+
idleCloseMs,
188+
setTimer: (_fn, ms) => {
189+
armed = ms;
190+
return "h";
191+
},
192+
}).start();
193+
return armed;
194+
};
195+
assert.equal(armedFor(60 * 60_000), 15 * 60_000, "an hour window sweeps every 15min");
196+
assert.equal(armedFor(60_000), 30_000, "a 1min window is floored, not swept every 15s");
197+
});
198+
199+
/**
200+
* A sweep awaits an agent per close, so a slow one can still be running when the
201+
* next tick fires. Without the re-entrancy guard the same session closes twice.
202+
*/
203+
test("a sweep already in flight suppresses the next one", async () => {
204+
let releaseFirst = () => {};
205+
const gate = new Promise<void>((r) => {
206+
releaseFirst = r;
207+
});
208+
const s = session({ lastActivityAt: 0 });
209+
const closed: string[] = [];
210+
const r = new IdleReaper({
211+
sessions: () => [s],
212+
close: async (id) => {
213+
closed.push(id);
214+
await gate;
215+
(s as { closed: boolean }).closed = true;
216+
},
217+
idleCloseMs: 60_000,
218+
now: () => 10 * 60_000,
219+
});
220+
221+
const first = r.sweep();
222+
await new Promise((res) => setTimeout(res, 5));
223+
assert.deepEqual(await r.sweep(), [], "the overlapping sweep is a no-op");
224+
225+
releaseFirst();
226+
assert.deepEqual(await first, [s.id]);
227+
assert.deepEqual(closed, [s.id], "closed exactly once");
228+
});

server/src/manager.test.ts

Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2510,3 +2510,99 @@ test("closeSession reaps even when the agent's close() never settles", async ()
25102510
store.close();
25112511
}
25122512
});
2513+
2514+
/**
2515+
* Teardown must be serialized against input (review: "Serialize input with
2516+
* session teardown"). While `closeSession` awaits `adapter.close()` and `kill()`,
2517+
* the session still reads `closed === false` with the live adapter installed, so
2518+
* a racing `send.message` would pass `ensureLiveForInput` and prompt an agent
2519+
* that is being reaped — the turn is lost or errors.
2520+
*/
2521+
test("a message racing a close waits for teardown, then reopens the session", async () => {
2522+
const store = new SqliteEventStore();
2523+
const cwd = mkdtempSync(join(tmpdir(), "makit-close-race-"));
2524+
try {
2525+
const order: string[] = [];
2526+
let releaseClose = () => {};
2527+
const gate = new Promise<void>((r) => {
2528+
releaseClose = r;
2529+
});
2530+
const mgr = new SessionManager({
2531+
projects: [cwd],
2532+
store,
2533+
adapterFactory: () => {
2534+
const a: any = stubAdapter([]);
2535+
a.close = async () => {
2536+
order.push("close:start");
2537+
await gate;
2538+
order.push("close:end");
2539+
};
2540+
return a;
2541+
},
2542+
});
2543+
const projectId = mgr.listProjects()[0].id;
2544+
const s = await mgr.spawnPiSession(projectId, "racer", "pi");
2545+
2546+
const closing = mgr.closeSession(s.id); // not awaited: teardown in flight
2547+
await new Promise((r) => setTimeout(r, 10));
2548+
assert.deepEqual(order, ["close:start"], "teardown is mid-flight");
2549+
2550+
const input = (async () => {
2551+
await mgr.ensureLiveForInput(s.id);
2552+
order.push("input:live");
2553+
})();
2554+
2555+
releaseClose();
2556+
await Promise.all([closing, input]);
2557+
2558+
assert.deepEqual(
2559+
order,
2560+
["close:start", "close:end", "input:live"],
2561+
"input must not be serviced until teardown finished",
2562+
);
2563+
assert.equal(mgr.getSession(s.id)!.closed, false, "the racing message reopened it");
2564+
} finally {
2565+
rmSync(cwd, { recursive: true, force: true });
2566+
store.close();
2567+
}
2568+
});
2569+
2570+
/**
2571+
* Closing drops queued mid-turn messages, mirroring `cancel`'s "stop means stop"
2572+
* (SPEC-35). Without this, the `idle` an adapter's `close()` emits triggers
2573+
* `flushNext()` and a queued message is sent into the agent being reaped.
2574+
*/
2575+
test("closeSession drops queued mid-turn messages instead of flushing them into a dying agent", async () => {
2576+
const store = new SqliteEventStore();
2577+
const cwd = mkdtempSync(join(tmpdir(), "makit-close-queue-"));
2578+
try {
2579+
const sends: string[] = [];
2580+
const mgr = new SessionManager({
2581+
projects: [cwd],
2582+
store,
2583+
adapterFactory: () => {
2584+
const a: any = stubAdapter([]);
2585+
a.send = async (input: { text: string }) => {
2586+
sends.push(input.text);
2587+
};
2588+
a.steer = async () => false; // unsteerable → mid-turn input queues
2589+
a.close = async () => a.emit("status", "idle"); // as a real cancel does
2590+
return a;
2591+
},
2592+
});
2593+
const projectId = mgr.listProjects()[0].id;
2594+
const s = await mgr.spawnPiSession(projectId, "queued", "pi");
2595+
await s.sendUserMessage("first");
2596+
s.adapter.emit("status", "running");
2597+
await s.sendUserMessage("queued-while-busy");
2598+
assert.equal(s.queuedMessages.length, 1, "precondition: one message is queued");
2599+
2600+
await mgr.closeSession(s.id);
2601+
2602+
assert.equal(s.queuedMessages.length, 0, "the queue is dropped, not flushed");
2603+
assert.deepEqual(sends, ["first"], "no queued message reached the closing agent");
2604+
} finally {
2605+
rmSync(cwd, { recursive: true, force: true });
2606+
store.close();
2607+
}
2608+
});

server/src/manager.ts

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -184,6 +184,12 @@ export class SessionManager extends EventEmitter {
184184
private readonly sessions = new Map<string, Session>();
185185
/** pi session uuid → live makit session id, so re-attach reuses the process. */
186186
private readonly attachedByPi = new Map<string, string>();
187+
/**
188+
* In-flight closes, keyed by session id. Teardown awaits the agent, during
189+
* which the session still reads `closed === false` with its live adapter
190+
* installed — so input paths MUST await this rather than racing it.
191+
*/
192+
private readonly closeInFlight = new Map<string, Promise<void>>();
187193
/** In-flight attaches, so concurrent attach calls collapse onto one process. */
188194
private readonly attachInFlight = new Map<string, Promise<Session>>();
189195
/** In-flight draft promotions, keyed by session id, so concurrent first
@@ -1204,6 +1210,27 @@ export class SessionManager extends EventEmitter {
12041210
const session = this.sessions.get(id);
12051211
if (!session) throw new Error(`no such session: ${id}`);
12061212
if (session.closed) return;
1213+
// Collapse concurrent closes (a user tap racing an idle sweep) onto one
1214+
// teardown, and publish it so input paths can wait instead of prompting an
1215+
// agent that is being reaped.
1216+
const already = this.closeInFlight.get(id);
1217+
if (already) return already;
1218+
const run = this.runClose(id, session);
1219+
this.closeInFlight.set(id, run);
1220+
try {
1221+
await run;
1222+
} finally {
1223+
this.closeInFlight.delete(id);
1224+
}
1225+
}
1226+
1227+
/** The teardown itself; {@link closeSession} owns de-duplication. */
1228+
private async runClose(id: string, session: Session): Promise<void> {
1229+
// Stop means stop (SPEC-35), as with `cancel`: drop pending mid-turn input
1230+
// before the agent is asked to close. Otherwise the `idle` an adapter emits
1231+
// while cancelling its turn triggers `flushNext()` and a queued message is
1232+
// prompted into the process we are about to reap.
1233+
session.clearQueue();
12071234
// Best-effort, in order: closing is also the recovery path in the
12081235
// removeWorktree loop (called after git already deleted the tree), so
12091236
// neither a rejecting close() nor a rejecting kill() may abort the close —
@@ -1263,6 +1290,11 @@ export class SessionManager extends EventEmitter {
12631290
* sessions for exactly that reason.
12641291
*/
12651292
async ensureLiveForInput(sessionId: string): Promise<void> {
1293+
// A close in flight still reads `closed === false` with the live adapter
1294+
// installed, so acting now would prompt an agent mid-reap. Let teardown
1295+
// finish, then bring the session back deliberately.
1296+
const closing = this.closeInFlight.get(sessionId);
1297+
if (closing) await closing.catch(() => {});
12661298
const session = this.sessions.get(sessionId);
12671299
if (session?.closed) {
12681300
await this.reopenSession(sessionId);

0 commit comments

Comments
 (0)