Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

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. A PATH entry 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 is OutputLimit. 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 is DeadlineExceeded.

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