test: harden QA cleanup and update preflight

This commit is contained in:
Peter Steinberger
2026-04-25 23:16:19 +01:00
parent 81a41fe5be
commit 8f78932059
8 changed files with 132 additions and 10 deletions

View File

@@ -48,8 +48,9 @@ export function createCodexAppServerAgentHarness(options?: {
}
},
dispose: async () => {
const { clearSharedCodexAppServerClient } = await import("./src/app-server/shared-client.js");
clearSharedCodexAppServerClient();
const { clearSharedCodexAppServerClientAndWait } =
await import("./src/app-server/shared-client.js");
await clearSharedCodexAppServerClientAndWait();
},
};
}

View File

@@ -231,6 +231,38 @@ describe("CodexAppServerClient", () => {
expect(process.kill).toHaveBeenCalledWith("SIGKILL");
expect(process.unref).toHaveBeenCalledTimes(1);
});
it("waits for app-server transport exit during async shutdown", async () => {
vi.useFakeTimers();
const process = Object.assign(new EventEmitter(), {
stdin: {
write: vi.fn(),
end: vi.fn(),
destroy: vi.fn(),
unref: vi.fn(),
},
stdout: Object.assign(new PassThrough(), { unref: vi.fn() }),
stderr: Object.assign(new PassThrough(), { unref: vi.fn() }),
exitCode: null as number | null,
signalCode: null as string | null,
kill: vi.fn(),
unref: vi.fn(),
});
const closed = __testing.closeCodexAppServerTransportAndWait(process, {
exitTimeoutMs: 100,
forceKillDelayMs: 25,
});
await vi.advanceTimersByTimeAsync(25);
expect(process.kill).toHaveBeenCalledWith("SIGTERM");
expect(process.kill).toHaveBeenCalledWith("SIGKILL");
process.signalCode = "SIGKILL";
process.emit("exit");
await expect(closed).resolves.toBe(true);
});
it("handles stdin write errors without crashing the process", async () => {
const harness = createClientHarness();
clients.push(harness.client);

View File

@@ -16,7 +16,11 @@ import {
} from "./protocol.js";
import { createStdioTransport } from "./transport-stdio.js";
import { createWebSocketTransport } from "./transport-websocket.js";
import { closeCodexAppServerTransport, type CodexAppServerTransport } from "./transport.js";
import {
closeCodexAppServerTransport,
closeCodexAppServerTransportAndWait,
type CodexAppServerTransport,
} from "./transport.js";
export const MIN_CODEX_APP_SERVER_VERSION = "0.125.0";
const CODEX_APP_SERVER_PARSE_LOG_MAX = 500;
@@ -225,15 +229,20 @@ export class CodexAppServerClient {
}
close(): void {
if (this.closed) {
if (!this.markClosed(new Error("codex app-server client is closed"))) {
return;
}
this.closed = true;
this.lines.close();
this.rejectPendingRequests(new Error("codex app-server client is closed"));
closeCodexAppServerTransport(this.child);
}
async closeAndWait(options?: {
exitTimeoutMs?: number;
forceKillDelayMs?: number;
}): Promise<void> {
this.markClosed(new Error("codex app-server client is closed"));
await closeCodexAppServerTransportAndWait(this.child, options);
}
private writeMessage(message: RpcRequest | RpcResponse): void {
if (this.closed) {
return;
@@ -325,13 +334,19 @@ export class CodexAppServerClient {
}
private closeWithError(error: Error): void {
if (this.markClosed(error)) {
closeCodexAppServerTransport(this.child);
}
}
private markClosed(error: Error): boolean {
if (this.closed) {
return;
return false;
}
this.closed = true;
this.lines.close();
this.rejectPendingRequests(error);
closeCodexAppServerTransport(this.child);
return true;
}
private rejectPendingRequests(error: Error): void {
@@ -485,5 +500,6 @@ function formatExitValue(value: unknown): string {
export const __testing = {
closeCodexAppServerTransport,
closeCodexAppServerTransportAndWait,
redactCodexAppServerLinePreview,
} as const;

View File

@@ -120,6 +120,18 @@ export function clearSharedCodexAppServerClient(): void {
client?.close();
}
export async function clearSharedCodexAppServerClientAndWait(options?: {
exitTimeoutMs?: number;
forceKillDelayMs?: number;
}): Promise<void> {
const state = getSharedCodexAppServerClientState();
const client = state.client;
state.client = undefined;
state.promise = undefined;
state.key = undefined;
await client?.closeAndWait(options);
}
function clearSharedClientIfCurrent(client: CodexAppServerClient): void {
const state = getSharedCodexAppServerClientState();
if (state.client !== client) {

View File

@@ -21,6 +21,7 @@ export type CodexAppServerTransport = {
kill?: (signal?: NodeJS.Signals) => unknown;
unref?: () => unknown;
once: (event: string, listener: (...args: unknown[]) => void) => unknown;
off?: (event: string, listener: (...args: unknown[]) => void) => unknown;
};
export function closeCodexAppServerTransport(
@@ -50,12 +51,55 @@ export function closeCodexAppServerTransport(
child.stdin.unref?.();
}
export async function closeCodexAppServerTransportAndWait(
child: CodexAppServerTransport,
options: { exitTimeoutMs?: number; forceKillDelayMs?: number } = {},
): Promise<boolean> {
if (!hasCodexAppServerTransportExited(child)) {
closeCodexAppServerTransport(child, options);
}
return await waitForCodexAppServerTransportExit(child, options.exitTimeoutMs ?? 2_000);
}
function hasCodexAppServerTransportExited(child: CodexAppServerTransport): boolean {
return child.exitCode !== null && child.exitCode !== undefined
? true
: child.signalCode !== null && child.signalCode !== undefined;
}
async function waitForCodexAppServerTransportExit(
child: CodexAppServerTransport,
timeoutMs: number,
): Promise<boolean> {
if (hasCodexAppServerTransportExited(child)) {
return true;
}
return await new Promise<boolean>((resolve) => {
let settled = false;
const onExit = () => {
if (settled) {
return;
}
settled = true;
clearTimeout(timeout);
resolve(true);
};
const timeout = setTimeout(
() => {
if (settled) {
return;
}
settled = true;
child.off?.("exit", onExit);
resolve(false);
},
Math.max(1, timeoutMs),
);
timeout.unref?.();
child.once("exit", onExit);
});
}
function signalCodexAppServerTransport(
child: CodexAppServerTransport,
signal: NodeJS.Signals,