The process module
sysl.process — starting another program and waiting for it: run, capture, Status, and why there is no shell anywhere in it.
sysl.process starts another program and waits for what it does. Two functions: run, which lets
the child share this program’s streams, and capture, which collects what it wrote.
import sysl.process.{run, capture}
import sysl.text.Search
// `true` and `false` are on every hosted system and do exactly what their names say.
print(run("true").unwrap())
print(run("false").unwrap())
val out = capture("echo", ["hello from a child"]).unwrap()
print(out.text.trim())
print(out.status.ok())
exited
exited 1
hello from a child
true
It requires os. A freestanding target has no processes to start, and nothing here can be given a
body on one.
A program that fails is not a failure
Err is for a child that could not be started. A program that ran and exited non-zero is
Ok, carrying a Status that says so — it did start, and its exit status is an answer rather than
a failure of the call.
import sysl.process.run
run("sysl-no-such-program") match
Ok(s) -> print("it ran, and", s)
Err(e) -> print("it did not start:", e)
run("false") match
Ok(s) -> print("it ran, and", s)
Err(e) -> print("it did not start:", e)
it did not start: no such file or directory
it ran, and exited 1
The error half is sysl.fs‘s IoError, so a missing program is NotFound and one
that is not executable is PermissionDenied — the same cases, from the same numbers.
Telling those two apart is not free, and most languages do not. A child that cannot exec has no
way to return, so the conventional answer is to exit 127 — which is indistinguishable from a
program that ran and chose to exit 127. This module’s child reports the failure through a
close-on-exec pipe instead, so NotFound means what it says.
Nothing goes through a shell
The arguments are a list rather than one string, and the list is handed to the program exactly as written. There is no quoting to get right because there is nothing to quote for.
import sysl.process.capture
import sysl.text.Search
// Under a shell this would be three words and a second command. It is one argument.
val out = capture("echo", ["one; two", "three four"]).unwrap()
print(out.text.trim())
one; two three four
A filename with a space in it is one argument, and one with a ; in it is not a second command.
How a child ended
Status has two cases, because they are not the same kind of answer: an exit status is something the
program chose, and a signal is something that happened to it.
import sysl.process.Status
print(Status.Exited(0))
print(Status.Exited(2))
print(Status.Signalled(9))
print(Status.Exited(0).ok())
print(Status.Exited(137) == Status.Signalled(9))
exited
exited 2
killed by signal 9
true
false
A shell folds the two together as 128 + n, which makes a program killed by SIGKILL
indistinguishable from one that deliberately exited 137. The last line is that distinction.
Where it starts, and what it can see
Both calls take a directory and a list of variables. The directory is where the child starts — this program does not move — and an empty one means wherever it already is.
import sysl.process.{capture, Var}
import sysl.text.Search
val out = capture("printenv", ["GREETING"], "", [Var("GREETING", "hello")]).unwrap()
print(out.text.trim())
hello
The variables are added to what this program has rather than replacing it, so the child keeps its
PATH and its HOME. They are set in the child, in the window between the fork and the exec, where
the process is single-threaded and this program’s own environment is untouched — which is why
sysl.env has no set and does not want one.
PATH is the one whose effect starts before the child does. Because the variables are in place
before the program is looked up, setting it decides where the program is looked for. A caller
handing a child a PATH meant for its own children should name the program by an absolute path.
Capture goes through a file, not a pipe
Deliberate, and worth knowing rather than hiding: a pipe has a buffer, and a parent that waits for a
child while the child waits for the parent to drain that buffer is a deadlock that only appears once
the output gets long enough. Nothing here can deadlock, and the file is removed before capture
returns.
Standard error is not captured. It goes wherever this program’s does, which is what a shell’s
$(...) leaves it doing — a tool asking a program a question wants the answer without a warning
mixed into the middle of it, and the warning is still worth seeing.
What is not here
Process management. There is no pid, no signal you can send, no process group, and no way to
hold a running child: every call starts one program and waits for it. That covers what a build tool,
an installer or a command-line front end does. A program that wants to supervise children wants a
different surface, and it would belong under sysl.posix, where a binding goes when it is POSIX
rather than merely implemented with it.
And that is why this module is sysl.process rather than sysl.posix.process. Starting a child
is the same idea on every hosted system — a program, its arguments, and how it ended — and only the
mechanism underneath differs. sysl.fs made the same call for the same reason.