Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
62 changes: 62 additions & 0 deletions src/__tests__/CodexACPAgent/e2e/acp-e2e-backend-death.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
import {execSync} from "node:child_process";
import {afterEach, expect, it} from "vitest";
import {createUnauthenticatedFixture, describeE2E, type SpawnedAgentFixture} from "./acp-e2e-test-utils";

// The process walk uses `ps`, so the spec is POSIX-only; the behaviour under
// test is platform-independent (the exit hook listens for both child exit and
// stdout EOF).
describeE2E("E2E backend death", () => {
let fixture: SpawnedAgentFixture;

afterEach(async () => {
await fixture?.dispose();
});

it.skipIf(process.platform === "win32")(
"exits promptly when the codex backend dies",
async () => {
fixture = await createUnauthenticatedFixture();
const agentPid = fixture.agentPid;
expect(agentPid).toBeDefined();

const backend = descendantsOf(agentPid as number).find(
(p) => /app-server/.test(p.command) && !/codex\.js/.test(p.command),
);
expect(backend, "codex app-server process not found under the agent").toBeDefined();

process.kill((backend as ProcessRow).pid, "SIGKILL");

const exited = await fixture.waitForAgentExit(5_000);
expect(exited, "agent did not exit within 5s of its backend dying").toBe(true);
},
);
});

interface ProcessRow {
pid: number;
ppid: number;
command: string;
}

function descendantsOf(rootPid: number): ProcessRow[] {
const out = execSync("ps -axo pid=,ppid=,command=", {encoding: "utf8"});
const rows = out
.trim()
.split("\n")
.map((line): ProcessRow | null => {
const m = line.trim().match(/^(\d+)\s+(\d+)\s+(.*)$/);
return m ? {pid: Number(m[1]), ppid: Number(m[2]), command: m[3] ?? ""} : null;
})
.filter((row): row is ProcessRow => row !== null);
const result: ProcessRow[] = [];
const walk = (parent: number): void => {
for (const row of rows) {
if (row.ppid === parent) {
result.push(row);
walk(row.pid);
}
}
};
walk(rootPid);
return result;
}
5 changes: 5 additions & 0 deletions src/__tests__/CodexACPAgent/e2e/acp-e2e-test-utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,11 @@ export async function createAuthenticatedFixture(initialMode?: AgentMode, mcpSer
}, extraEnv, mcpServers);
}

/** A fixture that only initializes — for tests that need no authentication. */
export async function createUnauthenticatedFixture(): Promise<SpawnedAgentFixture> {
return await createSpawnedFixture(async () => {});
}

export async function createGatewayFixture(
baseUrl: string,
headers: Record<string, string>,
Expand Down
12 changes: 12 additions & 0 deletions src/__tests__/CodexACPAgent/e2e/spawned-agent-fixture.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,10 @@ export interface TestSkill {
export interface SpawnedAgentFixture {
readonly connection: acp.ClientSideConnection;
readonly workspaceDir: string;
/** Pid of the spawned agent process, for tests that manage its process tree. */
readonly agentPid: number | undefined;
/** Resolves true if the agent process exits within the timeout. */
waitForAgentExit(timeoutMs: number): Promise<boolean>;
createSession(mcpServers?: acp.McpServer[]): Promise<LegacyNewSessionResponse>;
restart(): Promise<SpawnedAgentFixture>;
writeSkill(skill: TestSkill, rootDir?: string): void;
Expand Down Expand Up @@ -178,6 +182,14 @@ class SpawnedAgentFixtureImpl implements SpawnedAgentFixture {
return this.paths.workspaceDir;
}

get agentPid(): number | undefined {
return this.agentProcess.pid;
}

async waitForAgentExit(timeoutMs: number): Promise<boolean> {
return await waitForProcessExit(this.agentProcess, timeoutMs);
}

async createSession(mcpServers: acp.McpServer[] = []): Promise<LegacyNewSessionResponse> {
return await this.connection.newSession({
cwd: this.workspaceDir,
Expand Down
41 changes: 41 additions & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -89,7 +89,9 @@ function startAcpServer() {
stderr = (stderr + data.toString()).slice(-maxStderrTailChars);
});

let clientInitiatedShutdown = false;
process.stdin.on("close", () => {
clientInitiatedShutdown = true;
codexConnection.process.stdin.end();
// Kill the codex process if it doesn't exit naturally
setTimeout(() => {
Expand All @@ -100,6 +102,45 @@ function startAcpServer() {
}, 2000);
});

// If the codex process dies, exit instead of leaving the client a
// connection that can never answer again; exiting surfaces the death as
// stdio EOF and rejects in-flight client requests at the transport layer.
// stdout EOF is watched too: the win32 spawn goes through a shell, so the
// child handle can outlive the real codex process. The diagnostic goes to
// stderr because clients surface an exited agent's stderr to the user.
let backendLossHandled = false;
const reportBackendLossAndExit = (reason: string) => {
const exitCode = codexConnection.process.exitCode;
const hint = exitCode === 3221225781 ? " — VC++ redistributable should be installed" : "";
const stderrTail = stderr.trim();
process.stderr.write(
`codex-acp: codex process died (${reason}, exit code ${exitCode ?? "unknown"})${hint}\n` +
(stderrTail ? stderrTail + "\n" : "")
);
logger.log("Codex process lost; exiting", {reason: reason, exitCode: exitCode, stderrTail: stderr});
// Grace period so stderr and any queued stdout responses flush.
setTimeout(() => process.exit(1), 50);
};
const exitOnBackendLoss = (reason: string) => {
if (backendLossHandled || clientInitiatedShutdown) {
return;
}
backendLossHandled = true;
if (codexConnection.process.exitCode === null) {
// stdout EOF usually precedes the exit event that carries the exit
// code (and with it the VC++ hint) — give it a moment to arrive.
const fallback = setTimeout(() => reportBackendLossAndExit(reason), 150);
codexConnection.process.once("exit", () => {
clearTimeout(fallback);
reportBackendLossAndExit(reason);
});
} else {
reportBackendLossAndExit(reason);
}
};
codexConnection.process.on("exit", () => exitOnBackendLoss("process-exit"));
codexConnection.process.stdout.on("end", () => exitOnBackendLoss("stdout-eof"));

const acpJsonStream = createJsonStream(process.stdin, process.stdout);

function createAgent(connection: acp.AgentContext): CodexAcpServer {
Expand Down