Skip to content

Title-generation thread is never unsubscribed, so its MCP server processes outlive session/close #628

Open

Description

@shoedog

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
  1. Open session A and send a prompt. Stub processes: 1, then 2 after the turn.
  2. Open session B, send /rename x, then the same prompt. Stub processes: 3, and still 3 after the turn.
  3. Close A and B, and wait 90 s. Stub processes: 1, the one that appeared during A's first turn.
  4. 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

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

  1. revenant20 commented on Nov 5, 2026

    @revenant20

    Additional real-world confirmation with codex-acp 2.5.0 / bundled Codex 0.165.0, observed on November 5–6, 2026.

    Environment

    • macOS 26.7.1, arm64
    • OpenIDE Pro 2026.2.3, using @agentclientprotocol/codex-acp 2.5.0
    • A config-level stdio MCP server: MCP Steroid / Devrig 0.102.0 (a Java process)

    Observations

    A read-only process/log audit found 39 Devrig JVMs under four IDE-launched codex app-server processes (24 + 1 + 8 + 6). All had live parents and open stdin pipes connected to those parents; these were retained server connections, rather than orphaned processes.

    The Devrig logs for all 39 contained startup/initialization but zero tool calls. For 20 JVMs, startup times could be correlated with helper-thread logs on the same parent app-server, using the adapter's TITLE_MODEL (gpt-5.6-luna). Those threads were absent from the persisted thread index, and their last generation had already finished with needs_follow_up=false. The JVMs remained alive from tens of minutes to over 12 hours later.

    Examples (timestamps are UTC+03:00):

    JVM PID Last helper generation finished
    48185 2026-11-05 12:14:56
    48341 2026-11-05 12:15:57
    84680 2026-11-06 00:14:11

    The PID/thread attribution above is based on the parent app-server, startup timing, and model/log evidence, rather than direct introspection of the app-server's in-memory thread-to-process map. Two near-simultaneous helper threads could not be individually distinguished, but both had completed.

    Activity Monitor showed approximately 130–156 MB per JVM; 23 visible Java entries occupied about 3 GB in aggregate. One of those entries belonged to a separate diagnostic conversation and was excluded from the 39 IDE-owned JVMs above.

    This audit did not close or restart the active IDE sessions, so it is additional evidence of accumulation in an existing installation, rather than a fresh execution of the issue's controlled session/close reproduction.

    Installed code versus current main

    In the installed 2.5.0 bundle, both TitleGenerator.generateAndPersist and PromptSuggestionGenerator.generateAndPublish create an ephemeral thread without subsequently calling threadUnsubscribe.

    The prompt-suggestion path is already fixed on current main by #709. The title-generation path still lacks cleanup, and #629 remains open. This installation is therefore also affected by the older prompt-suggestion leak; the 20 helper processes above cannot all be attributed specifically to title generation.

    Please include the title-thread cleanup as well as the already-merged prompt-suggestion cleanup in a release. Repeated helper generations should leave no helper-owned MCP processes after the normal thread unload delay, including failed, empty-result, and invalidated generations.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions