Skip to content

Commit 6cfb086

Browse files
committed
fix(codex): close app-server stdio gracefully
1 parent bee3a73 commit 6cfb086

4 files changed

Lines changed: 45 additions & 15 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -163,6 +163,7 @@ Docs: https://docs.openclaw.ai
163163
### Fixes
164164

165165
- Gateway/watch: leave `OPENCLAW_TRACE_SYNC_IO` disabled by default in `pnpm gateway:watch:raw` so watch mode avoids noisy Node sync-I/O stack traces unless explicitly requested.
166+
- Codex app-server: close stdio stdin before force-killing the managed app-server, matching Codex single-client shutdown behavior and avoiding unsettled CLI exits after successful runs.
166167
- CLI/Codex: dispose registered agent harnesses during short-lived CLI shutdown so successful Codex-backed `agent --local` runs do not leave app-server child processes alive.
167168
- Agents/Codex: auto-enable the Codex harness plugin for one-shot OpenAI model overrides so `openclaw agent --local --model openai/...` does not fail with an unregistered `codex` harness.
168169
- Gateway/live tests: avoid full model-registry enumeration for explicit provider-qualified live model filters, preventing `.profile` OpenAI gateway profile runs from hanging before provider dispatch.

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

Lines changed: 33 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -242,7 +242,7 @@ describe("CodexAppServerClient", () => {
242242
expect(harness.writes).toHaveLength(1);
243243
});
244244

245-
it("force-stops app-server transports that ignore the graceful signal", async () => {
245+
it("waits for app-server transports to exit after closing stdin before force-stopping", async () => {
246246
vi.useFakeTimers();
247247
const process = Object.assign(new EventEmitter(), {
248248
stdin: {
@@ -261,7 +261,8 @@ describe("CodexAppServerClient", () => {
261261

262262
__testing.closeCodexAppServerTransport(process, { forceKillDelayMs: 25 });
263263

264-
expect(process.kill).toHaveBeenCalledWith("SIGTERM");
264+
expect(process.stdin.end).toHaveBeenCalledTimes(1);
265+
expect(process.kill).not.toHaveBeenCalled();
265266
await vi.advanceTimersByTimeAsync(25);
266267
expect(process.kill).toHaveBeenCalledWith("SIGKILL");
267268
expect(process.unref).toHaveBeenCalledTimes(1);
@@ -288,16 +289,44 @@ describe("CodexAppServerClient", () => {
288289
exitTimeoutMs: 100,
289290
forceKillDelayMs: 25,
290291
});
291-
await vi.advanceTimersByTimeAsync(25);
292292

293-
expect(process.kill).toHaveBeenCalledWith("SIGTERM");
293+
expect(process.stdin.end).toHaveBeenCalledTimes(1);
294+
expect(process.kill).not.toHaveBeenCalled();
295+
await vi.advanceTimersByTimeAsync(25);
294296
expect(process.kill).toHaveBeenCalledWith("SIGKILL");
295297
process.signalCode = "SIGKILL";
296298
process.emit("exit");
297299

298300
await expect(closed).resolves.toBe(true);
299301
});
300302

303+
it("keeps async shutdown alive until the exit timeout resolves", async () => {
304+
vi.useFakeTimers();
305+
const process = Object.assign(new EventEmitter(), {
306+
stdin: {
307+
write: vi.fn(),
308+
end: vi.fn(),
309+
destroy: vi.fn(),
310+
unref: vi.fn(),
311+
},
312+
stdout: Object.assign(new PassThrough(), { unref: vi.fn() }),
313+
stderr: Object.assign(new PassThrough(), { unref: vi.fn() }),
314+
exitCode: null as number | null,
315+
signalCode: null as string | null,
316+
kill: vi.fn(),
317+
unref: vi.fn(),
318+
});
319+
320+
const closed = __testing.closeCodexAppServerTransportAndWait(process, {
321+
exitTimeoutMs: 100,
322+
forceKillDelayMs: 25,
323+
});
324+
325+
await vi.advanceTimersByTimeAsync(100);
326+
327+
await expect(closed).resolves.toBe(false);
328+
});
329+
301330
it("handles stdin write errors without crashing the process", async () => {
302331
const harness = createClientHarness();
303332
clients.push(harness.client);

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

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -96,7 +96,7 @@ describe("shared Codex app-server client", () => {
9696
await expect(listPromise).rejects.toThrow(
9797
`Codex app-server ${MIN_CODEX_APP_SERVER_VERSION} or newer is required`,
9898
);
99-
expect(harness.process.kill).toHaveBeenCalledTimes(1);
99+
expect(harness.process.stdin.destroyed).toBe(true);
100100
startSpy.mockRestore();
101101
});
102102

@@ -111,7 +111,7 @@ describe("shared Codex app-server client", () => {
111111
await expect(listCodexAppServerModels({ timeoutMs: 5 })).rejects.toThrow(
112112
"codex app-server initialize timed out",
113113
);
114-
expect(first.process.kill).toHaveBeenCalledTimes(1);
114+
expect(first.process.stdin.destroyed).toBe(true);
115115

116116
const secondList = listCodexAppServerModels({ timeoutMs: 1000 });
117117
await sendInitializeResult(second, "openclaw/0.125.0 (macOS; test)");
@@ -128,7 +128,7 @@ describe("shared Codex app-server client", () => {
128128
await expect(createIsolatedCodexAppServerClient({ timeoutMs: 5 })).rejects.toThrow(
129129
"codex app-server initialize timed out",
130130
);
131-
expect(harness.process.kill).toHaveBeenCalledTimes(1);
131+
expect(harness.process.stdin.destroyed).toBe(true);
132132
});
133133

134134
it("passes the selected auth profile through the bridge helper", async () => {
@@ -288,7 +288,7 @@ describe("shared Codex app-server client", () => {
288288
await expect(secondList).resolves.toEqual({ models: [] });
289289

290290
expect(startSpy).toHaveBeenCalledTimes(2);
291-
expect(first.process.kill).toHaveBeenCalledWith("SIGTERM");
291+
expect(first.process.stdin.destroyed).toBe(true);
292292
});
293293

294294
it("does not let a superseded shared-client failure tear down the newer client", async () => {
@@ -347,7 +347,7 @@ describe("shared Codex app-server client", () => {
347347
await expect(firstList).resolves.toEqual({ models: [] });
348348

349349
expect(clearSharedCodexAppServerClientIfCurrent(first.client)).toBe(true);
350-
expect(first.process.kill).toHaveBeenCalledWith("SIGTERM");
350+
expect(first.process.stdin.destroyed).toBe(true);
351351

352352
const secondList = listCodexAppServerModels({ timeoutMs: 1000 });
353353
await sendInitializeResult(second, "openclaw/0.125.0 (macOS; test)");
@@ -357,7 +357,7 @@ describe("shared Codex app-server client", () => {
357357
expect(clearSharedCodexAppServerClientIfCurrent(first.client)).toBe(false);
358358
expect(second.process.kill).not.toHaveBeenCalled();
359359
expect(clearSharedCodexAppServerClientIfCurrent(second.client)).toBe(true);
360-
expect(second.process.kill).toHaveBeenCalledWith("SIGTERM");
360+
expect(second.process.stdin.destroyed).toBe(true);
361361
});
362362

363363
it("uses a fresh websocket Authorization header after shared-client token rotation", async () => {

extensions/codex/src/app-server/transport.ts

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -28,11 +28,8 @@ export function closeCodexAppServerTransport(
2828
child: CodexAppServerTransport,
2929
options: { forceKillDelayMs?: number } = {},
3030
): void {
31-
child.stdout.destroy?.();
32-
child.stderr.destroy?.();
3331
child.stdin.end?.();
3432
child.stdin.destroy?.();
35-
signalCodexAppServerTransport(child, "SIGTERM");
3633
const forceKillDelayMs = options.forceKillDelayMs ?? 1_000;
3734
const forceKill = setTimeout(
3835
() => {
@@ -44,7 +41,11 @@ export function closeCodexAppServerTransport(
4441
Math.max(1, forceKillDelayMs),
4542
);
4643
forceKill.unref?.();
47-
child.once("exit", () => clearTimeout(forceKill));
44+
child.once("exit", () => {
45+
clearTimeout(forceKill);
46+
child.stdout.destroy?.();
47+
child.stderr.destroy?.();
48+
});
4849
child.unref?.();
4950
child.stdout.unref?.();
5051
child.stderr.unref?.();
@@ -95,7 +96,6 @@ async function waitForCodexAppServerTransportExit(
9596
},
9697
Math.max(1, timeoutMs),
9798
);
98-
timeout.unref?.();
9999
child.once("exit", onExit);
100100
});
101101
}

0 commit comments

Comments
 (0)