Orphaned process pairs burn CPU when terminal is closed (SIGHUP not handled)
Summary
When a terminal tab or window is closed while Codex is running, the Node wrapper (codex.js) and its Rust child process both survive as orphans, with the Rust process entering a tight read(fd=0) → EIO spin loop at ~103K syscalls/sec, burning ~15-19% CPU each. Each terminal close creates one orphan pair (Node + Rust), accumulating indefinitely until manually killed.
Root Cause
Three-layer failure chain:
Layer 1 — Signal suppression: codex.js registers a handler for SIGHUP (line 204) that calls forwardSignal("SIGHUP") but never calls process.exit(). Since Node's default SIGHUP behavior (terminate) is suppressed by the handler, the Node process survives. Because the session leader (Node) survives, the kernel never delivers SIGHUP to the Rust child process — SIGHUP only propagates to the process group when the session leader actually dies.
Layer 2 — PTY I/O spin: When the terminal closes, the PTY file descriptors are revoked. The Rust child's crossterm event reader calls read(fd=0) in a tight loop, each returning EIO immediately (~103K calls/sec). This is a known crossterm bug: crossterm-rs/crossterm#793 (open since June 2024). Codex uses a forked crossterm 0.28.1 from nornagon/crossterm.
Layer 3 — No application-level exit: Even if crossterm's event stream ended gracefully, the Rust process's tokio::select! loop has other active branches that keep it alive.
Reproduction
Minimal reproduction using a Python PTY script:
#!/usr/bin/env python3
"""Reproduce codex orphan bug: spawns codex in a PTY, then closes the PTY."""
import pty, os, time, signal
pid, fd = pty.openpty()
child = os.fork()
if child == 0:
os.setsid()
os.dup2(fd, 0); os.dup2(fd, 1); os.dup2(fd, 2)
os.execvp("codex", ["codex"])
else:
time.sleep(3) # let codex start
os.close(fd) # revoke PTY (simulates closing terminal)
time.sleep(2)
# Check: both Node and Rust processes should be dead, but they're not
os.system(f"ps -o pid,ppid,%cpu,command -p {child}")
os.system("ps aux | grep 'codex' | grep -v grep")
Steps:
- Run the script above
- After 5 seconds, check for orphan processes:
ps aux | grep codex | grep -v grep - Observe: both the Node wrapper and Rust child survive with elevated CPU
Evidence
From a system with 6 orphan pairs (12 processes):
# lsof showing revoked PTY file descriptors on orphaned Rust process:
codex PID user 0u CHR ?,? 0t0 ??? /dev/ttys??? (revoked)
codex PID user 1u CHR ?,? 0t0 ??? /dev/ttys??? (revoked)
# dtruss showing EIO spin rate:
read(0x0, ...) = -1 Err#5 [~103,000 calls/sec]
# CPU usage per orphan pair: ~15-19% each process (30-38% per pair)
# 6 pairs = 104.7% total CPU consumed by orphans
Suggested Fixes
Priority 1: Fix codex.js SIGHUP handler (Node wrapper)
Replace the current signal registration (around line 204):
// Current (broken):
["SIGINT", "SIGTERM", "SIGHUP"].forEach((sig) => {
process.on(sig, () => forwardSignal(sig));
});
// Fixed:
["SIGINT", "SIGTERM"].forEach((sig) => {
process.on(sig, () => forwardSignal(sig));
});
process.on("SIGHUP", () => {
forwardSignal("SIGHUP");
setTimeout(() => {
try { child.kill("SIGKILL"); } catch {}
process.exit(1);
}, 3000).unref();
});
The .unref() ensures that if the child exits normally before the timeout, Node proceeds through the existing exit path (lines 213-229) without the timer keeping the event loop alive.
Priority 2: Fix crossterm EIO spin (Rust/crossterm layer)
The forked crossterm (from nornagon/crossterm) should treat EIO on read() as a stream-termination event rather than retrying indefinitely. See crossterm-rs/crossterm#793.
Priority 3: Rust application-level cleanup
The Rust binary should handle SIGHUP explicitly (e.g., via tokio::signal::unix::signal(SignalKind::hangup())) and initiate graceful shutdown.
Priority 4: Defense-in-depth — detect revoked FDs
The Rust binary could periodically check if its stdio FDs are still valid (e.g., fcntl(0, F_GETFD)) and exit if they've been revoked.
Environment
- OS: macOS 13.7.8 Ventura (x86_64, Intel)
- Codex version: codex-cli 0.115.0
- Node: v25.6.0
- Shell: zsh
This issue has 1 comment on GitHub. Read the full discussion on GitHub ↗