Skip to content

Investigate descriptor inheritance between concurrent spawns on macOS #79

Description

@novelKR

Status: investigation and proposal only.

  • The owner authorized this issue on 2026-09-28 as an independent CodeSpace correctness and hygiene investigation.
  • It is not part of CS-RG, which stays suspended, and not a backend rewrite.
  • Nothing here approves an implementation, a pin change or a dependency. Any implementation needs its own approval.

Problem

On macOS, a child spawned by one CodeSpace execution can inherit another execution's transient descriptors while
they are being created in the same process. Two facts combine:

  • Creation is not atomic. Rust std and Tokio (via mio) create pipes and sockets, and portable-pty creates PTYs,
    in two steps: the descriptor first, then close-on-exec. On macOS there is no atomic variant for these calls. In
    between, the descriptor is inheritable.
  • Children exclude nothing. std and Tokio Command do not use POSIX_SPAWN_CLOEXEC_DEFAULT, so a child
    inherits every descriptor that is inheritable at the moment it is spawned.

So, in the Gateway process (InProcess) or a Runner worker (UDS), a concurrent spawn can capture another execution's
pipe end or PTY descriptor. Linux is different: std uses pipe2, SOCK_CLOEXEC and accept4 there, which are
atomic. Linux was not examined further.

Why it matters to CodeSpace

This is inference; none of it has been measured in CodeSpace's own processes.

  • Delayed output EOF. A stray child holding another execution's stdout or stderr write end delays that
    execution's EOF until the stray child exits. Output completion can then wait on an unrelated process.
  • Stdin EOF lost. A stray child holding another execution's stdin write end means closing stdin no longer gives
    that child EOF.
  • PTYs kept open. A stray holder of a PTY slave keeps the terminal open after its child exits, so the master sees
    no EOF. A stray holder of a master keeps the terminal alive after CodeSpace drops it.
  • Long-lived leaks. A long-lived child keeps whatever it inherited for its whole lifetime. An example is the UDS
    worker the Gateway spawns.

Evidence so far

  • Source. Read at Rust 1.88.0, 1.95.0 and 1.98.1, mio 1.2.3, tokio 1.53.1 and Codex 72b8d8b: DevGuard handoff
    packet section 8.
    portable-pty 0.9.0, used by CodeSpace's pinned PTY path, calls openpty and then sets close-on-exec
    (src/unix.rs:36, :64-65).

  • Test: BD-1. A scratch crate outside both repositories, Codex 72b8d8b, one host, one run. During concurrent
    Codex PTY launches, unrelated std and Tokio children received:

    • a transient PTY master in 104 of 2000 cases;
    • a PTY slave in 4 of 2000;
    • pipe ends in 39 of 2000.

    See packet section 11.

  • Test: W3 experiment C1. A scratch harness, two runs, 400 children per cell.

  • Not run:

    • CodeSpace's own processes;
    • representative workloads;
    • frequency of the effects above;
    • Linux.

Spawn sites on macOS (main at ab0341b)

Site Spawner Child-side exclusion
Pipe execution, crates/runner/src/process.rs:283 Tokio Command, piped stdio none
Patch helper, crates/runner/src/patch_helper.rs:50 Tokio Command, piped stdio none
UDS worker, crates/server/src/runtime.rs:33 Tokio Command none
PTY execution through codespace-pty (crates/pty/src/lib.rs:66) Codex portable path, then portable-pty best-effort sweep in the child (close_random_fds, portable-pty src/unix.rs:152, :276)

The Linux sandbox helper is not probed or spawned on macOS (crates/runner/src/linux_sandbox.rs:60-69).

Candidate directions (none chosen)

  1. Kernel close-by-default for CodeSpace's own local spawns.
    • Codex's Command with DescriptorPolicy::Explicit does this.
    • It does not exist at CodeSpace's pin (6b9826e). It is at stable rust-v0.157.1 as DescriptorPolicy::StdioOnly,
      and at the prerelease and Codex main as Explicit.
    • Using it needs a reviewed pin PR under the existing pin process.
  2. A best-effort pre_exec sweep of inheritable descriptors in CodeSpace's own spawns, as portable-pty does. This
    moves std from posix_spawn to fork and exec.
  3. One CodeSpace-internal lock around its own descriptor creation and spawns. It covers only code that takes the
    lock. Descriptors created inside Codex, Tokio or other libraries stay uncovered.

Every candidate must keep today's semantics:

  • stdio wiring, kill_on_drop, reaping, exit status and signals, process group, working directory, PATH and arg0
    behaviour;
  • error reporting;
  • the governance-free off path.

Proposed next steps (each needs its own approval)

  1. Inventory every descriptor-creating and spawning path in the Gateway and Runner processes on macOS, including
    library-internal ones.
  2. A bounded diagnostic: measure whether the effects above occur with CodeSpace's own pipe and PTY paths under
    concurrency, for example one execution's EOF latency while unrelated children spawn.
  3. Compare the candidates on preserved semantics and pin requirements, then choose, or decide no change is needed.

Out of scope

  • DevGuard integration and CS-RG;
  • pin changes;
  • changes to off semantics;
  • any implementation.

Trackers: #76 and novelKR/DevGuard#14.

Activity

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

    processCommand and process supervisor

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions