ZE-027 - Processes
Processes
This proposal is still a draft and is subject to change. Please do not cite or reference it as a finalized design. Features described here may not be implemented as described and cannot be used right now.
Introduction
Zirric cannot start another program. A script that builds, tags and pushes a release has to be a shell script, and a TUI that shows git status has to shell out through a wrapper written in another language. tasks.Exec runs Zirric files, not arbitrary programs.
This proposal adds a standard library module, proc, that runs other programs and waits for them. A program is described by a Command value and run through a Runner, a capability passed in like fs.FileSystem. Only os hands out the real one; tests use a scripted one. Running a program is a switch point in the sense of ZE-025, so co.timeout, co.map and co.cancel work on processes without anything new. There is no new syntax, no new keyword and no new operator.
Three rules govern the design:
- Arguments are an array, never a string. Nothing is parsed, split, quoted or expanded. The shell is a program you run, not a place commands run in.
- Only
osstarts processes. Code that runs programs takes aproc.Runner, so its signature says so and a test can answer in its place. - A non-zero exit is an error. A failed command returns
Err, carrying its exit code and output. Ignoring a failure takes code; noticing one does not.
Motivation
Scripting other programs is the everyday half of the target domain. Release scripts, code generators, project task runners and developer TUIs all glue git, compilers and formatters together. Today that glue has to live outside Zirric:
#!/bin/sh
set -e
if [ -n "$(git status --porcelain)" ]; then
echo "working tree has uncommitted changes" >&2
exit 1
fi
git tag "v$1"
git push origin "v$1"
It works until it doesn’t: a missing set -e swallows a failure, an unquoted $1 splits on a space, and none of it can be tested without a real repository.
With proc, the same script is ordinary Zirric. Failures are results, arguments are array elements, and the runner is a parameter:
mod code.example.release
import proc
import strings
@Error(fn(err) { return "working tree has uncommitted changes" })
data DirtyTree
// Tags the current commit as v<version> and pushes the tag.
fn tagRelease(runner: proc.Runner, version: String) -> Result {
const status = proc.output(runner, ["git", "status", "--porcelain"])
if status is Err {
return status
}
if strings.trim(status.value) != "" {
return Err(DirtyTree())
}
const tagged = proc.call(runner, ["git", "tag", "v" + version])
if tagged is Err {
return tagged
}
return proc.call(runner, ["git", "push", "origin", "v" + version])
}
The entry point is the only place that reaches for the machine:
const result = release.tagRelease(os.runner(), os.args()[1])
if result is Err {
fmt.fprintln(result.reason, os.stderr())
os.exit(1)
}
And the test runs no program at all:
mod code.example.release._t
import proc
import strings
import tests
import tests.assert
import code.example.release
@tests.Test()
fn testRefusesDirtyTree() -> Result {
var ran = []
const runner = proc.scripted(fn(cmd) {
ran = append(ran, strings.join(cmd.args, " "))
return proc.Reply(0, " M Cavefile.zirr\n", "")
})
return assert.all([
assert.isErr(release.tagRelease(runner, "1.2.0")),
assert.equal(["git status --porcelain"], ran)
])
}
Because running a program is a switch point, the concurrency of ZE-025 applies unchanged. Checking many repositories, four at a time, is one call:
fn statuses(runner: proc.Runner, repos: [String]) -> [Result] {
return co.map(repos, 4, fn(repo) {
return proc.run(runner, proc.inDir(proc.command(["git", "status", "--porcelain"]), repo))
})
}
A test suite that must not hang a CI job gets a limit the same way:
co.timeout(timer, time.minutes(10), fn() { proc.call(runner, ["make", "test"]) })
Proposed Solution
A new module, proc:
| Declaration | Meaning |
|---|---|
data Command { args, dir, env, stdin, stdout, stderr } |
What to run, where, and where its streams go. args[0] is the program. |
command(args: [String]) -> Command |
A command in the current directory, with no environment overrides, input discarded, output captured. |
inDir(cmd: Command, dir: String) -> Command |
The same command, run in dir. |
withEnv(cmd: Command, key: String, value: String) -> Command |
The same command, with one environment variable set for the child. |
withStdin(cmd: Command, input: Input) -> Command |
The same command, reading from input. |
withStdout(cmd: Command, output: Output) -> Command |
The same command, writing standard output to output. |
withStderr(cmd: Command, output: Output) -> Command |
The same command, writing standard error to output. |
union Input { Inherit, Discard, FromBytes, FromReader } |
Where the child’s standard input comes from. |
union Output { Inherit, Discard, Capture, ToWriter } |
Where the child’s standard output or error goes. |
data Finished { stdout: Binary, stderr: Binary } |
A successful run. Streams that were not captured are empty. |
data Runner { run: fn(Command) -> Result, find: fn(String) -> Option } |
The capability to start programs. |
run(runner: Runner, cmd: Command) -> Result |
Runs cmd and waits for it. Ok(Finished) on exit code 0, an error otherwise. |
find(runner: Runner, program: String) -> Option |
The path program would run from, or None(). |
attr HasRunner |
Provides a Runner, like fs.HasFileSystem provides a filesystem. |
scripted(respond: fn(Command) -> Reply) -> Runner |
A runner that starts nothing and answers every command with respond. For tests. |
data Reply { code: Int, stdout: String, stderr: String } |
What a scripted program “printed” and how it exited. |
forbidden() -> Runner |
A runner that refuses every command. For tests. |
The members of Input and Output:
| Member | In | Meaning |
|---|---|---|
data Inherit |
Input, Output | The child uses the program’s own stream: the terminal, usually. |
data Discard |
Input, Output | Nothing in, nothing kept. The child reads end of input or writes to nowhere. |
data FromBytes { content: Binary } |
Input | The child reads content, then end of input. |
data FromReader { reader: @io.Reader } |
Input | The child reads what reader yields, until it is exhausted. |
data Capture |
Output | Collected into Finished, or into the error if the run fails. |
data ToWriter { writer: @io.Writer } |
Output | Written to writer as it arrives. |
Errors, all @Error:
| Error | When |
|---|---|
data NotFound { program: String } |
args[0] is not a path and is not on PATH. |
data CannotStart { program: String, reason: String } |
The program exists but the host refused to start it. |
data Exited { args: [String], code: Int, stdout: Binary, stderr: Binary } |
The program ran and exited with a code other than 0. |
data Forbidden { args: [String] } |
A forbidden() runner was asked to run something. |
Helpers, written in plain Zirric on top of the above:
| Helper | Meaning |
|---|---|
call(runner: Runner, args: [String]) -> Result |
Runs with output shown, as a line in a shell script would. Ok(void) or an error. |
output(runner: Runner, args: [String]) -> String! |
Runs and returns standard output as a String, as $(…) would, without trimming. |
interactive(runner: Runner, args: [String]) -> Result |
Runs with all three streams inherited: editors, pagers, password prompts. |
shell(script: String, args: [String]) -> [String] |
["sh", "-c", script, "sh", …args]: the arguments to run script with sh, passing args as $1… |
Two additions outside proc complete it: os.runner() -> proc.Runner, the real runner, and a handover for os.stdin(), described under Standard input.
Shell scripts
A script file is a program like any other; the host reads its #! line:
proc.call(runner, ["./scripts/deploy.sh", "staging"])
A snippet of shell is run by running a shell. shell builds the arguments and nothing else, so it is pure and its output can be asserted on:
proc.call(runner, proc.shell("make && make install", []))
Values go in as arguments, never into the script text. The shell sees "$1" and receives the value unparsed, so a file name with a space or a ; stays one argument:
proc.call(runner, proc.shell("tar czf \"$1.tar.gz\" \"$1\"", [name]))
Expected failures
Some programs use their exit code as an answer: grep exits 1 when nothing matched, diff when the files differ. Exited carries the code and the captured output, so the caller turns the expected case back into a value:
fn contains(runner: proc.Runner, pattern: String, path: String) -> Bool! {
const result = proc.run(runner, proc.command(["grep", "-q", pattern, path]))
if result is Ok {
return Ok(true)
}
if result.reason is proc.Exited {
if result.reason.code == 1 {
return Ok(false)
}
}
return result
}
Streaming output
Capture collects everything until the program exits. ToWriter streams instead, so a long build appears as it runs:
proc.run(runner, proc.withStdout(proc.command(["make"]), proc.ToWriter(out)))
A TUI that must also react to keys needs the output as a channel, to co.select on it next to them. That takes a writer of four lines and nothing from proc:
// Sends every chunk it is given into a channel.
@io.Writer(fn(w, buf) {
co.send(w.channel, buf)
return len(buf)
})
data ChannelWriter {
channel: co.Channel
}
The command runs in a routine that closes the channel when it ends; the event loop selects on [keys, chunks], and co.cancel on its scope stops the build when the user presses q.
Testing
The Runner is passed in like fs.FileSystem and co.Timer. Two doubles cover the common cases:
scripted(respond)callsrespondwith eachCommandand routes theReplyexactly as the real runner routes a real program’s output:Capturecollects it,ToWriterwrites it,InheritandDiscarddrop it. An exit code other than 0 becomesExited.findanswersSome(program)for every program.forbidden()answers everyrunwithErr(Forbidden(args))and everyfindwithNone(). Passing it proves that a function runs nothing.
respond sees the whole Command, so a test can assert on the directory, the environment and the input as well as the arguments.
Detailed Design
Commands
| Rule | Consequence |
|---|---|
Each element of args reaches the child as one argument, byte for byte. |
No splitting, quoting, globbing, ~ or $VAR expansion. Ever. |
args[0] containing a path separator is a path, relative to the command’s directory. |
./deploy.sh runs the file next to the command’s dir. |
Otherwise args[0] is looked up on the parent’s PATH when run is called, as find does. |
A program installed while the script runs is found. PATH overrides in env don’t change the lookup. |
An empty args panics. |
It is a bug in the caller, not an expected failure (ZE-008). |
dir is "" for the program’s own directory, os.cwd(). A relative dir is relative to it. |
inDir(cmd, "build") means what it reads as. |
The child gets the parent’s environment, with each entry of env set on top. |
Overrides are visible at the call site; the rest is inherited. |
command(args) sets stdin to Discard and both outputs to Capture. A program started by accident can neither read the user’s keys nor write over a TUI, and the error of a failed run carries what it printed. The helpers state other choices by name: call inherits both outputs, interactive inherits all three streams.
Exits and errors
| Outcome | run returns |
|---|---|
| Exit code 0 | Ok(Finished(stdout, stderr)) |
Exit code n, not 0 |
Err(Exited(args, n, stdout, stderr)) |
Stopped by signal n (Unix) |
Err(Exited(args, 128 + n, stdout, stderr)), as shells report it |
Program not on PATH, or no file at the given path |
Err(NotFound(program)) |
| File exists but cannot be started (permissions, format) | Err(CannotStart(program, reason)) |
| The routine running it was cancelled | Err(co.Cancelled()) |
Exited debugs as the command and code followed by the first line of captured standard error: git push origin v1.2.0 exited with 128: fatal: could not read from remote. Output that was not captured is empty in Finished and in Exited alike.
Streams
| Target | Behavior |
|---|---|
Inherit |
The child gets the program’s own descriptor. For standard input, see below. |
Discard |
The null device. |
FromBytes |
Written to the child’s input, then closed. |
FromReader |
Read with io.read and written to the child’s input until the reader returns an empty Binary, then closed. |
Capture |
Read by the host in full, whatever the child does with its other stream. Capturing both outputs cannot deadlock. |
ToWriter |
Written with io.write as it arrives, one call per chunk the host read. |
Captured output is held in memory without limit. A program that prints gigabytes should stream with ToWriter.
A child writing to an inherited stream and a routine printing with fmt may interleave on the terminal. Each fmt.fprint* call stays whole (ZE-025), the child’s writes are its own.
Scheduling
proc.run is a switch point of the outside-world kind (ZE-025): it always gives way, whatever the runner. As with io.read, the module decides, not the value behind it, so a test with scripted sees the same switch points as production with os.runner().
| Rule | Consequence |
|---|---|
| While the child runs, the calling routine is in the host and the run lock is released. | Other routines keep running. A TUI keeps drawing while make builds. |
Copying for FromReader and ToWriter happens in routines of a scope that run opens itself. |
The reader and writer are ordinary Zirric values, called at ordinary switch points. |
run returns once the child has exited and every byte has been copied. |
Nothing run started outlives it, in line with ZE-025’s scopes. |
scripted routes its Reply through the same routines. |
A ToWriter that blocks, like a channel writer, blocks in tests too. |
Standard input
ZE-025 left one thing out on purpose: the process-wide reader behind os.stdin() may have a read in flight, and a child given the same descriptor would lose bytes to it. proc resolves this with a handover:
| Rule | Consequence |
|---|---|
The host stream behind os.stdin() only reads while a routine is waiting for it, and only once the descriptor reports input ready. |
It never holds a read nobody asked for. |
Before a child with stdin set to Inherit starts, the stream stops waiting for input. |
Nothing typed after the child starts goes to the parent. |
| Bytes the stream had already read stay in it for the next Zirric reader. | Nothing typed before the child started goes to the child. |
While the child runs, a routine reading os.stdin() waits at a cancellable switch point. |
A key reader in a TUI simply pauses. When the child exits, reading resumes. |
Only one child inherits standard input at a time. Another Inherit waits for it, first come, first served. |
Two editors never fight over one keyboard. |
FromReader(os.stdin()) is different on purpose: the child reads a pipe, fed through the Zirric reader, and never sees the terminal. It suits filters like sort; it does not suit programs that check whether they talk to a terminal.
The terminal
proc knows nothing about terminal modes. A child that inherits the terminal gets it in whatever state the parent left it, including the raw mode and alternate screen a TUI switches on. A TUI that opens $EDITOR must restore the terminal first and re-enter afterwards. That belongs in the TUI library of ZE-026, as a function that takes a body and runs it with the terminal handed back, so proc stays small and a CLI without a TUI pays nothing for it.
Cancelling
Cancelling a scope stops a routine in run like any other (ZE-025). The difference: what it waits on is a process, and a process has to be stopped, not abandoned.
| In flight | On cancel |
|---|---|
| Waiting to inherit standard input | Stops at once. The program never starts. |
| Program running | Asked to terminate (SIGTERM; on Windows, terminated). If it still runs after five seconds, killed. The routine stops once it has exited. |
Copying into a ToWriter |
Stops at the next switch point. What was written stays written. |
Feeding a FromReader |
Stops; the child’s input is closed. |
A cancelled run is delayed, not stuck: a scope waits at most the grace period plus the time the host takes to reap the child. wait on the routine returns Err(co.Cancelled()), never Exited, even though the program did exit.
os.exit kills every child still running, without a grace period. Children do not outlive the program that started them, just as routines don’t.
Only the direct child is stopped. Programs it started itself (a shell script’s commands, a build’s compilers) are its responsibility, as they are for a shell.
Implementation
- The real runner uses Go’s
os/exec. ACommandmaps toexec.Cmdfield by field;args[0]is resolved withexec.LookPath, which also appliesPATHEXTon Windows. - Starting and waiting are host calls: the run lock is released before and reacquired after (ZE-025, Implementation).
- The standard input handover needs the host stream to poll its descriptor before reading, so that it can stop waiting without having consumed a byte. On Unix a terminal, pipe or file descriptor supports this.
- The module decides the switch point:
runis declared a switching extern, andscriptedruns through the same Zirric code path around it.
Static checks, tooling
Nothing new is needed. Command, Input, Output and the errors are ordinary data and union declarations; a misspelled member or a wrong argument count is reported like any other. There is no new grammar, so the formatter, language server and tree-sitter grammar need no work.
Deliberately not
- Command strings.
procnever splits"git commit -m 'x'".shellexists for the cases where a shell is wanted, and it says so. - Choosing a shell per platform.
shellrunssh. On Windows, runcmd /corpwsh -Commandexplicitly. - A process handle. No
start,pid,killorwaitinproc.co.spawn,co.waitandco.cancelalready are the handle, with an owner. - Signals. Beyond termination on cancel, no signal can be sent. A later proposal can add
interruptwithout breaking anything. - Pipelines between programs.
a | bruns throughshell, or by capturingaand feedingbwithFromBytes. A streaming pipe can follow once real programs ask for it. - Replacing the running program (
execin the Unix sense). A Zirric program always gets control back. - A pseudo-terminal. A child that needs a terminal inherits the real one.
- Configurable grace periods, output limits or environment clearing. See Open Questions.
- Terminal modes. They belong to the TUI library, not to processes.
Changes to the Standard Library
- New module
proc, as listed above. os.runner() -> proc.Runner: the real runner.- The host stream behind
os.stdin()reads only on demand and can hand the descriptor to a child. - ZE-025’s “Deliberately not: handing standard input to child processes” is resolved by the handover.
Compatibility
Nothing breaks. proc is a new module, and the change to os.stdin() is invisible to programs that start no child: a routine waiting for input receives the same bytes in the same order as before.
Dependencies on Other Proposals
| Proposal | Dependency |
|---|---|
| ZE-008 | Failed runs are @Error values; an empty command panics. |
| ZE-009 | find returns an Option. |
| ZE-018 | os hands out the real runner; streams are @io.Reader and @io.Writer. |
| ZE-019 | !., !! and ?? work on what run, output and find return. |
| ZE-025 | run is a switch point; cancellation stops the child; the standard input stream is handed over. |
| ZE-026 | Restoring the terminal before an interactive child runs. |
Alternatives Considered
Do nothing
Zirric stays a language for the logic between programs, and shell scripts do the gluing. tasks.Exec already runs Zirric files as tasks.
It loses because gluing programs together is what CLI scripts mostly do. A Zirric script that cannot call git is replaced by a shell script, and the testability the rest of the standard library is built for never reaches it.
Command strings
proc.run(runner, "git tag v" + version) reads like the shell. It means parsing a string into arguments, which means quoting rules, which means every value spliced in is an injection waiting for a space or a ;. Arrays have no such rules. shell keeps the string form available where the author asks for a shell by name.
Syntax for commands
Backticks, $(…) or a sh"…" literal as in zx or Bun Shell. Terse, and a sub-language with its own interpolation and escaping rules inside the language. Everything it offers is a function call here.
A process handle
Go’s exec.Cmd, Rust’s Child and Python’s Popen return an object to wait on, kill and inspect. In Zirric that would be a second lifecycle next to routines: its own wait, its own cancel, and processes that outlive their caller when nobody waits. A routine that runs proc.run already is that handle, and it has a scope that owns it.
Exit codes as values
run could return Ok for every exit and leave the code in Finished, as Python’s subprocess.run does unless check=True is passed. That default is the reason check=True exists: a failure is ignored unless someone remembers to look. Here, ignoring a failure is the visible choice, and Exited keeps the code and output for the callers that expect it.
os.run directly
Shorter, one import fewer. It puts starting programs behind a global function no test can replace, which is exactly what os is kept free of: os hands out capabilities, it does not act.
Inheriting all streams by default
command could behave like a shell line and inherit everything. Then any program run by a TUI could read its keys and write over its screen, and a failed run’s error would say nothing about why. call and interactive give the shell behavior by name.
Other module names
| Name | Why not |
|---|---|
exec |
Suggests replacing the running program, and reads like tasks.Exec, which does something else. |
sh |
Suggests a shell, which is the one thing proc does not use unless asked. |
cmd |
Reads like Windows’ cmd.exe, and is the obvious name for a local Command. |
process |
Fine, but long for a module used on every line of a script. proc is to it what fs is to “filesystem”. |
run |
A verb, and run.run reads badly. |
Open Questions
- Grace period. Five seconds between terminate and kill is a guess. Should
Commandcarry it, or is one fixed value the right amount of choice? - Output limits. Should
Capturetake a limit, failing with an error beyond it, instead of holding anything in memory? - A clean environment. Should there be a way to start a child without the parent’s environment, for reproducible builds?
- Lines. A helper that turns a program’s output into a channel of lines would make TUIs shorter. Does it belong in
proc, or as a line-splitting writer inio? - Streaming pipelines. Is
shellenough fora | b, or do real programs need a pipe between twoCommands? - Process groups. Should cancelling stop the child’s own children too, by starting it in a group of its own? That would stop
Ctrl-Cfrom reaching it through the terminal. - Windows. The standard input handover assumes a descriptor that can be polled. The Windows console needs a separate check before this proposal leaves Draft.
- ZE-026. The terminal handover for interactive children needs a name and a shape in the TUI library.
Acknowledgements
Prior art: Go’s os/exec for argument arrays and LookPath; Rust’s std::process::Command for the command-as-value shape; Python’s subprocess for check=True and what happens without it; Trio’s run_process for cancellation that terminates the child; Deno’s permissions for runners a program must be handed; zx and Bun Shell for how short script code wants to be.