hanki

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

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.