app-server: silent exit 0 mid-turn immediately after a shell tool call, in read-only sandbox (macOS)
Summary
codex app-server reliably exits mid-turn with exit code 0, no signal, no stderr as soon as the model issues 1-2 shell tool calls during a read-only sandbox turn. No turn/completed notification, no error notification, no crash — the child process just terminates cleanly and the JSON-RPC client is left with a turn that will never resolve.
Reproduced 8/8 times across:
- A CLI upgrade (0.142.5 → 0.149.0)
- A clean uninstall + reinstall of
@openai/codex(ruling out a corrupted install)
Environment
codex-cli 0.149.0(@openai/codexnpm package, installed globally)- macOS (Darwin), Apple Silicon
codex app-serverinvoked as a long-lived detached child process, driven over stdin/stdout JSON-RPC (initialize→thread/start→turn/start), via a third-party wrapper (a Claude Code plugin) — same architecture described in #21813 and #21937.
Repro sequence
initialize, thenthread/startwithsandbox: "read-only",approvalPolicy: "never".turn/startwith a prompt that requires reading a large file and reasoning over it.- Server emits
turn/startedcorrectly. - Model issues 1-2 shell tool calls (e.g.
rg -n ...,sed -n '149,230p' <file>). Both are reported as completed successfully (item/completed, exit 0) via notifications. - Immediately after the second tool call's
item/completed, thecodex app-serverprocess itself exits —exit code 0,signal: null, stderr empty. Noturn/completed, noerrornotification ever arrives. - A trivial prompt with zero tool calls ("reply with exactly X") completes successfully every time on the same setup. Only turns where the model makes a shell tool call while in
read-onlysandbox mode reproduce this.
What I checked
- Not a Node version issue on the client side — same client code, same failure, across two
codexCLI versions. - Not a corrupted/stale global install — clean
npm uninstall -g @openai/codex+npm install -g @openai/codex@latestreproduces identically. - Not a client-side hang — I instrumented the JSON-RPC client with a watchdog + proper
exit-event wiring; it now correctly observes the app-server process exiting (code 0, no signal) rather than hanging forever waiting on a promise that was never rejected. - Tried isolating to detached vs. foreground invocation shape; the two closest existing reports (#21813, #21937) both involve
app-serverspawned as a detached background child by a wrapper, same as this setup, which may be a relevant common factor.
Possibly related
- #21937 — closest symptom match: worker silent death after a parallel-command burst,
Turn completednever fires (Linux/WSL2). - #21813 — detached task-worker exits without writing failed status on broker socket disconnect — same detached-wrapper architecture.
- #26533 — app-server stdout hits EOF mid-turn while the process stays alive (Windows) — different trigger (prompt content vs. tool calls), same "no terminal event, ever" shape.
- #18243 — macOS-specific: shell execution silently fails in
workspace-write/read-onlysandbox via the siblingmcp-servertransport;danger-full-accessavoids it. As a workaround, switching this setup's sandbox fromread-onlytodanger-full-accessalso avoids the silent exit here, which points at the sandbox subsystem (Seatbelt on macOS) as a plausible common cause across all of these reports, though I have no direct evidence of why it kills the parent process rather than just the sandboxed child.
Expected behavior
Either the turn completes normally, or the app-server surfaces a real error notification / non-zero exit / signal when something goes wrong — not a silent, clean exit 0 mid-turn with no diagnostic of any kind.
Workaround in use
Forcing sandbox: "danger-full-access" instead of read-only avoids the silent exit in this setup. Not a real fix — it gives up the read-only guarantee entirely just to get turns to complete.
5 Comments
Potential duplicates detected. Please review them and close your issue if it is a duplicate.
Powered by Codex Action
Update: tried
sandbox: "danger-full-access"instead ofread-onlyas a workaround (theorized from #18243's macOS sandbox-vs-transport pattern). It did not help — identical failure, same point of death (immediately after the second successful shell tool call,exit code 0, no signal, no stderr).This is useful negative evidence: it rules out the sandbox subsystem (Seatbelt) as the cause in this environment, since bypassing it entirely didn't change the outcome. Whatever kills the
app-serverprocess here is unrelated to sandbox policy — the common factor across every reproduction so far is specifically "a turn where the model makes 1-2 shell tool calls," regardless of sandbox mode, CLI version (0.142.5 and 0.149.0), or install freshness.I traced the exit path through the current codebase (main @465eafacbc) and can pin down exactly how the process can exit 0 mid-turn with no stderr — and equally important, which suspects are not the cause. It's not a crash, it's a clean orderly shutdown triggered by the transport, and the app-server cannot tell it apart from an intentional client disconnect.
How exit 0 happens mid-turn
The only code path that produces a clean exit-0 while a turn is still running is the stdio connection-close handler in the processor loop:
codex-rs/app-server/src/lib.rs:1017-1038—TransportEvent::ConnectionClosed:``
rust
`if single_client_mode && stdio_closed {
break "stdio_connection_closed";
}
mainThis breaks the loop **immediately, without waiting for the running turn**, then runs the graceful shutdown (join connections, drain tasks) and
returns → exit code 0, no stderr.single_client_modeis true by default:lib.rs:728—matches!(&transport, AppServerTransport::Stdio), i.e. the default--listen stdio://`.codex-rs/app-server-transport/src/transport/stdio.rs:49-79) sends thatConnectionClosedonly in three cases:Ok(None)(line 68),So a clean exit 0 mid-turn means: the stdio reader observed EOF on stdin — the write end of the app-server's stdin pipe closed on the wrapper side. Nothing inside the app-server closes its own stdin.
Suspects I eliminated (with evidence)
Stdio::null()stdin and piped stdout/stderr:codex-rs/core/src/spawn.rs:118-127(StdioPolicy::RedirectForShellTool), which is what the shell tool uses (codex-rs/core/src/exec.rs:928). The macOS-only inherited-fd sweepclose_inherited_fds_except(codex-rs/utils/pty/src/pty.rs:465-531) runs in the child viapre_execand explicitly preserves fd 0-2. So neither the tool nor the sandbox can close the app-server's stdin pipe.std::process::exit/ panic — there is noprocess::exitanywhere inapp-server,app-server-transport,tools, orcore; a panic would exit with code 101 and stderr output, which contradicts both observations.lib.rs:945-954only breaks onShutdownAction::Finish, which requiresrunning_turn_count == 0 && connections.len() == 0(shutdown_state.update), andlib.rs:202-223marks the request but the loop keeps waiting for the running turn. A mid-turn signal would hang until the turn completes, not exit 0 right after a tool call.What this means for the repro
The EOF must come from the wrapper side of the pipe: the app-server's stdin write end was closed (wrapper process exited, wrapper closed the child's stdin fd, or the wrapper passed through its own stdin from Claude Code and that upstream stdin was closed). The app-server then treats it as an intentional disconnect — correct behavior if the client really went away, but here it produces exactly your symptom: silent exit 0, no notification, turn never resolves.
Two actionable things
Wrapper side (your repro): instrument the wrapper to log the moment it (or its parent) closes the app-server's stdin write end, and verify whether the wrapper process is still alive at that point. If you're passing your own stdin through, note that Claude Code may close the plugin's stdin at a phase boundary. A quick test that would sidestep the whole class: run the same session over
--listen unix://<path>(UDS accept/close is explicit, not EOF-based) and see if the mid-turn exit disappears.App-server side (worth a small fix): the exit reason is logged only at
info!level (lib.rs:1191-1196,"processor task exited") — invisible with default logging, hence "silent". When the break isstdio_connection_closedwhilerunning_turn_count > 0, the server should emit awarn!with the exit reason and the running-turn count. That single change turns every occurrence of this failure class (including the parallel-command-burst variant reported in #21937 on Linux) into a diagnosable event — it would immediately tell wrappers "your stdin pipe closed mid-turn" instead of nothing.Happy to put up the small warn-log change if it's wanted.
@argszero this is extremely useful — thank you for tracing it to the actual break. The
ConnectionClosed→break "stdio_connection_closed"path matches every observation I have, including the ones that previously made no sense (clean exit 0, no stderr, no signal, sandbox mode irrelevant).I've reverted my
danger-full-accessworkaround on the strength of your elimination of the sandbox — it was buying nothing and costing real isolation.Three new data points from my environment (macOS, Homebrew
codex-cli 0.149.0, Codex running as a Claude Code plugin):1. The app-server's stdin is a wrapper-owned pipe, not an inherited one
This narrows your third hypothesis. The plugin spawns the server as:
So it is not passing its own stdin through — the write end belongs to the Node wrapper process. That rules out "Claude Code closed the plugin's stdin and it propagated": there is no shared fd. The EOF has to come from the Node wrapper exiting or closing that pipe itself mid-turn, which is a much smaller target than the three-way split in your comment.
Worth noting the plugin also has a broker mode (
BrokerCodexAppServerClient) that speaks a UDS to plugin clients — but the broker itself constructs the same stdio-spawned client, so it relocates the exposure rather than removing it.2.
--listen unix://accepts the connection and immediately drops itI tried your suggested sidestep. The socket is created and the process stays alive, but a naive client gets closed on instantly:
So there appears to be a handshake on the unix listener that plain newline-delimited JSON-RPC (what the stdio transport uses) does not satisfy. If UDS is the recommended escape hatch for wrappers, it would help a lot to document the expected handshake — right now the failure is silent on both sides.
3.
daemon+proxyis gated on the managed standalone installThe other obvious route is unavailable on a Homebrew build:
So for anyone not on the standalone installer, neither
--listen unix://(point 2) nordaemon/proxyis currently a usable workaround — stdio is effectively the only transport, which is exactly the one with this failure mode.On the
warn!changeYes please — I'd very much like that PR. Promoting the exit reason to
warn!when the break isstdio_connection_closedwhilerunning_turn_count > 0would have saved me days here; the whole difficulty was that a mid-turn disappearance and a normal client disconnect are indistinguishable from outside. Including the running-turn count in that log line would let wrappers say "your stdin pipe closed mid-turn" instead of reporting a silent hang.In the meantime I've added a turn-stall watchdog on the wrapper side that fails the turn with a clear message instead of hanging, which at least makes the failure visible to the user.
Great data — especially point 2, which I can now explain precisely from the code (main @e16d098c00):
Why
--listen unix://drops a plain JSON-RPC clientThe UDS listener is not newline-delimited JSON-RPC — it's WebSocket-over-Unix-socket. The acceptor upgrades every accepted connection with
tokio_tungstenite::accept_async(codex-rs/app-server-transport/src/transport/unix_socket.rs:79), and the official client performs a full WebSocket client handshake over the UnixStream:So the expected handshake is: connect to the socket, then send an HTTP/1.1 Upgrade request (
GET /rpcwithConnection: Upgrade,Upgrade: websocket,Sec-WebSocket-Key,Sec-WebSocket-Version: 13), then speak JSON-RPC in WebSocket text frames. A naive client's first{"jsonrpc":"2.0",...}bytes fail the upgrade, soaccept_asyncerrors and the server closes the connection in ~0s with 0 bytes received — exactly what you observed. No auth token is needed on the UDS path (the socket file itself is mode 0600,unix_socket.rs:21-22).Practical implication: a wrapper that wants the UDS escape hatch today must implement (or pull in) a minimal WebSocket client with a Unix-socket transport. That's a real gap — the protocol mismatch is silent on both sides, as you said. I'd suggest a follow-up issue asking for either (a) documented UDS handshake guidance, or (b) a plain-JSON-RPC-over-UDS mode so wrappers have a non-stdio transport without needing a WS client. (I'd rather not fold that into the warn-log PR below — different concern.)
On the
warn!changeYes — designing it now, will push to my fork (
argszero/codex) as soon as I can this week. Design (matching your request, slightly generalized): when the processor loop exits withrunning_turn_count > 0, log atwarn!with the exit reason and the running-turn count, instead of the current unconditionalinfo!(codex-rs/app-server/src/lib.rs:1191-1196). Gating onrunning_turn_count > 0rather than onlystdio_connection_closedis deliberate: the gracefulshutdown_requestedpath already requires a zero turn count (lib.rs:945-954), so any mid-turn exit is abnormal — this catches the stdio-EOF case plus the same-class failures from #21937 (parallel-command burst on Linux), withexit_reasonin the line so wrappers can tell them apart. The normal turn-finished path still logs atinfo!.The line will look like:
Wrappers can then treat that as "your stdin pipe closed mid-turn" instead of a silent hang. I'll link the fork branch here once it's pushed.