Map: core / IO & process

The byte plumbing under the evaluator’s pipelines and external commands: where a stage’s bytes come from and go, the signals and process groups that govern a foreground child, and the daemon that fires every scheduled action. Authority over the controlling terminal is carried as a value, not re-derived from process state — the foreground handoff is gated on a held TerminalLease.

Windows process creation immediately owns both returned handles as OwnedHandle; failed Job Object assignment or thread resumption releases both. The child keeps the process handle, and the spawning thread releases the thread handle.

IO — core/src/io/

io.rs holds Io, the per-Shell bundle (stdin / stdout / stderr / interactive / terminal / stage), where stdout is wherever the running computation writes — a capture buffer, inside a capture — and stage: Option<Membership> — None at the top level, the orchestrator; Some for a stage’s own children, carrying the group an external spawned anywhere inside that stage must join — at the stage’s own root or nested arbitrarily deep, thread or process alike — and the stage scope whose causes the group’s owner delivers, so the member’s own teardown opens only with a cause that scope does not hold. It decides pgid placement (a top-level standalone external may lead its own group so a watchdog cancel can kill(-pgid, …) the whole subtree; anything inside a stage joins that stage’s pgid) and says whether a child’s reader is the caller or the next stage — never who may foreground. pipeline/launch.rs’s stage_stdin resolves a stage thread’s stdin against its route — the upstream edge, or (ByteIn::Parent) a duplicate of the parent’s own shell.io.stdin — never taking or moving it, since Source::reader only ever hands out a duplicate (pipeline execution).

  • source.rs — Source, a stage’s byte input: Terminal (fall through to fd 0), Empty (no input — immediate EOF, child stdin to /dev/null, no fall-through to fd 0), and Reader(SourceReader) — a pipe (an upstream stage, a here-string) or a file (a <file redirect), carrying the scope a blocked read samples between slices (poll.rs’s poll_slice); a cause in force reads as EOF, never as an interrupted-read error a retry loop would spin on. Source::reader never takes a source: it hands every consumer — a builtin, an external’s stdin, a nested stage — a duplicate, the scope left behind when a child inherits it, so a stage’s stdin outlives every command that reads it. Empty is also what a tty-owning pipeline’s stage 0 resolves to when it has no upstream reader — a thread in the shell process cannot read a terminal whose foreground belongs to the externals’ group — and what an exarch tool run installs so a tool command can never steal the TUI’s terminal; it is kept distinct from Terminal precisely so denial of byte input and denial of foreground stay separate effects.
  • sink.rs — Sink, byte output and child stdio routing (ChildStdioPlan): terminal, stderr, redirect file, in-memory ByteBuffer capture, tee, a watched worker’s line surface (Watch, each whole line handed to a line function the builtin builds around watch_event, so io knows no deferred sink), a stage thread’s pipe edge (Sink::Pipe { writer, edge, stage }, stage naming the writing stage’s node for its reader-gone stop; the scope a write polls is handed in, write_under). child_stdout / child_stderr centralise the (stdio, pump) decision so no caller computes inherit-vs-pipe by hand; same_destination says whether two sinks deliver to one destination, which decides whether a child’s stderr may share stdout’s descriptor. A ByteBuffer is Arc<CapturedBytes>: the prefix and its total byte count under one mutex, with a retention cap. overflowed compares that count against the cap (take, take_counted, peek, total, overflowed are its readers). new_buffer keeps 16 MiB plus a marker; the audit tee retains its caller’s smaller prefix without a marker. The write path cannot report the cap — a pump returns () from its own thread — so overflowed is read once the writers have joined, by whoever means to make the bytes a value (capture).
  • poll.rs — poll_slice, the one sliced fd wait (Unix): wait for readiness for at most POLL_SLICE, EINTR retried against the slice’s own deadline. A blocked pipe write (Sink::write_under) or read (read_interruptible) is a loop of (read the scope; one slice), so another thread ends it by striking a scope, never by waking it. Windows cannot poll an anonymous pipe: the thread waiting on the blocked one aborts its call (interrupt, CancelSynchronousIo), the collector a thread stage’s and Pumps::join_under a pump’s, and the aborted read or write (aborted) re-reads its scope and, under a shield, retries; writes go a 4 KiB chunk at a time, so the sentinel’s one read lands one (cancellation).
  • terminal.rs — TerminalState: cached startup isatty / ANSI / NO_COLOR / mode bits. startup_foreground records whether ral’s group owned the controlling terminal’s foreground at entry; it is not a per-handoff oracle but the lease’s mint condition (terminal-foreground-ownership). On Windows it also owns console_mode_snapshot / restore_console_mode, the termios-snapshot analogue a panic hook restores a raw-mode console through.

Redirect reads and writes — < file, > file and friends — open through the File source/sink here, and the runtime emits a byte-level I/O door at each: the read fires eagerly when stdin is redirected, and every write at frame settle with its target before and after — a stream committed whatever its body did, an atomic > committed, aborted or failed. The event shapes and their card rendering belong to io-surface.

Process — core/src/process/

  • outcome.rs — Signal, WaitOutcome, and the user-facing SpawnFailure / CommandFailure the evaluator surfaces. WaitOutcome::classify yields a ChildEnd: Failed(CommandFailure), or Cancelled(CancelCause) for a death by one of the cause’s signals_of (grace, key, kill), read off the one reader WaitOutcome::death; no death mints a cause, so a torn-down child reports the cause — an expired time limit, a cancel, an interrupt, a shutdown — exactly as a poll point does, while a signal from outside ral still reports its number (cancellation). classify takes cause: Option< CancelCause>, the strongest cause in force when the child ended, joined by max where two parties each ended it — a cancellation in force outranks the collector’s reader-gone kill (raised only once the sentinel hears a dead write) by that order rather than by a special case. It is the sole input to forgiveness for an external: only Some(ReaderGone), and only a death that kill actually caused, keeps no failure; every other status is kept, because the kill precedes the wait and cannot rewrite a recorded status (a-stage-ral-stopped-has-no-failure, the-cut-is-at-the-write).

  • spawn_lock.rs (target_vendor = "apple") — the process-wide RwLock closing Apple’s fork/CLOEXEC race: neither pipe2 nor SOCK_CLOEXEC is atomic there, so a fork racing a pipe’s create-then-fcntl window can hand an exec’d child a fd it should never have inherited. cloexec_pipe / cloexec_socketpair take the shared side; spawn(cmd) is the only fork door, holding the exclusive side around Command::spawn and nothing else — a .output()/.status() caller must spawn through it and wait outside the lock, or a child’s whole lifetime would block every other thread’s fd creation. clippy.toml’s disallowed-methods enforces both doors on this target. Every other target is the identity: no lock, no cost.

  • lease.rs — TerminalLease, the unforgeable authority to hand the controlling terminal to a child via tcsetpgrp. No public constructor, neither Clone nor Copy: a host cannot forge or duplicate it. Minted at most once at session construction iff ral owned the foreground at startup (None on a backgrounded or tty-less launch, and always on platforms with no tcsetpgrp), then lent per run as &TerminalLease to the one chokepoint that foregrounds — TerminalLoan::try_acquire, which is uninvocable without the borrow. The type lives at shell-state; the rationale at terminal-lease.

  • deadline.rs — one lazily started, process-global daemon (ral-deadline) owning a min-ordered heap of (when, action) entries, firing each at its Instant. Deadlines are data, not a thread per worker: arm_lifetime / arm_callback push an entry and return a #[must_use] Deadline guard — dropped, the entry disarms; keep-consumed, it fires regardless (the fire-and-forget mode a replied agent’s follow-up lease needs, since the lease outlives the call that armed it). The fired action is Cancel(scope) | Run(closure): Cancel cancels a ScopeNode with CancelCause::TimedOut (the foreground wall); Run invokes an opaque host closure once, the shape a scheduled wakeup rides — exarch arms a Run that posts a prompt and wakes its idle loop, and an agent worker arms a Run that cancels its own token at its ceiling. The scheduler stays ignorant of prompts, cron, and sessions; recurrence is not a scheduler concept — a recurring producer re-arms from inside its own Run, fired outside the heap lock so it cannot deadlock (scheduled-wakeups, concurrency-primitives).

  • reaper.rs — the process-wide child watch, the one door onto a wait: ChildHandle::into_watch is how a spawned child reaches it, Reaper:: watch how a bare pid does. A SIGCHLD handler on Unix (reaper/unix.rs) whose whole body is a write to a self-pipe, and a thread that per wake asks each watched pid two waitid questions — a stop, consumed and answered with SIGCONT on the spot so it never reaches a subscriber and is never re-reported on every wake, and an exit, observed with WNOWAIT and left as a zombie until Watch::reap, since the pid would otherwise be free for reuse while its owner may still signal it. The exit code is masked to a byte, as waitpid would report it: Darwin’s waitid returns all of exit()’s argument, so macOS bash 3.2’s syntax-error 258 would otherwise surface as 258 instead of 2. Not reaping is the point: holding the zombie is what closes pid reuse structurally, rather than by care. Windows watches the same shape (reaper/windows.rs) via RegisterWaitForSingleObject, where a stop cannot arise. Every subscriber names its own event type — the pipeline collector’s Event::Ended, the standalone command’s bare WaitOutcome channel, spawn_detached’s own — over WaitOutcome, the reaper’s one wire type.

  • cancel.rs — the cause-bearing scope tree (Scope read, ScopeNode written, DurableRoot, CancelCause) for structured-concurrency cancellation, polled cooperatively in hot loops (hot-path-cancellation). A scope’s cancellation is a join: one private resolve over its chain’s flags, naming the outermost node that attains it (ScopeId) and masking at a shielded node (shielded) within the grace from the chain’s first strike; POLL_SLICE is every sliced wait’s period. A signal handler raises an ambient cause instead — REQUESTED_ROOT, an absolute AtomicU8, or an interrupt on the INTERRUPTS count — which no scope folds: forward_ambient hands each to a host thread, which forwards it to its engine as Control (cancellation).

  • signal.rs — signals are causes: the platform handlers translate each delivered signal into a CancelCause on the ambient causes — SIGINT → foreground Interrupted, SIGTERM/SIGHUP → root Terminated — so one cancel-aware wait loop serves user interrupts, timeouts, and termination alike (signals-are-causes). The poll point is Shell::poll, scope-only; clear is the boundary acknowledgment; the ESCALATION counter backs only the third-delivery _exit ladder (escalation_pending is the probe), whose _exit reads CancelCause::code. A raw-mode frontend’s Esc drives the same non-escalating foreground cancel (esc-non-escalating-interrupt). signals_of is one function over each platform’s grace_signal, gesture_signal and KILL; interrupt_handler is the interactive SIGINT disposition — a bare request_interrupt(), with no delivery of its own, since a pipeline’s processes hear a cancellation through the collector — and quit_handler is the Ctrl-\ root abort. Unix install_interrupt_handler installs SIGINT without SA_RESTART so the REPL’s blocked input can return on interruption; it snapshots inherited ignored dispositions before changing them. grace_signal(cause) is the one cause→signal table both teardowns read (RunningChild::terminate and the pipeline collector’s cancel_all): Interrupt → SIGINT, Explicit/Deadline/Terminate → SIGTERM (on Windows all four → Ctrl-Break, named by its STATUS_CONTROL_C_EXIT), ReaderGone/RootAbort → None, straight to the kill (one-delivery-path). Platform handlers live in signal/unix.rs and signal/windows.rs; the Windows side carries the console-control ladder in the Unix shape (two request_interrupt()s, then ExitProcess), which signals no process.

  • child.rs — ChildHandle, the spawned child, and into_watch, the one door from it to the reaper. Every Unix child wait goes through the reaper’s waitid, the one funnel (total-wait-status, superseded on the pid side).

  • group.rs — Pgid / PgidPolicy and the platform spawn_with_pgid family (group/unix.rs) for process-group placement, and the two facts of who signals a group: Membership (a joined pipeline group and its stage scope) and Group (Owns(Pgid), signalled and killed whole, or Joins(Membership)). A pgid is signalled directly by kill(-pgid, …), never waited on. On Windows (group/windows.rs) a group is a Job Object and break_pipeline_group is the grace an owned group’s teardown sends — RunningChild::terminate for Group::Owns, the collector’s Address::signal for a group — before escalating to kill_pipeline_group; a pid gets the kill alone.

  • foreground.rs — unix TerminalLoan takes the &TerminalLease and the run’s node (ScopeNode), performs the tcsetpgrp handoff, snapshots and restores tty foreground / termios, blocks SIGTTOU for the parent-only restore window, hears the tenant’s key back (hear, reclaim; gesture reads GESTURES) and strikes it on the frame on drop — uninhabited on Windows; interrupt_foreground_child re-sends raw-mode Esc/Ctrl-C to a foreground external group.

  • limits.rs — what a child spawned under an active grant may consume: no core dump, and a process budget (macOS’s fork brake, limits/fork_brake.rs, a Windows Job Object cap). Resource-limit installation is checked; the macOS cap respects the inherited hard limit, and limits::failure_hint offers a conditional process-limit explanation for status 128 when no kernel denial was attributed. Launch::limit_resources arms the pre-exec half and ChildHandle::limit_processes the post-spawn one; a brake that cannot be armed is an Err, never a warning. runtime::command::brake caps the job the child already sits in; when it cannot, the child is killed, any group it leads released, and the launch refused as sandbox-unavailable (two-enforcers).

  • tree.rs — sample_descendants, one ps sample of the process table, which with_denials reads only for a sandboxed, uncancelled failure.

  • launch.rs — the owned launch value and its platform interpreters, and the two births: spawn, which hands back a child this process owns and the pgid of a group it leads — NewLeader, NewSession, or behind an envelope the payload’s own — never one it merely joined or inherited, and the cfg(unix) spawn_detached, a double-fork whose grandchild is reparented to init — its pid comes back but nothing else does, so there is no handle, no wait, and (below) no JailCgroup. Unix lowers to std::process::Command and keeps the pre_exec pgid/fd discipline; Windows owns the raw CreateProcessW boundary, including command-line/env rendering, explicit helper-handle allow lists, the SECURITY_CAPABILITIES attribute a confined spawn attaches (its projection’s AppContainer SID and the per-path fs and network capability SIDs — capabilities), the launch mutex, suspended create → Job Object assignment → resume, and the widened ChildHandle raw process wrapper (windows-spawn-boundary). The whole stop-work flow — the Interrupt < Explicit < Deadline < Terminate < RootAbort order — is narrated in cancellation.

  • jail.rs — the guest spawn jail’s decision layer: GuestJail::plan mints each exec a fresh unprivileged uid/gid and a fresh transient cgroup under JailLimits (memory / pids / CPU), with no syscall in the plan. There is one GuestJail per guest process, installed once by the first engine to boot (jail::install_guest, gated on RAL_GUEST, whose value is the cgroup root, ral-daemon’s mounts::EXEC_CGROUP, which the daemon makes and delegates into at boot; the jail makes engine-<pid>/exec-<seq> below it, or detached/exec-<seq> for a detach survivor, and the daemon removes a dead engine’s tree) and read by jail::guest(); a sealed wire-seat child engine (agents) is its own process and installs its own, so the sequence number behind both the uid and the cgroup name is minted guest-globally, not per-engine: linux::next_guest_seq locks a counter file (/run/ral/jail.seq, O_CREAT, read-increment-write under an exclusive flock, released when the file drops) so every booted engine’s jail mints off the one number line and two engines’ first spawned commands cannot collide on a uid or a cgroup name. The cgroup path carries the engine’s own pid as a grouping label, not a uniqueness guarantee (the sequence file already gives that): ral-exec/engine-<pid>/ exec-<seq>, so a child engine’s teardown stays scoped to its own engine’s tree (the daemon kills a dead engine’s commands through engine-<pid>/cgroup.kill, since they leave its process group) and pid recycling can at worst hand a later engine a dead engine’s stale directory name, which its own teardown tolerates. plan takes the launch’s sandbox::Ownership: a Surrendered (detached) exec goes to ral-exec/detached/exec-<seq> instead, out of every engine’s tree, so an engine’s end spares its survivors as it does on the host, and only the daemon’s halt reaches them. Off a real Linux guest, where GuestJail is never actually installed, plan falls back to a plain in-process counter so the module’s own tests stay portable — linux.rs remains the only place a decision reaches the kernel. jail/linux.rs is the thin platform edge that realises a JailPlan — the cgroup tree and limit writes, the pre-exec supplementary-group clear / setresgid / setresuid / NO_NEW_PRIVS, kill-whole via cgroup.kill, and EBUSY-polled removal. The tree is built by descent, and the reason is a cgroup2 rule: a controller’s files appear in a child only once the parent’s cgroup.subtree_control enables it, so memory.max at the leaf needs an unbroken chain of enabling parents up to the cgroup2 mount itself. make_delegated therefore climbs to the first ancestor that already exists — the mount root at the latest, which is exempt from the no-internal-process rule and so may delegate while the daemon and engine still live in it — and enables on the way back down, one level ahead of each mkdir. A parent that will not delegate is refused outright rather than tolerated: a jail whose limits cannot be written must not run the command uncapped instead. JailCgroup is plain data RunningChild carries uniformly (None off a real guest); a detached birth is handed none at all, since a caller that cannot name the process cannot know when its cgroup is empty — the survivor keeps the transient cgroup’s limits and leaves one inert directory until the guest reboots. The jail itself is process-global (a OnceLock in jail.rs), so workers, pipeline stages, and forks share one counter; only a guest engine (RAL_GUEST) installs it, and there it replaces the per-command OS projection (runtime, docs/SPEC.md §12.11).

    The recorded gap. The jail is uid + cgroup + NO_NEW_PRIVS — there is no seccomp filter, so nothing stops a jailed process from calling socket(AF_VSOCK). The one guest listener is the daemon’s engine factory (synod), and a connection it accepts is an unjailed engine that receives whatever its Attach confers, so admission is everything. Two independent lines hold, both kernel-enforced. A jailed process cannot dial a guest-local vsock port at all, measured 2026-08-24: the guest kernel refuses VMADDR_CID_LOCAL with ECONNRESET, and /dev/vsock, the only way to read one’s own CID, is EACCES under the jail. And were a dial to arrive, the daemon accepts only a peer whose CID is VMADDR_CID_HOST, which the guest kernel’s transport writes and no guest process can present. The old token is gone, and loses nothing. A seccomp address-family filter that closes the syscall is unbuilt debt, not a design gap, and its reason is host-side only: the host’s own listeners (the VZ device is synod’s; the Hyper-V socket’s SDDL names the one user) are what a jailed socket(AF_VSOCK) could still reach. The jail plan above has room for it (one more syscall-time check beside NO_NEW_PRIVS).

Spawning an external command is capability-checked; that in-process guard lives in capabilities, and the command/pipeline dispatch that drives this plumbing in runtime.