Repository navigation
Title-generation thread is never unsubscribed, so its MCP server processes outlive session/close #628
Description
Summary
After the first turn of a new session, codex-acp starts an ephemeral thread to generate the session title and never unsubscribes it. session/close unsubscribes only the session's own thread. The title thread stays loaded until codex-acp exits, together with every MCP server process Codex started for it from the user's config. A client that opens and closes many sessions over one codex-acp process accumulates one set of those processes per session.
Seen with @agentclientprotocol/codex-acp 2.2.2 (codex 0.160.1) and 1.13.1. src/TitleGenerator.ts is the same on main (a636804). Fix: #629.
Steps to reproduce
The script below is one file with no dependencies and edits no config. It adds one stub MCP server to the app-server through a CODEX_PATH wrapper, so each loaded Codex thread shows up as one stub process. It sends two one-word prompts.
node title-thread-repro.mts /path/to/node_modules/@agentclientprotocol/codex-acp/dist/index.js- Open session A and send a prompt. Stub processes: 1, then 2 after the turn.
- Open session B, send
/rename x, then the same prompt. Stub processes: 3, and still 3 after the turn. - Close A and B, and wait 90 s. Stub processes: 1, the one that appeared during A's first turn.
- Terminate codex-acp. Stub processes: 0.
Expected: 0 after step 3. The ACP session/close RFD says the agent must free the resources associated with the session.
title-thread-repro.mts
// Repro: codex-acp's title-generation thread is never unsubscribed, so the MCP server processes codex starts
// for it outlive `session/close` and run until codex-acp exits.
//
// Usage: node title-thread-repro.mts /path/to/node_modules/@agentclientprotocol/codex-acp/dist/index.js
// Needs: Node >= 22.18 (runs TypeScript directly), macOS or Linux, a logged-in codex.
// Cost: two one-word model prompts, plus the title turn the adapter itself starts for session A.
//
// It is self-contained and edits no config. It points CODEX_PATH at a generated wrapper that starts the
// bundled codex with `-c mcp_servers.title_repro_stub...`, so every codex thread in this one codex-acp process
// starts one stub MCP server (this same file, run with `--stub`). Counting those stubs counts loaded threads.
//
// Session A (control): first prompt is an ordinary prompt.
// Session B: first prompt is `/rename ...`, then the same ordinary prompt.
//
// Expected if nothing leaked: 0 stub processes once A and B are closed (codex unloads a thread about 60 s
// after its last subscriber leaves). Observed: 1, started during A's first turn, belonging to no ACP session.
import { spawn, execSync } from "node:child_process";
import { chmodSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { createRequire } from "node:module";
import { tmpdir } from "node:os";
import path from "node:path";
import readline from "node:readline";
import { fileURLToPath } from "node:url";
const self = fileURLToPath(import.meta.url);
if (process.argv[2] === "--stub") {
runStub();
} else {
await main();
}
/** Minimal stdio MCP server: answers initialize / tools/list / ping, then idles until stdin closes. */
function runStub(): void {
const rl = readline.createInterface({ input: process.stdin });
const send = (m: unknown) => process.stdout.write(JSON.stringify(m) + "\n");
rl.on("line", (line) => {
let m: any;
try { m = JSON.parse(line); } catch { return; }
if (m.id === undefined) return; // notification
if (m.method === "initialize") {
send({ jsonrpc: "2.0", id: m.id, result: {
protocolVersion: m.params?.protocolVersion ?? "2025-06-18",
capabilities: { tools: {} },
serverInfo: { name: "title-repro-stub", version: "0.0.1" },
} });
} else if (m.method === "tools/list") {
send({ jsonrpc: "2.0", id: m.id, result: { tools: [] } });
} else if (m.method === "ping") {
send({ jsonrpc: "2.0", id: m.id, result: {} });
} else {
send({ jsonrpc: "2.0", id: m.id, error: { code: -32601, message: "not implemented" } });
}
});
rl.on("close", () => process.exit(0));
}
async function main(): Promise<void> {
const acp = process.argv[2];
if (!acp) {
console.error("usage: node title-thread-repro.mts /path/to/@agentclientprotocol/codex-acp/dist/index.js");
process.exit(2);
}
const sleep = (ms: number) => new Promise<void>((r) => setTimeout(r, ms));
const log = (s: string) => console.log(`${new Date().toISOString().slice(11, 19)}Z ${s}`);
// A wrapper codex that adds one config-level stdio MCP server for the whole app-server process.
const marker = `title-repro-stub-${process.pid}`;
const work = mkdtempSync(path.join(tmpdir(), "codex-acp-title-repro-"));
const cwd = path.join(work, "project");
mkdirSync(cwd);
const codexJs = createRequire(path.resolve(acp)).resolve("@openai/codex/bin/codex.js");
const sh = (s: string) => `'${s.replace(/'/g, `'\\''`)}'`;
const wrapper = path.join(work, "codex-with-stub-mcp.sh");
writeFileSync(wrapper, [
"#!/bin/sh",
`exec ${sh(process.execPath)} ${sh(codexJs)} "$@" \\`,
` -c ${sh(`mcp_servers.title_repro_stub.command=${JSON.stringify(process.execPath)}`)} \\`,
` -c ${sh(`mcp_servers.title_repro_stub.args=${JSON.stringify([self, "--stub", marker])}`)}`,
"",
].join("\n"));
chmodSync(wrapper, 0o755);
// Stub processes only: the app-server's own command line carries the marker inside a quoted `-c` value.
const stubs = (): string[] => {
try {
return execSync(`pgrep -f -- "--stub ${marker}"`, { encoding: "utf8" }).split("\n").filter(Boolean);
} catch { return []; }
};
const count = (label: string): string[] => {
const pids = stubs();
log(`${label}: ${pids.length} stub MCP server process(es) [${pids.join(", ")}]`);
return pids;
};
const waitForCount = async (n: number, timeoutMs: number): Promise<boolean> => {
for (let waited = 0; waited < timeoutMs; waited += 500) {
if (stubs().length >= n) return true;
await sleep(500);
}
return false;
};
const child = spawn(process.execPath, [acp], {
stdio: ["pipe", "pipe", "ignore"],
detached: true,
env: { ...process.env, CODEX_PATH: wrapper },
});
let buf = "";
let nextId = 1;
const pending = new Map<number, (m: any) => void>();
child.stdout.on("data", (d: Buffer) => {
buf += d;
let i: number;
while ((i = buf.indexOf("\n")) >= 0) {
const line = buf.slice(0, i);
buf = buf.slice(i + 1);
let m: any;
try { m = JSON.parse(line); } catch { continue; }
if (m.id !== undefined && !m.method && pending.has(m.id)) {
pending.get(m.id)!(m);
pending.delete(m.id);
} else if (m.method && m.id !== undefined) {
// Any request from the agent (for example a permission prompt) is refused.
child.stdin.write(JSON.stringify({ jsonrpc: "2.0", id: m.id, error: { code: -32601, message: "unsupported" } }) + "\n");
} else if (m.method === "session/update" && m.params?.update?.sessionUpdate === "session_info_update") {
const title = m.params.update.title;
if (title !== undefined) log(` session_info_update ${String(m.params.sessionId).slice(0, 13)} title=${JSON.stringify(title)}`);
}
}
});
const req = (method: string, params: unknown, timeoutMs = 60_000): Promise<any> =>
new Promise((resolve, reject) => {
const id = nextId++;
pending.set(id, resolve);
child.stdin.write(JSON.stringify({ jsonrpc: "2.0", id, method, params }) + "\n");
setTimeout(() => { if (pending.delete(id)) reject(new Error(`timeout: ${method}`)); }, timeoutMs);
});
const prompt = async (name: string, sessionId: string, text: string): Promise<any> => {
const r = await req("session/prompt", { sessionId, prompt: [{ type: "text", text }] }, 120_000);
if (r.error) throw new Error(`prompt ${name}: ${JSON.stringify(r.error)}`);
log(`prompt ${name} ${JSON.stringify(text)} -> stopReason=${r.result?.stopReason} usage=${r.result?.usage ? "reported" : "null"}`);
return r.result;
};
const PROMPT = "Reply with the single word OK. Do not use tools.";
let verdict = "INCONCLUSIVE";
try {
const init = await req("initialize", { protocolVersion: 1, clientCapabilities: {} });
log(`agent ${JSON.stringify(init.result?.agentInfo ?? {})}`);
const a = await req("session/new", { cwd, mcpServers: [] });
if (a.error) throw new Error(`session/new A: ${JSON.stringify(a.error)}`);
const A: string = a.result.sessionId;
log(`session A (control) = ${A}`);
if (!(await waitForCount(1, 30_000))) {
throw new Error("setup: the stub MCP server did not start for session A; nothing was sent to the model");
}
const afterAOpen = count("A open, no prompt yet");
await prompt("A", A, PROMPT);
await sleep(15_000);
const afterATurn = count("A, 15 s after its first turn");
const extra = afterATurn.filter((p) => !afterAOpen.includes(p));
const b = await req("session/new", { cwd, mcpServers: [] });
if (b.error) throw new Error(`session/new B: ${JSON.stringify(b.error)}`);
const B: string = b.result.sessionId;
log(`session B (/rename first) = ${B}`);
await waitForCount(afterATurn.length + 1, 30_000);
const afterBOpen = count("B open, no prompt yet");
const renamed = await prompt("B", B, "/rename title-repro-b");
if (renamed?.usage) log(" note: /rename reported model usage, so this adapter did not handle it locally");
await prompt("B", B, PROMPT);
await sleep(15_000);
const afterBTurn = count("B, 15 s after /rename and its first turn");
const ca = await req("session/close", { sessionId: A });
const cb = await req("session/close", { sessionId: B });
log(`session/close A -> ${JSON.stringify(ca.error ?? ca.result)}; B -> ${JSON.stringify(cb.error ?? cb.result)}`);
let left: string[] = [];
for (let t = 10; t <= 90; t += 10) {
await sleep(10_000);
left = count(`${t} s after closing A and B`);
}
log("");
log(`A's first turn added ${extra.length} thread(s) beyond the session's own [${extra.join(", ")}]`);
log(`B's /rename + first turn added ${afterBTurn.length - afterBOpen.length}`);
if (left.length > 0) {
const fromTitle = left.every((p) => extra.includes(p));
verdict = `REPRODUCED: ${left.length} stub MCP server process(es) still running 90 s after every session was closed`
+ ` (expected 0)${fromTitle ? "; each was started during A's first turn" : ""}`;
} else {
verdict = "NOT REPRODUCED: no stub MCP server process is left after closing every session";
}
} catch (e) {
verdict = `INCONCLUSIVE: ${(e as Error).message}`;
} finally {
log(verdict);
try { process.kill(-child.pid!, "SIGTERM"); } catch { /* already gone */ }
await sleep(4000);
log(`after terminating codex-acp: ${stubs().length} stub MCP server process(es)`);
rmSync(work, { recursive: true, force: true });
process.exit(verdict.startsWith("REPRODUCED") ? 1 : 0);
}
}Output on 2.2.2
23:54:06Z agent {"name":"@agentclientprotocol/codex-acp","title":"Codex","version":"2.2.2"}
23:54:08Z session A (control) = 01a137b0-1117-70c3-9216-802ce5414229
23:54:08Z A open, no prompt yet: 1 stub MCP server process(es) [86223]
23:54:13Z session_info_update 01a137b0-1117 title="Reply with the single word OK. Do not use tools."
23:54:13Z prompt A "Reply with the single word OK. Do not use tools." -> stopReason=end_turn usage=reported
23:54:17Z session_info_update 01a137b0-1117 title="Single-word OK response"
23:54:28Z A, 15 s after its first turn: 2 stub MCP server process(es) [86223, 86407]
23:54:29Z session B (/rename first) = 01a137b0-62ec-7301-acfd-4ebe2cbbddb1
23:54:29Z B open, no prompt yet: 3 stub MCP server process(es) [86223, 86407, 86735]
23:54:29Z prompt B "/rename title-repro-b" -> stopReason=end_turn usage=null
23:54:29Z session_info_update 01a137b0-62ec title="title-repro-b"
23:54:33Z prompt B "Reply with the single word OK. Do not use tools." -> stopReason=end_turn usage=reported
23:54:48Z B, 15 s after /rename and its first turn: 3 stub MCP server process(es) [86223, 86407, 86735]
23:54:48Z session/close A -> {}; B -> {}
23:54:58Z 10 s after closing A and B: 3 stub MCP server process(es) [86223, 86407, 86735]
23:55:08Z 20 s after closing A and B: 3 stub MCP server process(es) [86223, 86407, 86735]
23:55:18Z 30 s after closing A and B: 3 stub MCP server process(es) [86223, 86407, 86735]
23:55:28Z 40 s after closing A and B: 3 stub MCP server process(es) [86223, 86407, 86735]
23:55:38Z 50 s after closing A and B: 3 stub MCP server process(es) [86223, 86407, 86735]
23:55:48Z 60 s after closing A and B: 1 stub MCP server process(es) [86407]
23:55:58Z 70 s after closing A and B: 1 stub MCP server process(es) [86407]
23:56:08Z 80 s after closing A and B: 1 stub MCP server process(es) [86407]
23:56:18Z 90 s after closing A and B: 1 stub MCP server process(es) [86407]
23:56:18Z
23:56:18Z A's first turn added 1 thread(s) beyond the session's own [86407]
23:56:18Z B's /rename + first turn added 0
23:56:18Z REPRODUCED: 1 stub MCP server process(es) still running 90 s after every session was closed (expected 0); each was started during A's first turn
23:56:22Z after terminating codex-acp: 0 stub MCP server process(es)
Root cause
TitleGenerator.ts#L131-L176:generateAndPersistcallsthreadStart({cwd, ephemeral: true}), runs the title turn and renames the main thread. Nothing releases the ephemeral thread. It came in with feat: AI session title generation and /rename command #392.CodexAcpClient.ts#L821-L828:closeSessionis the only caller ofthreadUnsubscribe, and it passes the session's own thread id.- Codex unloads a thread only when it has no subscribers and is idle (
thread_lifecycle.rs#L56-L63), so a thread that is never unsubscribed is never unloaded.
Workaround
Send /rename <name> as the first prompt of a new session. It is handled locally, and a session with an explicit name skips title generation.
Activity