npm shims exit 0 when mirrored child signal is re-caught by the parent

Open 💬 2 comments Opened May 26, 2026 by bar-d

What happened?

The npm JavaScript shims for codex and codex-responses-api-proxy try to mirror a native child process termination signal by calling process.kill(process.pid, childResult.signal) after the child exits due to a signal.

However, the same process still has signal forwarding handlers installed for SIGINT, SIGTERM, and SIGHUP. When the parent sends the signal to itself, its own handler catches that signal and forwards it to the already-exited child instead of letting Node terminate with signal semantics. The parent can then fall off the end of the module and exit successfully.

Reproduction

A minimal reproduction of the current signal-mirroring pattern exits with status 0 after the child exits from SIGTERM:

node --input-type=module -e 'import { spawn } from "node:child_process"; const child = spawn(process.execPath, ["-e", "setTimeout(()=>{}, 10000)"]); const forwardSignal = (signal) => { if (!child.killed) child.kill(signal); }; ["SIGINT", "SIGTERM", "SIGHUP"].forEach((sig) => process.on(sig, () => forwardSignal(sig))); child.kill("SIGTERM"); const childResult = await new Promise((resolve) => child.on("exit", (code, signal) => resolve(signal ? { type: "signal", signal } : { type: "code", exitCode: code ?? 1 }))); if (childResult.type === "signal") process.kill(process.pid, childResult.signal); else process.exit(childResult.exitCode);'; echo $?

Observed: 0

Expected behavior

If the native child exits because of SIGTERM, SIGINT, or SIGHUP, the npm shim parent should also terminate from the same signal so shell scripts and supervisors observe the correct signal/exit semantics.

Proposed fix

Store the installed signal handlers and remove them immediately before re-emitting the child's signal to the parent process. I have a local patch that applies this to both npm shims and adds a Node built-in test that creates temporary fake native binaries and asserts that the parent process exits with signal === "SIGTERM".

Local validation performed:

pnpm test:node-shims
node --check tests/node_shim_signals.test.js
node --check codex-cli/bin/codex.js
node --check codex-rs/responses-api-proxy/npm/bin/codex-responses-api-proxy.js
pnpm dlx prettier@3.5.3 --check package.json codex-cli/bin/codex.js codex-rs/responses-api-proxy/npm/bin/codex-responses-api-proxy.js tests/node_shim_signals.test.js
git diff --check

I noticed the repository currently accepts external code PRs by invitation only, so I am filing this issue first per docs/contributing.md.

View original on GitHub ↗

2 Comments

bar-d · 3 months ago

I have a focused local patch prepared for this, but I will hold off on opening a PR unless invited per docs/contributing.md.

Patch scope:

  • codex-cli/bin/codex.js
  • codex-rs/responses-api-proxy/npm/bin/codex-responses-api-proxy.js
  • tests/node_shim_signals.test.js
  • package.json

The fix removes the installed signal-forwarding handlers before the parent re-emits the child's terminating signal. That lets Node terminate from the signal instead of catching its own re-emitted signal and exiting successfully.

Local validation:

pnpm test:node-shims
node --check tests/node_shim_signals.test.js
node --check codex-cli/bin/codex.js
node --check codex-rs/responses-api-proxy/npm/bin/codex-responses-api-proxy.js
pnpm dlx prettier@3.5.3 --check package.json codex-cli/bin/codex.js codex-rs/responses-api-proxy/npm/bin/codex-responses-api-proxy.js tests/node_shim_signals.test.js
git diff --check
edweiss412 · 1 month ago

Confirming this from the field, with an impact measurement — the analysis above is correct, and the consequence downstream is worse than "wrong exit status."

We run codex exec -o <file> non-interactively from a wrapper. Under this bug a signalled run produces exit 0, no -o file, and nothing on stderr — byte-for-byte indistinguishable from a run that legitimately finished having produced no output. There is no error, no panic, no tokens used footer, and the session rollout under ~/.codex/sessions/** simply stops mid-turn with no task_complete. Nothing anywhere records that a signal was involved.

A machine-local process reaper on our side had been SIGTERMing long-running dispatches for weeks (its liveness check watched paths a non-interactive run never writes to — our bug, not Codex's). Because the shim reported success, those kills were invisible. Measured over ~/.codex/sessions for 2026-07-19 → 2026-07-24:

651 codex_exec sessions with >=1 tool call
272 completed (all ended on task_complete)
379 silent deaths (58%) — all ended mid-turn on `reasoning` or `token_count`

Zero error / turn_aborted / stream_error payloads in the entire corpus. The failure rate spanned four CLI versions (0.144.5, 0.144.6, 0.145.0, 0.146.0-alpha.6) and two models, which is what finally pointed away from the model and toward the environment.

Diagnosis took an extended investigation precisely because of the laundering: every retry looked like a clean empty run, so the natural response was to re-dispatch, which burned more time and produced the same nothing. Bypassing the shim and invoking node_modules/@openai/codex-<platform>/vendor/<triple>/bin/codex directly was what finally made the SIGTERM visible — four dispatches then died simultaneously with signal: "SIGTERM" at the same instant, and the reaper's own log confirmed the kill in the same second.

Worth noting the asymmetry that makes this so hard to spot: SIGKILL propagates correctly, because no handler is installed for it. Only the three handled signals are laundered.

grandchild SIGTERM -> shim observed {"type":"signal","signal":"SIGTERM"} -> caller sees exitCode=0 signal=null
grandchild SIGINT  -> shim observed {"type":"signal","signal":"SIGINT"}  -> caller sees exitCode=0 signal=null
grandchild SIGHUP  -> shim observed {"type":"signal","signal":"SIGHUP"}  -> caller sees exitCode=0 signal=null
grandchild SIGKILL -> shim observed {"type":"signal","signal":"SIGKILL"} -> caller sees exitCode=null signal=SIGKILL   (correct)

So any supervisor, CI runner, cgroup OOM killer, systemd unit, or process reaper that uses SIGTERM — the conventional, polite choice — gets a false success, while the one signal that does report correctly is the one nobody sends deliberately.

One addition to the proposed fix worth considering: a process.exit(128 + signo) fallback after the re-raise, guarding the case where a handler is still attached for any other reason. process.kill(process.pid, sig) silently no-ops into "keep running" whenever the signal is handled, so the re-raise alone has no failure mode that surfaces.

Happy to supply more detail if useful. bin/codex.js is unchanged on this point through 0.146.0-alpha.6.