Proc
Proc: child processes, as an ordinary algebraic effect.
The capability is Proc. Its operation collect runs a Command to completion: the host spawns the child, feeds it its input, drains both of its output streams, reaps it, and answers an Outcome. collect_pipeline does the same for a Pipeline, a chain of commands each reading the one before it. Nothing outlives either operation. There is no handle to a running child, no pipe to forget to close, and no zombie for a program to leak, because every child’s whole life is inside one step. run_proc is the default handler over the host; a test installs its own with with_fake_proc and the program under it is unchanged.
run_proc(\() -> check(exec("git", ["rev-parse", "HEAD"])))
A Command says exactly what the child gets, and nothing reaches it that the command does not name:
- The program is found on the parent’s
PATH, never the child’s, so editing the child’s environment cannot change which binary runs. APATHentry that is not absolute is skipped. A name containing/is not searched for: it is used as written, relative to the child’s directory. - The environment is the parent’s with edits applied in order (Inherit), or only the variables the command sets (Clean). - Standard input is empty (NoInput) or the given bytes (Feed). Standard output and standard error are each discarded or captured up to a byte limit; a child that writes past its limit is killed and the outcome isOutputLimit. The default limit is 1 MiB and no limit may exceed 256 MiB. - An optional deadline in milliseconds bounds the whole exchange. A child still running when it passes is killed and the outcome isDeadlineExceeded.
A child the host had to kill, for its output or its deadline, answers with both captured streams empty: what it had written by then depends on scheduling, and an answer that differs from run to run is not one a program can rely on.
What the host kills is the direct child. A process that child started and left running in the background is not tracked, on any platform; a command that must not outlive its deadline should not detach work of its own.
Recorded runs hold each exchange as one observation that commits to a digest of the request, so a replay serves the recorded outcome only to the same command. Proc is not in the replayable set all the same: a recorded outcome does not reproduce what the child did to the world.
Types
ProcError
type ProcError
= NotFound
| PermissionDenied
| BadCommand
| ResourceLimit
| HostError
deriving (Eq, Show)
Why a child did not run, or why the host gave up on it. A closed, platform-independent classification, mapped once from the host’s error codes, so the same failure is the same value on every host.
StdStream
type StdStream = Stdout | Stderr deriving (Eq, Show)
One of the child’s two output streams.
ExitStatus
type ExitStatus
= Exited(Int)
| Signaled(Int)
| SpawnFailed(ProcError)
| OutputLimit(StdStream)
| DeadlineExceeded
| Aborted(ProcError)
deriving (Eq, Show)
How a child’s run ended. Exited carries the exit code and Signaled the host’s raw signal number. SpawnFailed is a child that never started; Aborted is one the host lost track of after it did.
EnvBase
type EnvBase = Inherit | Clean deriving (Eq, Show)
Where the child’s environment starts.
EnvEdit
type EnvEdit = SetVar(String, String) | UnsetVar(String) deriving (Eq, Show)
One change to the child’s environment, applied in order.
Input
type Input = NoInput | Feed(Buf)
The child’s standard input.
Output
type Output = Discard | Capture(Int) deriving (Eq, Show)
What happens to one of the child’s output streams.
Command
type Command = Command {
program: String,
arguments: List(String),
directory: Option(Path),
base_env: EnvBase,
env_edits: List(EnvEdit),
input: Input,
stdout_policy: Output,
stderr_policy: Output,
deadline_ms: Option(Int)
}
Everything the child gets. Build one with command and adjust it with a record update.
Outcome
type Outcome = Outcome { status: ExitStatus, out: Buf, err: Buf }
What a run produced: how it ended, and the captured bytes of each stream.
Pipeline
type Pipeline = Pipeline { stages: List(Command), deadline_ms: Option(Int) }
A chain of commands, each reading the standard output of the one before it, under one deadline. Build one with pipeline.
The pipes between stages belong to the host, so three fields of a stage Command are decided by its place in the chain. The first stage’s input is the pipeline’s input, and every later stage must have NoInput, since it reads the stage before it. The last stage’s stdout_policy is the pipeline’s output, and every earlier stage’s is the pipe. No stage carries a deadline of its own; the pipeline’s bounds the whole run. A stage that breaks these rules makes the pipeline a BadCommand. Each stage’s stderr_policy is its own.
PipelineResult
type PipelineResult = PipelineResult {
statuses: List(ExitStatus),
out: Buf,
errs: List(Buf)
}
What a pipeline produced: every stage’s status in order, the last stage’s captured output, and every stage’s captured error stream in order.
A stage that could not start answers SpawnFailed, and its neighbours run anyway, as they would in a shell: the stage after it reads an empty input and the stage before it finds its output closed. Every status is kept, so whether a failure counts is decided afterwards with check_last, check_all, or check_pipefail.
When the host stops the run, every stage that had started answers why, and every stream is empty, for the reason a single command’s are. Past the deadline, that is DeadlineExceeded for every stage. When one stream goes over its limit, its stage answers OutputLimit and every other stage that had started answers Aborted(ResourceLimit).
StageFailure
type StageFailure = StageFailure {
stage: Int,
status: ExitStatus
} deriving (Eq, Show)
The stage, counted from 0, whose status failed a check, and that status.
Effects
Proc
effect Proc
collect(Command) : Outcome
collect_pipeline(Pipeline) : PipelineResult
The process capability. collect runs a command to completion, and collect_pipeline a pipeline.
Functions and Values
default_capture
default_capture : Int
The capture limit a command starts with, in bytes.
max_capture
max_capture : Int
The largest capture limit a command may ask for, in bytes.
command
command : (String, List(String)) -> Proc.Command
program with arguments, inheriting the environment, with no input, both streams captured up to default_capture, and no deadline.
exec
exec : (String, List(String)) -> Proc.Outcome ! {Proc.Proc}
Run program with arguments under the defaults of command.
check
check : (Proc.Outcome) -> Result(Buf, Proc.ExitStatus)
The captured standard output of a run that exited with code 0, or the status of one that did not.
pipeline
pipeline : (List(Proc.Command)) -> Proc.Pipeline
stages as a pipeline with no deadline.
check_last
check_last : (Proc.PipelineResult) -> Result(Buf, Proc.StageFailure)
The output of a pipeline whose last stage exited with code 0, whatever the earlier stages did. This is a shell’s default.
check_all
check_all : (Proc.PipelineResult) -> Result(Buf, Proc.StageFailure)
The output of a pipeline every stage of which exited with code 0, or the first stage that did not.
check_pipefail
check_pipefail : (Proc.PipelineResult) -> Result(Buf, Proc.StageFailure)
The output of a pipeline every stage of which exited with code 0, or the last stage that did not. This is a shell’s pipefail: the same runs fail as under check_all, and the failure named is the one a shell would report.
text
text : (Buf) -> Option(String)
Captured bytes as text, when they are valid UTF-8.
run_proc
run_proc : forall e0 a. (() -> a ! {IO, Proc.Proc, e0}) -> a ! {IO, e0}
Run action against the host. Each command is checked here and then handed to the runtime in one call that returns only once the child is reaped.
run_proc(\() -> exec("true", []).status)
with_fake_proc
with_fake_proc : forall e0 a. ((Proc.Command) -> Proc.Outcome, () -> a ! {Proc.Proc, e0}) -> a ! {e0}
Run action with every command answered by answer and no child spawned. A pipeline is answered stage by stage, in order, each stage fed the output the one before it was answered with.
with_fake_proc(
\(c) -> Outcome { status = Exited(0), out = buf_of_string(c.program), err = buf_empty() },
\() -> text(exec("echo", []).out),
)
Some(echo)
fn shout(c : Command) : Outcome =
let heard =
match c.input of
Feed(b) => string_of_buf(b)
NoInput => ""
Outcome { status = Exited(0), out = buf_of_string("{heard}{c.program}"), err = buf_empty() }
fn main() : Unit ! {IO} =
let r = with_fake_proc(shout, \() -> collect_pipeline(pipeline([command("a", []), command("b", [])])))
println(string_of_buf(r.out))
ab