Skip to content

codex-utils-pty: let a caller observe an exited PTY child before the waiter reaps it #53298

Open

Description

@novelKR

What variant of Codex are you using?

The codex-utils-pty Rust crate, used as a library dependency at rust-v0.154.0.

What feature would you like to see?

An opt-in, bounded way to look at an exited PTY child before codex-utils-pty reaps it.

Today both Unix PTY spawn paths (spawn_process_portable and spawn_process_preserving_fds in utils/pty/src/pty.rs) reap the child in their own waiter as soon as it exits. That is the right default, and the waiter must stay the only reaper. But a caller that accounts for a launch by its process group cannot inspect the group while the exited root still holds its PID and process group ID. Once the root is reaped, a descendant that ignored SIGHUP and stayed in the group can no longer be attributed with certainty, because the ID may be reused. The caller cannot reap the child itself, and a second waiter would race the existing one.

Proposed shape (implemented in the reference patch below):

  • spawn_pty_process_observed(program, args, cwd, env, arg0, size, inherited_fds, observer), the same as spawn_pty_process plus an observe::ExitObserver { opportunity: oneshot::Sender<ExitOpportunity>, bound: Duration, outcome: oneshot::Sender<ObservationOutcome> }. The observer is registered before the spawn, so even a very fast exit cannot be missed.

  • When the child exits, the existing waiter calls waitid(P_PID, pid, WEXITED | WNOWAIT). The child stays a zombie and keeps its PID and process group ID.

  • The waiter then publishes one ExitOpportunity, which carries no PID, and waits until it is acknowledged or the bound passes. Only then does it reap through the original child object, exactly as today.

  • After the reap, and before the exit code is sent, it reports one of:

    • Completed;
    • TimedOut;
    • Failed, when the observer dropped the opportunity or stopped listening;
    • Unavailable, when the exit could not be seen without reaping.

    An acknowledge() after the bound returns OpportunityExpired.

  • Without an observer, nothing changes: spawn_pty_process and every existing path are untouched. Platforms other than Unix refuse an observed spawn before creating a process.

The bound keeps every failure finite. A stalled observer delays the reap by at most the bound, and a dropped one ends the wait at once. Termination and handle Drop during the window still reach the process group, which the unreaped child keeps, and the child is reaped exactly once.

Additional information

  • Reference implementation: a patch to rust-v0.154.0, one commit exported with git format-patch: https://github.com/novelKR/CodeSpace/blob/374a3fc6f9897ce97d1eb9b442df8d0f627aba08/third_party/codex-patches/0001-utils-pty-pre-reap-observation.patch. It is 304 added and 5 removed lines of product code (observe.rs, pty.rs, lib.rs) plus nine tests, each run on both Unix paths. The tests cover:
    • the exit seen before the reap;
    • the bound, including a late acknowledgement refused;
    • a dropped opportunity and a dropped observer;
    • a SIGHUP-ignoring descendant still in the root's process group during the window;
    • termination in the window;
    • a handle dropped in the window;
    • 256 KiB of output draining fully;
    • a runtime shutdown held only for the bound.
  • Verified on Linux x86_64 (36 crate tests) and macOS (38; two existing tests are macOS-only): every test passes in each of 30 repeated runs, and clippy is clean on Linux, macOS and Windows targets. On macOS we also ran it under our resource manager: a surviving descendant stayed attributed to its launch until it ended (10/10), a stalled observer was reaped at its 500 ms bound, and the observer added about 4 ms at the median to the exit report.
  • ProcessDriver / spawn_from_driver would make the caller own PTY allocation, terminal setup, I/O and reaping, duplicating this crate. A hook inside the existing single waiter avoids that.
  • CodeSpace has used it for its governed PTY executions since 2026-10-12 (pty, runner, server: observe governed PTY executions before the reap (CSRG-U5) novelKR/CodeSpace#95): the resource manager observes the exited root's process group in the window, so a descendant that outlives its root stays attributed to its launch until it ends.
  • We carry this as a downstream patch and will drop it once an upstream equivalent exists. Any API shape that keeps the single waiter, a bound and an outcome would meet our need.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    enhancementNew feature or request

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions