On this page

Execution book

Process execution and capture

ExecSpec resolution, capture-mode pipes, cancellation escalation, result status, bounded memory and disk spill, stdin modes, sandbox wrapping, and failure ownership.

Status
Process and capture state machine
For
Execution, evaluator, journal, and security contributors
On this page
  1. Execution request contract
  2. Program resolution
  3. Stdin modes
  4. Capture-mode spawn
  5. Streaming capture handle
  6. Cancellation ladder
  7. Status decoding
  8. Complete result contract
  9. Memory capture bound
  10. Disk-spill state machine
  11. Evaluator spill adoption
  12. Sandbox application in spawn path
  13. Spawn failure and cleanup ownership
  14. Global configuration caveat
  15. Change protocol
  16. Known sharp edges

shoal-exec is the blocking Unix process engine beneath external commands. It never invokes a shell. It receives a fully resolved ExecSpec, selects pipe capture or a PTY, owns process-group lifecycle, and returns an ExecResult that distinguishes exit status, signal death, stop, truncation, spill, and actual sandbox enforcement.

This chapter covers capture mode. The PTY and job-control chapter covers interactive execution and long-lived terminal sessions.

Sources: lib.rs, capture.rs, watcher.rs, and sandbox.rs.

Execution request contract🔗

ExecSpec contains all information needed to spawn exactly one process:

FieldContract
argvargv[0] program plus exact argument bytes
cwdchild working directory
envcomplete child environment; spawn calls env_clear first
stdinnull, inherited, bytes, or file
modeCapture or PtyTee
sandboxoptional requested shoal_leash::SandboxPolicy
spilloptional directory permitting oversized stdout spill

There is no shell command string, implicit environment merge, global cwd mutation, redirection AST, adapter parser, or Reef constraint in this crate. The evaluator resolves those concerns before it constructs the spec.

flowchart LR
accTitle: Execution request contract
accDescr: Shows the components and relationships described in Execution request contract.
  AST["structured CmdCall"] --> Eval["shoal-eval resolution"]
  Eval --> Reef
  Eval --> Adapter
  Eval --> Leash["policy → SandboxPolicy"]
  Eval --> Spec["ExecSpec"]
  Spec --> Exec["shoal-exec"]
  Exec --> Result["ExecResult"]
  Result --> Outcome["shoal-value OutcomeVal"]

Program resolution🔗

If argv[0] contains /, spawning uses that path as supplied. Otherwise resolve_program searches the PATH entry in ExecSpec.env; only when the spec environment has no PATH does it fall back to the host process PATH.

which treats an empty PATH component as .. A hit must be a regular file with at least one execute bit. Resolution uses native OsStr bytes and does not require UTF-8.

The direct-slash path is not prevalidated by which; OS spawn errors remain authoritative. Search does not apply shell builtins, functions, aliases, extensions, PATHEXT, or shebang parsing itself.

resolve_and_hash resolves with the same rules and hashes the executable bytes with BLAKE3. Kernel hosts use it to build spawn effects compatible with Reef/Leash pin hashes.

Stdin modes🔗

StdinSpecCapture behaviorPTY behavior
Null/dev/nullno bytes forwarded
Inheritparent stdinreal terminal forwarding when available
Bytespiped writer thread, then closewrite into PTY master, then stop writing
Fileopened and installed as fd 0contents written into PTY master

In capture mode, byte-input writing ignores EPIPE because early child exit is expected. File open failure occurs before spawn. A Stream cannot currently become a live stdin producer; evaluator .feed must materialize a supported value into Bytes.

Capture-mode spawn🔗

sequenceDiagram
accTitle: Capture-mode spawn
accDescr: Shows the components and relationships described in Capture-mode spawn.
  participant E as Evaluator
  participant X as shoal-exec
  participant S as Sandbox wrapper
  participant C as Child
  participant O as stdout reader
  participant R as stderr reader
  participant W as cancel watcher
  E->>X: run(ExecSpec::Capture, token)
  X->>S: apply optional sandbox
  X->>X: resolve program
  X->>C: env_clear, cwd, args, pipes, setpgid, spawn
  par concurrent helpers
    X->>O: drain stdout
    X->>R: drain stderr
    X->>W: poll cancellation
  end
  X->>C: wait/reap
  X->>O: join
  X->>R: join
  X->>W: stop/join
  X-->>E: ExecResult

The child calls setpgid(0,0) before exec, giving it a process group whose id is normally the child PID. Cancellation and drop cleanup signal the negative PGID, affecting the spawned process tree rather than only the immediate child.

The command clears inherited environment and installs exactly spec.env. Stdout and stderr are separate pipes. Draining them on two threads is load-bearing: sequential reads can deadlock when a child fills the pipe not currently being drained.

Streaming capture handle🔗

spawn_capture returns StreamingChild with public stdout/stderr readers and hidden child lifecycle state. This is for callers that consume output incrementally.

StreamingChild::wait drops any readers still stored in the handle before waiting. An undrained child then sees a closed pipe/EPIPE instead of leaving the waiter deadlocked behind a full pipe. The result’s stdout/stderr vectors are empty because ownership of stream consumption belonged to the caller.

Dropping a streaming child without wait sends SIGKILL to its process group, waits/reaps the child, marks watcher completion, and joins helper threads. The no-zombie guarantee applies on abandonment, not only the happy path.

Cancellation ladder🔗

CancelToken is Arc<AtomicBool>. All clones share a flag, and cancellation is idempotent.

A watcher polls every 50 ms. Once any watched token trips, exactly one watcher claims escalation:

stateDiagram-v2
accTitle: Cancellation ladder
accDescr: Shows the components and relationships described in Cancellation ladder.
  [*] --> Poll
  Poll --> Poll: not cancelled / child not done
  Poll --> INT: token cancelled
  INT --> Done: child reaped within 3s
  INT --> TERM: 3s grace elapsed
  TERM --> Done: child reaped within 3s
  TERM --> KILL: 3s grace elapsed
  KILL --> Done
  Poll --> Done: owner marks reaped

Each signal targets the whole process group. The watcher checks done before signals and throughout grace periods. This narrows but cannot fully eliminate a PID/PGID reuse race between reap and signal.

Cancellation is not returned as an io::Error; execution returns normally with the actual final exit/signal information. Callers decide how a cancelled outcome should become a language error.

If a distinct token is passed to streaming wait, another watcher is installed. A shared claimed flag ensures only one executes the ladder.

Status decoding🔗

Normal exit produces status = Some(code), signal = None. Signal death produces status = None, signal = Some("SIG..."). Shoal does not encode signals as 128 + n.

Known signal numbers render symbolic names for INT, TERM, KILL, SEGV, ABRT, BUS, FPE, ILL, PIPE, HUP, and QUIT. Other signals render as SIG<number>.

Blocking waits retry EINTR. Capture mode does not request WUNTRACED and can never return stopped = true.

Complete result contract🔗

FieldMeaning
statusnormal exit code, absent on signal death/stop
signalfatal signal, absent on normal exit/stop
stdoutcaptured bytes or bounded preview
stderrcaptured bytes; empty in PTY mode
truncatedsome captured content was lost at a bound
stdout_spilloptional caller-owned spill metadata/file
durelapsed wall-clock duration from spawn to reap
pidchild id
pgidchild process group id
stoppedlive stopped PTY foreground child
enforcementactual sandbox tier/status when requested

An io::Error means resolution, setup, spawning, or wait plumbing failed—not that the child exited nonzero. Exit status and fatal signal are successful execution results.

Memory capture bound🔗

The default in-memory cap is 64 MiB per captured stream. It resolves once from positive SHOAL_CAPTURE_CAP_BYTES or the default, unless the process-level atomic override setter is called. Zero overrides clamp to one byte.

drain_capped continues reading to EOF after the buffer reaches the cap, discarding overflow and setting truncated. Continuing the drain prevents pipe backpressure from blocking the child.

Stdout and stderr each have a resident buffer, so the normal maximum is approximately twice the cap plus overhead. PTY has one merged buffer.

Disk-spill state machine🔗

Only stdout can spill, only in capture mode, and only when ExecSpec.spill supplies an existing directory. The default spill cap is 1 GiB, resolved from positive SHOAL_CAPTURE_SPILL_CAP_BYTES or an atomic override.

stateDiagram-v2
accTitle: Disk-spill state machine
accDescr: Shows the components and relationships described in Disk-spill state machine.
  [*] --> Preview
  Preview --> ResidentComplete: EOF at or under RAM cap
  Preview --> CreateSpill: first byte beyond RAM cap
  CreateSpill --> Spill: tempfile created, preview + overflow written
  CreateSpill --> RamFallback: create failed
  Spill --> Spill: write chunks up to disk cap
  Spill --> SpillTruncated: disk cap/write bound reached
  Spill --> Durable: flush succeeds
  Spill --> RamFallback: flush fails, temp removed
  RamFallback --> [*]: drain remainder, mark truncated
  ResidentComplete --> [*]
  Durable --> [*]
  SpillTruncated --> Durable

The spill file is created lazily at first overflow. At that moment the existing preview plus the overflow tail are written so the file begins at byte zero. BLAKE3 hashes exactly the bytes actually stored. Reads continue to EOF even after the disk cap, but extra bytes are discarded and CaptureSpill.truncated is true.

The returned CaptureSpill contains path, hash, stored length, and its own truncation flag. Ownership passes to the caller, which must adopt/move it into the CAS or remove it. shoal-exec deliberately does not depend on the journal/CAS crate.

When a spill exists, ExecResult.stdout remains the bounded preview. ExecResult.truncated does not mean stdout was lost merely because it spilled; it reflects stderr loss or spill truncation as assembled by the capture path. Consumers must inspect both result and spill metadata.

Evaluator spill adoption🔗

The evaluator’s command path requests a spill when a journal/CAS context is available, then adopts the caller-owned file. A successfully adopted large value becomes lazy CasBytes/an outcome stdout reference. Redirects and .feed must load full content through stdout_bytes, not write only the resident preview.

Any error between process return and adoption must clean up the temporary file. Tests should cover successful adoption, adoption failure, no-journal behavior, disk-cap truncation, and redirects from spilled outcomes.

Sandbox application in spawn path🔗

Before resolution/spawn, sandbox::apply consumes the optional sandbox request. It resolves the program, verifies an optional executable-content pin, and may rewrite argv through the sibling shoal-sandbox-exec helper.

Platform/pathFilesystem enforcementNetwork enforcement
Linux with Landlock ABI/helperapplied, tier Anot enforced
macOS helper/Seatbeltapplied, reported tier Cnot enforced
unsupported hostchild may run unconfined with honest degraded statusnot enforced
any host with hermetic and unmet requestspawn fails closedspawn fails closed

net = deny is advisory unless a future backend reports network_enforced. A hermetic request rejects execution when filesystem or requested network enforcement cannot be fully applied.

The helper is searched beside the current executable or its parent. Missing helper can fail a path that otherwise has an enforcement mechanism. Wrapped argv encodes read/write/delete grants, then --, resolved program, and original arguments after argv zero.

Executable pinning hashes before exec. There is a documented TOCTOU window between hash verification and the kernel executing the path; the status must not claim atomic verified execution.

Spawn failure and cleanup ownership🔗

Failure pointOwner/action
no program/empty argvreturn InvalidInput before child
PATH missreturn NotFound before child
stdin file openreturn I/O error before child
sandbox/pin/helper failurereturn before unapproved spawn, subject to nonhermetic degrade rules
OS spawn including E2BIGpropagate io::Error
reader thread panicsubstitute empty/default drain result; child still reaped
spill create/write/flush failuredrain, fall back to preview/truncation, remove invalid file where possible
caller drops streaming childkill group and reap
process exits nonzeroreturn ExecResult, not io::Error

Global configuration caveat🔗

Capture limits are process-global atomics, not fields in ExecSpec or evaluator/session config. The first environment lookup is cached. Tests and multiple kernel sessions can affect one another if they call setters concurrently or assume per-session limits. Moving limits into requests would make resource budgets composable but requires plumbing through every execution host.

Change protocol🔗

  1. keep ExecSpec fully resolved and shell-free;
  2. preserve native OsString argv/environment data;
  3. put every child in an owned process group before relying on group cancellation;
  4. drain stdout and stderr concurrently and continue draining beyond storage caps;
  5. define resident, spill, and truncation ownership independently for stdout and stderr;
  6. ensure every early/drop path reaps the child and joins helper threads;
  7. report actual sandbox enforcement, never requested enforcement as fact;
  8. test normal exit, every signal class, cancellation rung, E2BIG, missing PATH, closed stdin, over-cap output, spill failure, and caller abandonment;
  9. update OutcomeVal, wire value projection, journal/CAS adoption, and redirect behavior together;
  10. keep PTY-specific stop semantics out of capture mode.

Known sharp edges🔗

  • Unix-specific setpgid, signals, permissions, and wait status are foundational; there is no Windows execution backend.
  • Capture caps are process-global and cached.
  • Stderr never spills and can lose content above the resident cap.
  • Spill write errors are treated like reaching the storage bound rather than preserving a detailed error cause for the caller.
  • Sandbox pin verification has a TOCTOU gap.
  • Network policy has no enforcing backend in the current spawn wrapper.
  • Nonhermetic sandbox requests can degrade to an unconfined child, though status is honest.
  • StreamingChild::wait closes undrained pipes; callers wanting all bytes must drain both concurrently before waiting.
Type to search every guide navigate open esc close
Diagram