process
stdlib/extra/process.hk: subprocess capture, attach, detach, pipes, and pseudoterminals.
A pure-Hanki face over the sys native seam: run! bears no @intrinsic, it delegates to sys.process_run!, and the [process] effect bubbles up through the delegating call.
ProcessResult, ProcessOutcome, and ProcessFailure are defined in sys (core) beside the primitive: a subprocess result has no core type to return otherwise (Hanki has no tuple type), and run! therefore surfaces sys.ProcessResult directly. The outcome is a sum (Exited(i32) / SpawnFailed(string)) and no exit_code = -1 magic value: a command that never started is distinguishable from one that ran and failed.
Runs on both tiers (bytecode and LLVM AOT): each runtime implements sys.process_run! over the shared OS layer, and an AOT-built program spawns real subprocesses.
Subprocess lifecycle and stream progress need real OS state. The CLI integration tests exercise them on both tiers.
Arriving here for something this module does not have
- Progress before exit, or stopping a child. Use
start!and theChildmethods. A child handle is actor-owned: it can only transfer to another actor through an explicit resource move.kill!,close!, final drop, actor death, and program exit all reap a live child, which leaves no implicit detach path. There is still no arbitrary signal API. - A child that preserves colour or other
isattybehaviour. Usestart_pty!, which gives it a fresh terminal without lending it this process's terminal. Its output streams are necessarily merged, andChild.set_size!changes a full-screen child's dimensions alongside the parent UI. - A child that must outlive its actor and this program. Use
spawn_detached!. It has noChildhandle, output, input, or exit status; this is the visible exception to the ordinary owned-child rule. - A script and no library.
extra/shis the same spawn with the branching taken out: it crashes when the program cannot be started, a script treating that as a bug and no case to handle. Stay here when "not installed" is something you branch on. - Reading the environment you are about to overlay.
run_with!andrun_in!take the overlay as an argument and mutate nothing;env.vars!is what builds "everything I have, plus one more". - Taking a variable away, or attaching the terminal to a child that also needs a directory or an environment. Both are
SpawnOptions, throughrun_opts!andrun_attached_opts!. An overlay can only add: setting a variable to""sets it to the empty string, which is not the same as unsetting it.with_env_removeunsets, andwith_env_cleaninherits nothing at all.
A credential belongs in stdin_input, never in args: argv is world-readable in ps for as long as the call runs.
run!
def run!(cmd: string, args: List<string>, stdin_input: string) -> sys.ProcessResult [process]
Spawn cmd with the given args and pipe stdin_input into the child's standard input (the child sees EOF after stdin_input is drained). Wait for the child to exit. Returns the captured stdout, stderr, and outcome.
args are passed verbatim: no shell escaping, no globbing. To invoke a shell, the caller spells it explicitly: run!("sh", ["-c", "..."], ""). @no-doctest: spawns a subprocess; environment-dependent, and cannot assert in a doctest
run_with!
def run_with!(cmd: string, args: List<string>, stdin_input: string, env: Map<string, string>) -> sys.ProcessResult [env, process]
run! with an environment overlay: the child inherits this process's environment with env's entries laid on top (the overlay wins on collision). Per spawn by design: there is no process-wide env.set! at all, which would be ambient mutable state racing across actors. Charges [env] alongside [process]: handing a value to a subprocess is the same capability as reading one out, and --deny env therefore reaches it. @no-doctest: spawns a subprocess; environment-dependent, and cannot assert in a doctest
run_in!
def run_in!(cmd: string, args: List<string>, stdin_input: string, cwd: string, env: Map<string, string>) -> sys.ProcessResult [env, process]
run_with! plus a working directory: the child runs in cwd (an empty cwd inherits this process's, matching run!/run_with!), with env overlaid, which shows a spawned tool both a chosen directory and per-spawn environment in one call. Charges [process, env] like run_with!; setting the child's directory is a spawn attribute within [process], needing no further capability. @no-doctest: spawns a subprocess; environment-dependent, and cannot assert in a doctest
run_attached!
def run_attached!(cmd: string, args: List<string>) -> sys.ProcessOutcome [io, process]
Spawn cmd with args attached to this process's terminal and wait for it to exit. The child inherits stdin, stdout, and stderr and has none of them piped, which lets it be interactive: this is what hands the terminal to $EDITOR, a pager, or any child that draws its own screen.
Nothing is captured, and the return is therefore sys.ProcessOutcome, Exited(code) or SpawnFailed(reason), and no sys.ProcessResult, whose stdout and stderr could only ever come back empty. Use run! when you want the output; use this when the user wants it.
Charges [io] alongside [process], on the rule run_with! follows for [env]: handing the terminal to a subprocess is the same capability as writing to it, and --deny io therefore refuses it.
While the child runs, the terminal's interrupt and quit signals are ignored here, which sends Ctrl-C to the child in place of the program waiting on it, and this process's own raw mode, if it had any, is dropped for the child and restored on return. Only what this process saved is restored: a child that crashed mid-raw-mode leaves the terminal as it left it. Refused under --deterministic, whose replay a terminal cannot honour. @no-doctest: hands the terminal to a subprocess; cannot assert in a doctest
SpawnOptions
struct SpawnOptions
cwd: string
env: Map<string, string>
env_remove: List<string>
env_clean: bool
merge_stderr: bool
end
Everything about a spawn that is not the command line: the working directory, how the child's environment is derived from this process's, and whether a streaming child's stderr joins its stdout pipe. Build one from SpawnOptions.defaults() and the with_* updaters; the fields are public for direct construction too.
The environment fields apply in a fixed order: env_clean, then env_remove, then env. A variable named in both env and env_remove therefore ends up set. "Set it, and unset it" resolves to set, the one reading under which the two compose and neither voids the other unannounced. merge_stderr controls start! alone: a pseudoterminal intrinsically merges its streams, run_opts! already drains both output pipes concurrently into separate result fields, an attached child inherits both streams, and a detached child sends all three streams to the platform null device.
impl SpawnOptions
defaults
def defaults() -> SpawnOptions
Plain inheritance: the child gets this process's environment and working directory unchanged, which is what run! does.
SpawnOptions.defaults().cwd => ""
SpawnOptions.defaults().env_clean => false
SpawnOptions.defaults().env_remove.length => 0
SpawnOptions.defaults().merge_stderr => false
with_cwd
def with_cwd(self, val: string) -> SpawnOptions
Run the child in val. An empty string inherits this process's directory, which spares defaults() a separate "inherit" spelling.
SpawnOptions.defaults().with_cwd("/tmp").cwd => "/tmp"
with_env
def with_env(self, val: Map<string, string>) -> SpawnOptions
Lay val on top of what the child inherits; these win on collision. env.vars! is what builds "everything I have, plus one more".
SpawnOptions.defaults().with_env(Map.empty().insert("K", "v")).env.length => 1
withenvremove
def with_env_remove(self, val: List<string>) -> SpawnOptions
Unset these names in the child: the env -u NAME a test harness spends its life doing, which leaves a token sitting in the developer's shell unable to change what the run compares. Overlaying "" is not this; that sets the variable to the empty string, and a child that tells set-but- empty from unset sees two different worlds. Unsetting a name this process never had is not an error.
SpawnOptions.defaults().with_env_remove(["TOKEN"]).env_remove.length => 1
withenvclean
def with_env_clean(self, val: bool) -> SpawnOptions
Inherit nothing: the child's whole environment is the env overlay and no more. What a reproducible harness wants, since it makes the child's environment a function of the program and not of whoever's shell started it. The child then has no PATH, and cmd therefore wants to be an absolute path.
SpawnOptions.defaults().with_env_clean(true).env_clean => true
withmergestderr
def with_merge_stderr(self, val: bool) -> SpawnOptions
Redirect a streaming child's stderr into its stdout pipe. The child then has one output stream to drain, and read_stderr! returns Failed. This is the safe setting when separation does not matter: draining only stdout while a child fills a separate stderr pipe can deadlock.
SpawnOptions.defaults().with_merge_stderr(true).merge_stderr => true
Child
Child, or process.Child, is a runtime-managed native resource handle (HANKI.md §4): live native state the per-actor memory manager owns, released when the last handle drops. It is single-owner - never copied, moved across a send - and has no fields of its own, so its methods are its whole surface. A handle is minted by an API that opens one; it is never constructed.
start!
def start!(cmd: string, args: List<string>, opts: SpawnOptions) -> Result<Child, sys.ProcessFailure> [env, process]
Spawn a child and return immediately with its three piped streams. The command line has no shell interpretation. The environment and working directory follow SpawnOptions; the call charges [env] because the options can hand environment values to the child.
The returned Child is a runtime-managed, single-owner resource. Its final drop terminates and reaps a live child, as close! does eagerly. To keep stdout and stderr separate without risking a full-pipe deadlock, alternate timed reads from both; when separation does not matter, set merge_stderr and drain stdout alone. @no-doctest: spawns a subprocess; environment-dependent
start_pty!
def start_pty!(cmd: string, args: List<string>, opts: SpawnOptions, columns: int, rows: int) -> Result<Child, sys.ProcessFailure> [env, process]
Spawn a child on a fresh pseudoterminal and return immediately. The child sees a controlling terminal of columns by rows cells on stdin, stdout and stderr. This preserves colour and interactive behaviour without handing it this process's terminal. Read the merged output with Child.read_stdout!, write input with Child.write_stdin!, and resize it with Child.set_size!. Unsupported off Unix; the Err names spawn_pty. @no-doctest: spawns a subprocess and needs a POSIX pseudoterminal
spawn_detached!
def spawn_detached!(cmd: string, args: List<string>, opts: SpawnOptions) -> Result<(), sys.ProcessFailure> [env, process]
Spawn cmd independently of this actor and program. This call has no Child handle: successful exec is the last event the caller can observe, and actor death or program exit does not terminate the child. Stdin, stdout, and stderr are the platform null device. On POSIX the child is double-forked into a new session. It cannot acquire a controlling terminal later.
The environment and working directory follow SpawnOptions; merge_stderr is irrelevant because there are no streams. The call uses [process] because a stronger atom would be nominal alone. [process] already permits a shell child to background its own grandchild; another atom could not form a real sandbox boundary. It charges [env] because the options can hand values to the child. @no-doctest: starts a process beyond this program's lifetime
impl Child
read_stdout!
def read_stdout!(self, max: int, timeout_ms: i32) -> sys.StdinRead [process]
Read one stdout chunk, up to max bytes. timeout_ms < 0 waits indefinitely and 0 polls. The sys.StdinRead result distinguishes bytes, timeout, EOF, and an OS failure; the stdin-only Resized arm is never produced. Shutdown interrupts a parked read. @no-doctest: reads a live child pipe; environment-dependent
read_stderr!
def read_stderr!(self, max: int, timeout_ms: i32) -> sys.StdinRead [process]
The stdout read's stderr twin. On a merged child, stderr bytes arrive from read_stdout! and this returns Failed because no second pipe exists. @no-doctest: reads a live child pipe; environment-dependent
write_stdin!
def write_stdin!(self, data: bytes, timeout_ms: i32) -> Result<int, sys.ProcessFailure> [process]
Write one prefix of data and return its byte count. 0 polls, a positive timeout bounds the whole call across retries, and a negative timeout waits for progress. The call returns after its first successful native write. Expiry returns Ok(0); an empty buffer on an open stdin also returns Ok(0). An error accepts no bytes in this call. Retain the unwritten suffix and alternate writes with output reads to make full-duplex progress. Return from actor handlers between bounded batches to service control messages. Shutdown interrupts a parked Unix write. Non-Unix hosts reject finite timeouts on nonempty writes; an indefinite native write is not cancellable. @no-doctest: writes a live child pipe; environment-dependent
writeallstdin!
def write_all_stdin!(self, data: bytes) -> Result<(), sys.ProcessFailure> [process]
Write all input, waiting for capacity as needed. An error may follow a successfully written prefix. Use when the child consumes input without requiring this actor to drain output: simultaneous full input and output queues can deadlock this helper. A full-duplex owner uses write_stdin!. @no-doctest: writes a live child pipe; environment-dependent
close_stdin!
def close_stdin!(self) -> () [process]
Close stdin and deliver EOF after buffered writes drain. Idempotent. A pseudoterminal has no half-close: this hangs it up and makes later output unavailable too. @no-doctest: closes a live child pipe; environment-dependent
set_size!
def set_size!(self, columns: int, rows: int) -> Result<(), sys.ProcessFailure> [process]
Set a pseudoterminal child's window size. The kernel delivers SIGWINCH to its foreground process group. A piped child returns Err; dimensions must fit the platform's unsigned 16-bit terminal fields. @no-doctest: changes a live child pseudoterminal; environment-dependent
try_wait!
def try_wait!(self) -> sys.ChildPoll [process]
Poll for exit without waiting or changing a live child. Returns Running, Exited(code), or Failed(ProcessFailure) with operation try_wait. A detected exit is reaped and cached; output remains drainable. Poll failures release an unwaitable process handle. Closed handles return Failed. @no-doctest: observes a live subprocess; environment-dependent
wait!
def wait!(self) -> sys.ProcessOutcome [process]
Wait interruptibly and reap the child. Its output pipes remain readable afterward, and repeated waits return the first outcome. @no-doctest: waits for a live subprocess; environment-dependent
kill!
def kill!(self) -> sys.ProcessOutcome [process]
Terminate and reap a live child (SIGTERM, bounded grace, then SIGKILL on Unix). Output pipes remain readable; repeated kills return the first outcome. @no-doctest: terminates a live subprocess; environment-dependent
close!
def close!(self) -> () [process]
Terminate a live child and release every pipe now. Idempotent; final drop has the same effect. @no-doctest: terminates a live subprocess; environment-dependent
run_opts!
def run_opts!(cmd: string, args: List<string>, stdin_input: string, opts: SpawnOptions) -> sys.ProcessResult [env, process]
run! with a SpawnOptions: the capturing spawn with a working directory and full control of the child's environment. run_with! and run_in! remain the short spellings of the two common cases; reach for this one when the spawn needs a directory AND an overlay, or needs to take something away. @no-doctest: spawns a subprocess; environment-dependent, and cannot assert in a doctest
runattachedopts!
def run_attached_opts!(cmd: string, args: List<string>, opts: SpawnOptions) -> sys.ProcessOutcome [env, io, process]
run_attached! with a SpawnOptions: the child owns the terminal and also runs where and with what the caller says. The two cases this exists for are a container entrypoint (put a credential in the environment, then hand over the terminal) and a build step (run it over there and let me watch it), neither of which the bare attached spawn could express.
No stdin_input, for the reason there is no captured output: the child reads the terminal directly, and there is no pipe to feed it.
Charges [env] on top of run_attached!'s [process, io], since it now hands the child environment values too. Refused under --deterministic, as run_attached! is. @no-doctest: hands the terminal to a subprocess; cannot assert in a doctest
flattenenv
def _flatten_env(env: Map<string, string>) -> List<string>
The seam's wire shape: a flattened alternating [key, value, ...] list, built from the map's entries. Its length is always even.