Orphaned process pairs burn CPU when terminal is closed (SIGHUP not handled)

Open 💬 1 comment Opened Mar 17, 2026 by mjpensa

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:

  1. Run the script above
  2. After 5 seconds, check for orphan processes: ps aux | grep codex | grep -v grep
  3. 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

View original on GitHub ↗

This issue has 1 comment on GitHub. Read the full discussion on GitHub ↗