app-server: silent exit 0 mid-turn immediately after a shell tool call, in read-only sandbox (macOS)

Open 💬 5 comments Opened Aug 21, 2026 by nodera-studio
💡 Likely answer: A maintainer (github-actions[bot], contributor) responded on this thread — see the highlighted reply below.

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/codex npm package, installed globally)
  • macOS (Darwin), Apple Silicon
  • codex app-server invoked as a long-lived detached child process, driven over stdin/stdout JSON-RPC (initializethread/startturn/start), via a third-party wrapper (a Claude Code plugin) — same architecture described in #21813 and #21937.

Repro sequence

  1. initialize, then thread/start with sandbox: "read-only", approvalPolicy: "never".
  2. turn/start with a prompt that requires reading a large file and reasoning over it.
  3. Server emits turn/started correctly.
  4. 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.
  5. Immediately after the second tool call's item/completed, the codex app-server process itself exitsexit code 0, signal: null, stderr empty. No turn/completed, no error notification ever arrives.
  6. 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-only sandbox mode reproduce this.

What I checked

  • Not a Node version issue on the client side — same client code, same failure, across two codex CLI versions.
  • Not a corrupted/stale global install — clean npm uninstall -g @openai/codex + npm install -g @openai/codex@latest reproduces 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-server spawned 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 completed never 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-only sandbox via the sibling mcp-server transport; danger-full-access avoids it. As a workaround, switching this setup's sandbox from read-only to danger-full-access also 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.

View original on GitHub ↗

5 Comments

github-actions[bot] contributor · 6 days ago

Potential duplicates detected. Please review them and close your issue if it is a duplicate.

  • #39964

Powered by Codex Action

nodera-studio · 6 days ago

Update: tried sandbox: "danger-full-access" instead of read-only as 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-server process 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.

argszero · 3 days ago

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-1038TransportEvent::ConnectionClosed:

``rust
if single_client_mode && stdio_closed {
break "stdio_connection_closed";
}
`
This breaks the loop **immediately, without waiting for the running turn**, then runs the graceful shutdown (join connections, drain tasks) and
main returns → exit code 0, no stderr. single_client_mode is true by default: lib.rs:728matches!(&transport, AppServerTransport::Stdio), i.e. the default --listen stdio://`.

  • The stdio reader task (codex-rs/app-server-transport/src/transport/stdio.rs:49-79) sends that ConnectionClosed only in three cases:
  • stdin EOF: Ok(None) (line 68),
  • a read error (line 69-72),
  • the transport event channel closing (the processor is already gone — not the case here).

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)

  1. The shell tool subprocess touching the app-server's stdin — it can't. Shell tool children are spawned with 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 sweep close_inherited_fds_except (codex-rs/utils/pty/src/pty.rs:465-531) runs in the child via pre_exec and explicitly preserves fd 0-2. So neither the tool nor the sandbox can close the app-server's stdin pipe.
  1. std::process::exit / panic — there is no process::exit anywhere in app-server, app-server-transport, tools, or core; a panic would exit with code 101 and stderr output, which contradicts both observations.
  1. A signal (SIGTERM/SIGINT/SIGHUP) — the signal path is a graceful drain, not an immediate exit: lib.rs:945-954 only breaks on ShutdownAction::Finish, which requires running_turn_count == 0 && connections.len() == 0 (shutdown_state.update), and lib.rs:202-223 marks 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.
  1. stdout write failure alone — the stdout writer task just exits; it doesn't break the processor loop.

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 is stdio_connection_closed while running_turn_count > 0, the server should emit a warn! 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.

nodera-studio · 3 days ago

@argszero this is extremely useful — thank you for tracing it to the actual break. The ConnectionClosedbreak "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-access workaround 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:

// plugins/codex/scripts/lib/app-server.mjs:190
this.proc = spawn("codex", ["app-server"], {
  cwd: this.cwd,
  env: this.options.env ?? process.env,
  stdio: ["pipe", "pipe", "pipe"],
  ...
});

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 it

I tried your suggested sidestep. The socket is created and the process stays alive, but a naive client gets closed on instantly:

codex app-server --listen unix:///tmp/cx.sock &
# socket_exists=YES  alive=YES  stderr empty
# connect() OK, send newline-delimited JSON-RPC "initialize"
# -> peer closed connection after 0.0s, 0 bytes received

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 + proxy is gated on the managed standalone install

The other obvious route is unavailable on a Homebrew build:

$ codex app-server daemon start
Error: managed standalone Codex install not found at
       ~/.codex/packages/standalone/current/codex

So for anyone not on the standalone installer, neither --listen unix:// (point 2) nor daemon/proxy is currently a usable workaround — stdio is effectively the only transport, which is exactly the one with this failure mode.

On the warn! change

Yes please — I'd very much like that PR. Promoting the exit reason to warn! when the break is stdio_connection_closed while running_turn_count > 0 would 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.

argszero · 2 days ago

Great data — especially point 2, which I can now explain precisely from the code (main @e16d098c00):

Why --listen unix:// drops a plain JSON-RPC client

The 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:

// codex-rs/app-server-client/src/remote.rs:69
const UDS_WEBSOCKET_HANDSHAKE_URL: &str = "ws://localhost/rpc";
// remote.rs:745-789: UnixStream::connect → client_async_with_config(request, stream, ...)

So the expected handshake is: connect to the socket, then send an HTTP/1.1 Upgrade request (GET /rpc with Connection: 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, so accept_async errors 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! change

Yes — 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 with running_turn_count > 0, log at warn! with the exit reason and the running-turn count, instead of the current unconditional info! (codex-rs/app-server/src/lib.rs:1191-1196). Gating on running_turn_count > 0 rather than only stdio_connection_closed is deliberate: the graceful shutdown_requested path 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), with exit_reason in the line so wrappers can tell them apart. The normal turn-finished path still logs at info!.

The line will look like:

WARN processor task exited while a turn was still running exit_reason=stdio_connection_closed running_turn_count=1 remaining_connection_count=0 shutdown_forced=false

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.