Skip to content

Commit c66a2e5

Browse files
committed
fix(codex): fail detached-but-leased clients on suspect retirement
1 parent d65ee9b commit c66a2e5

2 files changed

Lines changed: 41 additions & 0 deletions

File tree

extensions/codex/src/app-server/shared-client.test.ts

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -797,6 +797,32 @@ describe("shared Codex app-server client", () => {
797797
expect(second.process.stdin.destroyed).toBe(false);
798798
});
799799

800+
it("suspect retirement closes a client that was already gracefully detached", async () => {
801+
const first = createClientHarness();
802+
vi.spyOn(CodexAppServerClient, "start").mockReturnValueOnce(first.client);
803+
804+
const lease = getLeasedSharedCodexAppServerClient({ timeoutMs: 1000 });
805+
await sendInitializeResult(first, "openclaw/0.142.0 (macOS; test)");
806+
await expect(lease).resolves.toBe(first.client);
807+
808+
// Routine cleanup detaches gracefully; a later terminal-idle kill must
809+
// still be able to fail the leaseholders off the poisoned process.
810+
expect(retireSharedCodexAppServerClientIfCurrent(first.client)).toEqual({
811+
activeLeases: 1,
812+
closed: false,
813+
});
814+
expect(first.process.stdin.destroyed).toBe(false);
815+
816+
expect(
817+
retireSharedCodexAppServerClientIfCurrent(first.client, { failActiveLeases: true }),
818+
).toEqual({
819+
activeLeases: 1,
820+
closed: true,
821+
});
822+
expect(first.process.stdin.destroyed).toBe(true);
823+
expect(releaseLeasedSharedCodexAppServerClient(first.client)).toBe(true);
824+
});
825+
800826
it("retires gracefully by default: leased clients close on release, not immediately", async () => {
801827
const first = createClientHarness();
802828
vi.spyOn(CodexAppServerClient, "start").mockReturnValueOnce(first.client);

extensions/codex/src/app-server/shared-client.ts

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,10 @@ type SharedCodexAppServerClientState = {
3434
leasedReleases: WeakMap<CodexAppServerClient, Array<() => void>>;
3535
};
3636

37+
// Clients we already force-closed for suspect retirement; a repeat retire must
38+
// report closed:false instead of pretending to close the corpse again.
39+
const suspectClosedClients = new WeakSet<CodexAppServerClient>();
40+
3741
// Symbol.for shares one client table across duplicate module copies (dist +
3842
// src bundles in one process). Plugin updates restart the gateway, so every
3943
// copy writing this state runs the same code and the shape never migrates.
@@ -436,6 +440,9 @@ export function retireSharedCodexAppServerClientIfCurrent(
436440
if (opts?.failActiveLeases) {
437441
entry.closeError = new Error("codex app-server client is closed");
438442
const closed = closeRetiredSharedClientEntry(entry);
443+
if (closed) {
444+
suspectClosedClients.add(client);
445+
}
439446
return { activeLeases: entry.activeLeases, closed };
440447
}
441448
const closed = closeRetiredSharedClientEntryIfIdle(entry);
@@ -444,6 +451,14 @@ export function retireSharedCodexAppServerClientIfCurrent(
444451
}
445452
const activeLeases = state.leasedReleases.get(client)?.length ?? 0;
446453
if (activeLeases > 0) {
454+
// A gracefully detached client (e.g. one-shot cleanup) can still be leased
455+
// when a later terminal-idle kill declares it suspect; the map miss must
456+
// not let the poisoned process keep serving those co-leases.
457+
if (opts?.failActiveLeases && !suspectClosedClients.has(client)) {
458+
suspectClosedClients.add(client);
459+
client.close();
460+
return { activeLeases, closed: true };
461+
}
447462
return { activeLeases, closed: false };
448463
}
449464
return undefined;

0 commit comments

Comments
 (0)