Manual PTY job-control check
Canonical Quirl project documentation synced from crates/quirl-process/MANUAL_JOB_CONTROL.md.
Failure model and invariants
The native executor treats pipeline construction as a transaction and terminal ownership as a lease. These are the failure cases that every implementation and real-PTY check must preserve:
- Process-group identity: every Unix group has a dedicated direct-child anchor member before guest code can run. The anchor stays unreaped while any group signal is possible, so the live owned group prevents its PGID from being reused for an unrelated group. Cleanup signals the group once while the anchor is owned, then kills and reaps the anchor and never addresses that PGID again.
- Native leader staging: the first native guest starts as absolute
/bin/shrunning a fixed staging script in a fresh group. It validates the executable, stops beforeexec, and is continued only after the separate anchor has joined and reported ready. The same PID then executes the guest, preserving the first stage as process-group leader and the foreground TTY contract without allowing guest instructions before anchored ownership. - Anchor construction: Quirl starts absolute
/bin/shwith a fixed script, an empty environment, piped standard input, and null terminal-facing output. The script ignores interrupt, termination, and background-terminal signals, but retains the defaultSIGTSTPdisposition before its one-byte readiness handshake. The foreground wait polls that owned anchor as a stop sentinel; observing it stopped causes one anchored groupSIGSTOP, covering a Darwin PTY delivery where the anchor stops but a guest misses the terminalSIGTSTP. Spawn, process-group verification, early exit, malformed readiness, and each two-second setup wait all unwind by killing and reaping the anchor before an error is returned. - Partial spawn: every child and descriptor is owned immediately. Failure after any spawn kills the process group, kills each direct child as a fallback, reaps every direct child, closes pending pipe ends, and returns the original construction error.
- Leader exit before group formation: the parent verifies each child's process-group membership after spawn. It never continues with an assumed group, and cleanup addresses both the group and each direct child so a fast leader cannot strand later stages.
- Terminal handoff failure: children remain inside the construction guard until handoff succeeds. A failed handoff terminates and reaps them. Once handoff succeeds, an RAII lease restores Quirl's foreground group and saved termios on success, stop, cancellation, wait failure, and unwinding.
- Stopped child: observing one stopped pipeline member stops every live
member before the job is committed. A stopped job retains its children and
bounded drain tasks until
fg,bg, cancellation, completion, or executor destruction owns the next transition. - Cancellation and deadline: each bounded polling turn observes the request
state. Cancellation or expiry terminates the whole contained process tree,
reaps direct children, drains or closes capture streams, and returns the
cancellation error rather than a cleanup error. Public hosted execution uses
NativeExecutor::execute_interactive_requestorexecute_capture_request; the frozenProcessBackendstring methods are trusted-local conveniences with no host cancellation flag or deadline. - Drains: capture readers continuously drain child output while retaining only the configured byte budget. Here-string writers remain owned by the job while stopped and are joined after completion or termination. When direct children finish first, refresh best-effort terminates surviving group members before joining either kind of task, so inherited pipe ends cannot hang the shell.
- Expansion: one pipeline retains at most 1 MiB of expanded command words
and redirect targets. The budget is cumulative across fragments, words, and
stages; the first byte beyond it fails with
ResourceLimitbefore that byte is appended. NUL bytes are rejected as invalid command data before spawn. - Cleanup failure: cleanup is best-effort across every owned resource; one failed group operation does not skip direct-child cleanup. When an operating error already exists, cleanup cannot replace it. A standalone explicit lifecycle operation reports its own cleanup failure with actionable context. Even when group signaling fails, the direct-child anchor is killed and reaped last; no retry or probe may use the released PGID.
- Retained jobs: a
NativeExecutorretains at most 1,024 job records. At capacity it refreshes state and removes completed records before accepting a new job; 1,024 still-live records fail early withResourceLimit. - Job IDs: zero is never issued. Allocation wraps from
u32::MAXto one and scans the bounded retained table, so an ID cannot collide with a visible job. - Redirects: redirect targets are opened in source order and the last input redirect supplies standard input. Earlier opens still take effect or fail, matching shell descriptor-order semantics.
- Reference dialects: Bash and Zsh compatibility runners are explicitly noninteractive. Their standard input is closed; they remain process-group or Job-Object contained, continuously drain bounded captures, and observe cancellation. Scripts that need interactive reads must be run directly in a terminal outside this compatibility boundary.
Every wait above is either request-bounded or advances in bounded polling turns. Foreground interactive commands may intentionally run until they exit, stop, or receive a terminal signal; they do not retain output in memory.
Each live Unix process group adds exactly one anchor process, two bounded pipes, one retained readiness byte, and one reader thread for at most the two-second anchor handshake. Native groups also have a preceding two-second leader-stop wait, for at most four seconds of total setup; the trusted staging shell becomes the first guest rather than adding another live process. The reader is joined on every startup outcome. Native stage and retained-job limits therefore also bound anchors; established groups retain no anchor reader thread or output buffer. Foreground waits poll at most 16 queued anchor status transitions per turn before yielding to child polling and cancellation.
The canonical Unix PTY harness automates foreground-group ownership, native
Ctrl-Z/Ctrl-C, jobs/bg/fg, fast-leader and construction-failure
races, stopped-job termios preservation, prompt restoration, and the explicit
noninteractive dialect-island policy:
cargo build -p quirl-cli
cargo xtask rich-ptyThe following remains a useful release smoke check in a maintainer's terminal after the automated harness passes.
cargo run -p quirl-cliAt the Quirl prompt:
- Run
sleep 30, pressCtrl-Z, then runjobs. The job must bestoppedand the prompt must accept input normally. - Run
bg %1, thenjobs. The same job id must berunning. - Run
fg %1, then pressCtrl-C. Quirl must regain the terminal and the next prompt must be usable without an extra keypress. - Run
sh -c 'printf out; printf err >&2' | cat. Both streams must complete; Quirl must not hang. - Run
printf hidden > /tmp/quirl-process-manual | cat, thencat /tmp/quirl-process-manual. The pipeline prints nothing and the file containshidden.
Repeat steps 1–3 on both Linux and macOS before calling the Preview job-control gate complete.