Quirlv0.5.1
ArchitectureDecisions

Owned Unix process-group anchors

Canonical Quirl project documentation synced from docs/decisions/2026-08-17_192326083_owned-unix-process-group-anchors.md.

Accepted · 2026-08-17

Context

Quirl used the first guest process as a Unix pipeline's process-group leader. The parent could reap that direct child before cleanup addressed descendants. On Darwin, the kernel may then reuse the numeric process-group identifier. Neither killpg nor bounded existence probes can distinguish a credential- changed owned descendant from an unrelated group that acquired the reused identifier. Signaling after the last owned identity is released can therefore target processes that Quirl did not create.

Safe kqueue registration after exit reports only EV_ERROR/ESRCH, Darwin has not supported NOTE_TRACK since macOS 10.5, the pinned safe Rust interfaces do not expose Apple waitid(WNOWAIT), and the audited crate policy prohibits a new unsafe FFI boundary. Retrying EPERM, accepting it, or probing for eventual ESRCH cannot establish ownership.

Decision

Every Unix process group created by quirl-process has a dedicated direct-child anchor member before guest code can execute. Quirl keeps the anchor unreaped until the group is terminated or confirmed absent. A live group containing the owned anchor prevents its numeric PGID from being reused while any group signal is possible. No code may signal the group after releasing the anchor.

Native pipelines preserve the first guest as group leader. Quirl initially spawns absolute /bin/sh in a fresh group with a fixed staging script and the guest executable plus arguments as inert arguments. The script performs only a bounded executable lookup, stops itself with SIGSTOP, and has not executed any guest instruction when the parent observes that stop. Quirl then starts the anchor directly in the staged leader's group, verifies readiness, and sends SIGCONT; the staging shell execs the guest in the same PID. Later stages join the already-anchored group normally. A missing first executable exits the trusted stage before its stop and remains a ProcessSpawn error; later spawn failures retain the operating-system spawn error.

The anchor is absolute /bin/sh running a fixed, argument-free script with an empty environment. Standard input is a private keepalive pipe, standard output is a private readiness pipe, and standard error is null, so it never reads or writes the user terminal. Before reporting ready it ignores SIGHUP, SIGINT, SIGQUIT, SIGTERM, SIGTTIN, and SIGTTOU. It retains the default SIGTSTP disposition and is polled as the foreground stop sentinel. On Darwin, the PTY can stop the anchor while a guest in the same foreground group misses the terminal SIGTSTP; observing the anchor stop causes Quirl to send the still-owned group SIGSTOP and wait for every guest to stop. SIGKILL and SIGSTOP retain their kernel-defined behavior, allowing deterministic cleanup and explicit whole-job suspension. A one-byte handshake must arrive within two seconds and process-group membership is verified before any guest instruction can run.

ProcessGroupAnchor owns the child and keepalive descriptor. Its cleanup sends at most one group SIGKILL while the anchor is unreaped, directly kills and reaps the anchor as a fallback, and then irrevocably releases the PGID. Group errors remain errors; EPERM is never accepted generically. Direct guest children are still killed and reaped individually so one group-operation failure cannot skip resources the parent can address. Existing terminal leases continue to restore the shell foreground group and termios before an operating error escapes.

For single-root ChildProcessTree containment, the anchor itself leads the group and the guest joins it. Callers must construct containment and apply its command configuration before spawn; post-spawn assignment only verifies membership and cannot establish containment retroactively.

Bounds and failure model

One live group adds one process and two pipes. Startup temporarily adds one reader thread and retains one byte; the thread is joined on success, malformed readiness, EOF, timeout, or spawn/setup failure. Each anchor handshake waits at most two seconds. Native construction also waits at most two seconds for the trusted leader stage to stop, bounding complete native setup to four seconds; that stage becomes the guest and is not an additional retained process. Native pipeline-stage and retained-job limits bound the number of live anchors, and each ChildProcessTree owns exactly one anchor and one guest root. A foreground poll turn consumes at most 16 queued anchor status transitions before returning to guest polling and cancellation.

Failure before readiness kills and reaps the anchor without starting guest code. Failure after any guest spawn keeps the original operating error while a construction guard signals the still-anchored group, kills and reaps every direct guest, and releases the anchor last. Normal exit, nonzero exit, cancellation, deadline, stop/continue, backgrounding, foregrounding, terminal handoff failure, explicit termination, and Drop all converge on the same ownership sequence. If group signaling fails, Quirl reports or records the cleanup failure, directly cleans what it still owns, releases the anchor last, and performs no later PGID operation.

Consequences

  • Darwin PGID reuse cannot redirect Quirl's cleanup signal to an unrelated process group.
  • Each native or adapter process tree costs one additional short-lived process.
  • Unix containment setup can now fail before the requested executable is spawned when the trusted anchor cannot be created or verified.
  • The process crate remains safe Rust and adds no dependency or unsafe FFI.

On this page