Skip to content

Release idle Codex app-server helper trees - #563

Open
ccheng555 wants to merge 11 commits into
openai:mainfrom
ccheng555:cc-5219-idle-app-server-child
Open

Release idle Codex app-server helper trees#563
ccheng555 wants to merge 11 commits into
openai:mainfrom
ccheng555:cc-5219-idle-app-server-child

Conversation

@ccheng555

@ccheng555 ccheng555 commented Jul 27, 2026

Copy link
Copy Markdown

Stops Codex companion app-server helpers from accumulating after their client or Claude session goes away.

Root cause

The shared broker eagerly kept one heavyweight codex app-server alive for its entire session. Some app-server helpers launch in their own process groups, so parent-only cleanup could leave those detached descendants running. Socket closure also erased stream ownership, which could incorrectly make an active turn look idle.

What changed

  • Start the app-server lazily and release it after five minutes with no sockets, requests, or active streams; restart it on demand.
  • Track stream lifetime independently from its client socket so a disconnect cannot terminate active work.
  • Capture the Unix app-server process identity after initialization.
  • Enumerate descendant process groups, revalidate start-time identity before every signal, wait and rescan after SIGTERM, escalate resistant processes to SIGKILL, and verify the tracked tree is empty.
  • When Unix process enumeration is unavailable or unparsable, degrade to a direct-child kill with a stderr warning and verified: false instead of failing the invocation; resolve ps from /bin/ps with a PATH fallback.
  • Preserve the existing Windows taskkill /T /F behavior.
  • Isolate runtime tests in a private plugin-data root and run SessionEnd for every surviving test broker so the suite cannot reuse a live broker or leave fixtures behind.

Second review round (latest commit)

A cold adversarial review of this branch surfaced defects in the new code itself; the latest commit addresses them:

  • Disconnected-stream wedge fixed. Stream state is cleared unconditionally when a streaming request errors and when the app-server child is released or exits, so a client that disconnects during a turn that later fails can no longer leave the broker answering BROKER_BUSY forever. New regression test: client disconnects mid-turn, the turn rejects, a second client must then be served (verified to fail against the prior branch state).
  • Cleanup verification is no longer fatal to completed work. close() records {verified, survivors} on the client and warns on stderr instead of throwing, so a successful turn result is never discarded because post-exit cleanup could not be verified. The broker refuses to spawn a replacement child while unverified survivors are still alive (RPC error -32002) and recovers automatically once they are gone.
  • Cleanup no longer blocks the event loop. terminateProcessTree is async (awaited sleeps, one process-table read per poll round instead of a per-unit re-read), so the broker stays responsive during idle release.
  • Unverified group kills removed from error paths. initialize() failure paths kill the direct child handle instead of signaling the process group without a verified identity.
  • De-flaked timing tests. The idle-release runtime tests poll observable conditions instead of fixed sleeps. Added a parse test against a captured Linux procps ps sample.

Behavior notes for reviewers

  • On Unix the app-server child is spawned detached (own process group) so the group can be terminated as a unit. Tradeoff: if the broker is SIGKILLed, the detached child survives until its stdin closes; the graceful-path leak this PR fixes was the more common case.
  • Lazy start means the first request after an idle release pays app-server cold-start latency; CODEX_COMPANION_BROKER_CHILD_IDLE_MS tunes the idle window (0 releases immediately; invalid values fall back to the 5-minute default).
  • verified: true from Unix cleanup covers same-process-group descendants plus processes observed at scan time; a descendant that calls setsid after the scan can escape (documented in code).

Checks

  • npm test: 97 passed; the only 4 failures are pre-existing on main (state-directory/environment tests unrelated to this change).
  • Red-arm verified twice: reverting the source changes while keeping the new tests makes the new tests fail (8 tests in round one; the disconnect-wedge regression in round two).
  • Focused tests cover process-table failure, root and descendant PID reuse, resistant descendants, PATH isolation, degraded enumeration fallback, and client disconnect during a live turn.
  • git diff --check main...HEAD: clean.
  • Pre-push secret and operator-PII diff sweep: clean.

Internal tracking: CC-5219.

ccheng555 and others added 7 commits July 26, 2026 23:12
Attributed-To: Codex interactive
Attributed-To: Codex interactive
Attributed-To: Codex interactive
Attributed-To: Codex interactive
Attributed-To: Codex interactive
… non-fatal cleanup verification, async non-blocking process cleanup, ps portability with degraded fallback, de-flaked idle-release tests

Attributed-To: Codex executor lane (implementation), Claude interactive (verification, test-anchor fix)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MrRkob3GtGnbYqq6aeE5co
@ccheng555
ccheng555 marked this pull request as ready for review July 27, 2026 18:45
@ccheng555
ccheng555 requested a review from a team July 27, 2026 18:45

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 38529f436e

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

};
}

export async function terminateProcessTree(pid, options = {}) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Await the new asynchronous process-tree teardown

After making terminateProcessTree asynchronous, existing production callers still discard its promise (codex-companion.mjs:986, session-lifecycle-hook.mjs:65, and broker-lifecycle.mjs:176). On Unix, teardown yields while polling before it can escalate to SIGKILL, so cancellation or session shutdown can exit and remove job state while a TERM-resistant worker remains alive; additionally, the surrounding synchronous try/catch blocks no longer catch rejected cleanup promises. Update these call paths to await the teardown before reporting or completing cleanup.

Useful? React with 👍 / 👎.

Comment on lines +180 to +181
if (activeStreamSocket === socket) {
activeStreamSocket = null;
activeStreamThreadIds = null;
clearStreamState();

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Keep stream state when its owner socket closes

When a client disconnects after a streaming request has returned but before turn/completed, this clears activeStreamRunning along with the socket. The broker consequently accepts another client's request during the still-running turn and schedules the child for idle termination; with a short configured idle interval—or a turn exceeding the five-minute default—the app-server is killed before completing active work. Detach only the notification socket here and retain stream ownership until completion, failure, or child exit.

Useful? React with 👍 / 👎.

Comment on lines +275 to +276
for (const unit of units) {
delivered = signalVerifiedUnit(unit, signal, processes, options.killImpl) || delivered;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Reap helpers before terminating their app-server parent

In environments where PID 1 does not reap orphaned children, signaling every depth-sorted unit back-to-back kills detached helpers and their app-server parent without giving the parent an opportunity to reap them. The subsequent polling treats Z records as gone, returns verified: true, and leaves permanent zombie PID entries; this is reproducible in the repository's container, where both new helper-cleanup runtime tests time out and the helper PIDs remain as <defunct>. Terminate and wait for descendant units before killing the parent, or otherwise avoid claiming successful cleanup while zombies persist.

Useful? React with 👍 / 👎.

…s, keep stream ownership on socket close, reap descendants before their parent

Attributed-To: Claude interactive
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MrRkob3GtGnbYqq6aeE5co

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 16547a0cc8

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".


function signalTracked(tracked, signal, options) {
const processes = readUnixProcessTable(options.runCommandImpl, options);
mergeTrackedDescendants(tracked, processes, options.rootPid, options.rootIdentity);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Keep the root out of the descendant phase

When PID 1 does not reap orphans, the new two-phase cleanup still kills the parent in the first phase: signalTracked(descendants, ...) calls mergeTrackedDescendants, which always seeds rootPid and then adds that root to the supplied descendants map. buildSignalUnits consequently signals the root process group alongside its helpers, so the parent cannot reap them and the subsequent polling treats their zombie records as gone. Fresh evidence in this revision is that the attempted descendant/root phase split is neutralized by this merge; both new helper-cleanup runtime tests time out with defunct helper PIDs in the container.

Useful? React with 👍 / 👎.

Comment on lines +243 to +247
client.setExitHandler(() => {
clearStreamState();
if (appClient === client) {
appClient = null;
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Clean up the process group after an unexpected child exit

When the app-server crashes after spawning an MCP helper, this exit handler only clears the client reference and allows the next request to start a replacement. Because the app-server is now launched as a detached process-group leader, its surviving helpers are reparented when it exits and are no longer descendants of the broker, so neither idle cleanup nor session teardown can discover them; they can remain alive indefinitely while replacement app-servers accumulate. The captured process group must be cleaned up on this unexpected-exit path before permitting a restart.

Useful? React with 👍 / 👎.

…kill phase, reclaim orphaned process group on unexpected child exit

Attributed-To: Claude interactive
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MrRkob3GtGnbYqq6aeE5co

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 58d84b1801

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment on lines +413 to +415
const tracked = new Map();
for (const record of collectProcessTree(pid, initialProcesses)) {
tracked.set(record.identity, record);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Track all signaled process-group members

When an app-server descendant has already exited after leaving a TERM-resistant helper in the app-server's process group, that helper is reparented outside the root's process tree and is omitted from tracked. The root-phase kill(-pid, SIGTERM) still signals it, but once the tracked root exits, polling reports no live records, skips SIGKILL, and returns verified: true while the helper remains alive. Include every observed member of a signaled process group in the tracked set so resistant orphaned members are escalated and verified.

Useful? React with 👍 / 👎.

Comment on lines +239 to +240
appClientStartPromise = CodexAppServerClient.connect(cwd, { disableBroker: true })
.then((client) => {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Reap detached children when initialization fails

When codex app-server responds to initialize with an RPC error but remains running, CodexAppServerClient.connect() rejects before this .then() receives the client, so the broker drops its only reference without calling close(). Because the process is now detached and the broker catches the request error without exiting, every subsequent request can spawn another live app-server and helper tree. Ensure a failed connection initialization terminates its spawned client before clearing appClientStartPromise.

Useful? React with 👍 / 👎.

ccheng555 and others added 2 commits July 27, 2026 13:03
A TERM-resistant helper whose parent already exited is reparented out of
the root's tree, so it was signaled by the group kill but never tracked:
polling saw no live records, skipped SIGKILL, and reported verified while
the helper was still alive. Group members observed at scan time now enter
the shared tracked map, so they escalate and count as survivors.

Attributed-To: Codex executor lane
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
connect() rejected before returning the client, so a detached app-server
that answered initialize with an RPC error but stayed alive was never
closed, and every later request could spawn another live helper tree.
connect() now closes its own client on a failed initialize and relays an
unverified cleanup outcome to the broker's existing refusal gate.

Attributed-To: Codex executor lane
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 386411c99f

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

// The child is a detached process-group leader; on an unexpected
// exit its surviving helpers reparent away from the broker, so
// reclaim the group before allowing a replacement to spawn.
appClientClosePromise = terminateProcessGroup(childPid)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Reclaim independently grouped helpers after a child crash

When the app-server crashes after a helper has created its own process group, this cleanup still misses that helper: terminateProcessGroup(childPid) initially selects only processes whose PGID equals the dead app-server PID (process.mjs:520-525), while the reparented helper's PGID is its own PID. It then returns verified: true when that selection is empty, allowing a replacement to start while the helper remains alive. Fresh evidence in this revision is that the new crash handler cleans only the root group rather than the independently grouped descendants that motivated the process-tree cleanup.

Useful? React with 👍 / 👎.

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant