process
stdlib/extra/process.hk: subprocess spawn and capture.
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 and ProcessOutcome 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.
No hermetic test block belongs here: this module has no logic of its own, each function being a thin effectful delegation to the sys native seam, and exercising it needs real, non-deterministic OS state that cannot be asserted in the pure stdlib test pack. Coverage is end-to-end through the CLI integration tests and example programs.
Arriving here for something this module does not have
- Stopping a child. There is no
kill!and no signal API. Run the child from its own actor andactor.shutdown!that actor: the wait insiderun!is interruptible, and shutdown sends the childSIGTERM, waits briefly, thenSIGKILL. Program exit does the same, which leaves a child unable to outlive the program that spawned it (HANKI.md §15). - 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
end
Everything about a spawn that is not the command line: the working directory and how the child's environment is derived from this process's. 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.
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
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
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.