sysl

The process module

sysl.process — starting another program and waiting for it: run, capture, start and Child, spawn and pipe for a pipeline, Status, how long a child may take, exec to become another program, why there is no shell or fork anywhere in it, and which user the program runs as.

Every declaration in sysl.process, with its signature: the generated API page. This page is the argument — what the module is for, and how its pieces fit; that one is the list.

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 — and a third, start, for when the waiting should come later — and spawn and pipe, which are what a pipeline is built from.

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 posix. On a hosted target the whole of the mechanism is posix_spawn, or fork and execvp — the PATH search below is execvp‘s own — and neither exists outside POSIX: a hosted target that is not POSIX has no way to start a program. WASI preview1 is the case that made that visible, having files and a clock and no way to spawn at all. A target with no C library can still start one, if the program says how — a kernel with its own process calls answers the module’s hooks itself, below. The module also says which user the program is.

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 keeps its cases apart, because they are not the same kind of answer: an exit status is something the program chose, a signal is something that happened to it, and TimedOut is this program deciding it had waited long enough.

import sysl.process.Status

print(Status.Exited(0))
print(Status.Exited(2))
print(Status.Signalled(9))
print(Status.TimedOut)

print(Status.Exited(0).ok())
print(Status.Exited(137) == Status.Signalled(9))
print(Status.TimedOut == Status.Signalled(9))
exited
exited 2
killed by signal 9
timed out
true
false
false

A shell folds the first two together as 128 + n, which makes a program killed by SIGKILL indistinguishable from one that deliberately exited 137. The fifth line is that distinction.

And the last line is the same argument once more. A child stopped for running past its deadline really was killed by a signal, so reporting Signalled would be true and useless: the caller set the bound and knows what stopped it, and what it wants to say is “it took too long” rather than “it crashed” — one of those is worth retrying and the other is not.

A child ended for a fault is Signalled, with the number POSIX raises for that fault — 11 (SIGSEGV) for an address it could not touch, 4 (SIGILL) for an instruction it could not run. That is what a POSIX host reports, and what a kernel answering the hooks itself is asked to report too, so Signalled(11) means the same thing on both.

A started child can be sent a signal of your own choosing: Child.kill(signal), 15 by default, which asks it to stop, and 9 to make it. Its wait then says the signal ended it:

import sysl.process.start

val c = start("sleep", ["5"]).unwrap()

c.kill(9).unwrap()
print(c.wait().unwrap().status)
killed by signal 9

A child already waited for is not signalled — the kernel forgot it at the wait, and its pid may by then be somebody else’s — and kill answers Ok without doing anything.

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. The child’s environment is built for it and handed over as it starts, 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.

inherit_env = false makes the list the child’s whole environment — nothing of this program’s comes with it, and an empty list is an empty environment. It is what an interpreter’s run wants, or a test that must not see the variables of whatever started it. run, capture and start all take it, as their last parameter:

import sysl.process.{capture, Var}
import sysl.text.Search

val out = capture("/usr/bin/env", env = [Var("GREETING", "hello")], inherit_env = false).unwrap()

print(out.text.trim())
GREETING=hello

A replaced environment with a PATH in it is searched as above. One without a PATH leaves the child none, and a bare program name is then looked for on the system’s default search path (confstr(_CS_PATH) — /usr/bin:/bin:/usr/sbin:/sbin on macOS, /bin:/usr/bin with glibc), which is where execvp looks in a process that has no PATH. It is never this program’s PATH: the child was asked to inherit nothing, and where its program is found is part of that.

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 left alone unless it is asked for. By default 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.

Sending a child’s output to a file

run takes stdout and stderr, each a Stdio: Inherit — the default, this program’s own stream — or a file. ToPath names one by its path and starts it empty, a shell’s >; ToFile hands over a file this program already has open, opened with create to start it afresh or append to add to it, and stays this program’s to close.

import sysl.fs.{append, read_text, remove_file}
import sysl.process.{Stdio, run}
import sysl.text.Search

val listing = "process-page-listing.txt"

run("echo", ["first"], stdout = Stdio.ToPath(listing)).unwrap()

// Both streams onto one open file, as a shell's `>> f 2>&1`.
var log = append(listing).unwrap()

log.write("between\n".bytes)
run("sh", ["-c", "echo second; echo third >&2"], stdout = Stdio.ToFile(log),
    stderr = Stdio.ToFile(log)).unwrap()
log.close().unwrap()

print(read_text(listing).unwrap().trim())
remove_file(listing)
first
between
second
third

What this program wrote to a ToFile file and had not yet handed over is handed over first, so the child’s output follows it rather than landing in the middle of it — between above was still in the file’s buffer when sh started. A path that cannot be opened is an error before anything is started, and nothing is run.

A child’s standard input

run, capture and start take a stdin too, the same Stdio read the other way: FromPath opens a file for the child to read, a shell’s <, and FromFile hands over a file this program has open — the child reading on from where this program stopped, what this program’s buffer had read ahead being given back to the file first.

import sysl.fs.{open, remove_file, write_text}
import sysl.io.Reader
import sysl.process.{Stdio, capture}
import sysl.text.Search

val names = "process-page-names.txt"

write_text(names, "pear\napple\nfig\n").unwrap()

// `sort < names`.
print(capture("sort", stdin = Stdio.FromPath(names)).unwrap().text.trim())

// The first line read here, the rest by `wc`.
var f = open(names).unwrap()
var first: [5]u8 = [0; 5]

f.read(first[..])
print(capture("wc", ["-l"], stdin = Stdio.FromFile(f)).unwrap().text.trim())
f.close().unwrap()
remove_file(names)
apple
fig
pear
2

The direction is part of each name, and a stream handed the wrong one is refused with Other(22) (EINVAL) before anything is opened or started: ToPath as an input would empty the very file the child was meant to read, and FromPath as an output would open one it cannot write.

Starting now, waiting later

start is capture with the wait taken out. It takes the same arguments meaning the same things, and answers a Child; the child’s wait answers exactly what capture would have — the same Output, the same TimedOut. capture is in fact written as start followed by wait, so the two cannot come to disagree.

The room in the middle is the point. Start several, then collect them in whatever order suits you, and they run at the same time:

import sysl.process.start

val slow = start("sh", ["-c", "sleep 0.2; echo slow"]).unwrap()
val fast = start("sh", ["-c", "echo fast; echo why >&2; exit 3"], stderr = true).unwrap()

val f = fast.wait().unwrap()
val s = slow.wait().unwrap()

print(f.text)
print(f.status)
print(f.err)
print(s.text)
fast

exited 3
Some(why
)
slow

A program that is not there fails at the start, not at the wait — there is no child to hand back:

import sysl.process.start

print(start("no-such-program-anywhere").is_err())
true

Several at once is where capturing through a file earns its keep. With pipes, a child that writes more than a pipe holds stops until somebody reads — and a parent waiting for a different child is not reading. Through files nothing fills: every child runs to its end on its own, and wait reads what it wrote afterwards. A megabyte on each stream from one child, collected after two others, is one of the module’s own tests.

timeout is measured from start, and kept by wait. A child that outstays it while somebody is waiting is stopped and reported as TimedOut; one that finished before anybody came to wait for it is reported as it finished — it did not outstay anything, its parent was busy.

Waiting twice answers the first answer again, since by then the kernel has forgotten the child.

A Child owns its child. It is a &Child, and when the last reference goes, a child that was never waited for is ended — asked to stop, made to after a short grace, the same two steps a timeout takes — and reaped, so no zombie is left in the process table; one that had already finished is only reaped. Ending it rather than letting it run on is not severity for its own sake: its output files are removed at the same moment, so it would be writing into files nobody can open. A program that wants a child’s work should wait for it.

Reading why a child failed

A child that exits non-zero has usually said why, on the stream capture lets through to the terminal — where a person may not be looking and a program cannot read it at all. stderr = true collects it into Output.err, and takes it off the terminal.

import sysl.process.capture
import sysl.text.Search

val quiet = capture("sh", ["-c", "echo why >&2; exit 3"]).unwrap()

print(quiet.status, quiet.err.is_none())

val loud = capture("sh", ["-c", "echo why >&2; exit 3"], "", [], true).unwrap()

print(loud.status, loud.err.unwrap().trim(), loud.text == "")
exited 3 true
exited 3 why true

err is an Option[string] and the two empty answers are different. None is “this call did not collect standard error”; Some("") is “it was collected and the child wrote nothing”. A bare string could say only the second, so a tool reporting a failure would have had to guess which it had.

import sysl.process.capture

val asked = capture("true", [], "", [], true).unwrap()

print(asked.err.is_some(), asked.err.unwrap() == "")
print(capture("true").unwrap().err.is_none())
true true
true

The second stream goes through a second file, on the same mechanism and for the same reason: two pipes is where the deadlock above gets easier to reach, and two files cannot deadlock at all.

A child that never ends

“Runs a program and waits for it” says nothing about a program that does not finish, and a caller with no bound has no move left: a child stuck on a socket nobody answers, or on a prompt nobody is there to type at, holds its parent for as long as the machine is up. Both calls take a timeout in milliseconds, last, and zero — the default — means no bound at all.

import sysl.process.{run, capture}
import sysl.text.Search

// A second of sleep, allowed a fifth of one.
val s = run("sleep", ["1"], "", [], 200).unwrap()

print(s, s.ok())

// What a child wrote before it was stopped still comes back.
val out = capture("sh", ["-c", "echo half; exec sleep 5"], "", [], false, 300).unwrap()

print(out.status, out.text.trim())
timed out false
timed out half

A child that outstays its timeout is asked to stop and then made to: SIGTERM first, so a program that tidies up on the way out gets to, then SIGKILL a fifth of a second later if it is still there. The grace is deliberately short — the child has already had the whole of its timeout, and one that ignores the first signal is not going to honour a longer wait for the second.

The signal goes to the child and to nothing else. Putting it in a process group of its own would take it out of the terminal’s foreground group, and then a person’s own interrupt would stop reaching it — so a child that forked children of its own leaves them behind. That is one more reason to run the program rather than a shell that runs it, which is what this module does anyway; the example above says exec for exactly that reason.

A timeout costs nothing to have and is worth setting wherever the child is something other than a program you wrote: run and capture with no bound are the right calls for a build you are watching, and the wrong ones for a tool that has hung on somebody’s machine once already.

A pipeline

a | b is two children started before either is waited for, with a pipe between them. pipe() makes one — the end its bytes are read from, then the end they are written into, both ordinary Files — and spawn starts a program with each of its three streams wherever its Stdio says, neither waiting nor collecting: it answers a Spawned, whose wait answers a Status and reads no file. Here is echo hi | tr a-z A-Z, with tr‘s output read back through a second pipe:

import sysl.io.read_all_text
import sysl.process.{Stdio, pipe, spawn}

val (r1, w1) = pipe().unwrap()
val (r2, w2) = pipe().unwrap()

val echo = spawn("echo", ["hi"], stdout = Stdio.ToFile(w1)).unwrap()
var a = w1

a.close().unwrap()

val tr = spawn("tr", ["a-z", "A-Z"], stdin = Stdio.FromFile(r1), stdout = Stdio.ToFile(w2)).unwrap()

for f in [r1, w2]
    var g = f

    g.close().unwrap()

var back = r2

print(read_all_text(&back).unwrap())
print(echo.wait().unwrap())
print(tr.wait().unwrap())
HI

exited
exited

Who closes what is the whole of making a pipeline end. A file handed to spawn as ToFile or FromFile stays open and stays this program’s, the child having a copy of its own by the time spawn returns; a file spawn opened for a path it closes itself. For a pipe, closing this program’s copy is not tidiness: a reader sees the end of its input only when no write end is open anywhere, so the write end is closed as soon as the stage writing into it has started — above, tr would otherwise wait for ever on an echo long finished, because this program could still write. A writer whose reader has gone is told so (SIGPIPE, which wait reports as Signalled(13)) only once every read end is closed, so the read end goes as soon as its stage has started too.

A pipe’s ends are never inherited by accident. Each is made to close when a program is started, and reaches a child only as the stream it was handed as — so tr above holds no copy of its own write end, which would be a writer it waits for and never hears from.

Like a Child, a Spawned dropped without being waited for is ended and reaped there and then, so a pipeline abandoned halfway leaves no stage running and no zombie; spawn takes dir, env, timeout and inherit_env meaning what they mean for run, the timeout kept by wait.

Who the program is

user() and group() answer the ids the program acts as, as u32s — the type sysl.fs‘s Meta.owner and Meta.group already have, so whether a file is the program’s own is one comparison:

import sysl.fs.{write_text, metadata, remove_file}
import sysl.process.{user, group, real_user, real_group}

val path = "/tmp/sysl-process-doc-id.txt"

write_text(path, "mine").unwrap()

print(metadata(path).unwrap().owner == user())
print(user() == real_user(), group() == real_group())

remove_file(path).unwrap()
true
true true

They are the effective ids — the ones a filesystem checks a permission against and makes a new file’s owner, which is what a program asking “who am I” is asking. real_user() and real_group() are who started it, and differ only in a program that changed who it acts as: one installed set-user-ID, or one that called set_user. A target that keeps one user per process answers the same number for both, and a target with no users at all answers 0 for every one — its filesystem leaves Meta.owner zero for the same reason, so Meta.owner == user() still says yes.

set_user(id) and set_group(id) are POSIX’s setuid and setgid, and privileged: a program acting as user 0 becomes id for good — real, effective and saved together, so the privilege is given up rather than set aside — and any other may only move between the ids it already has. Anything else is NotPermitted. A program giving up root calls set_group first: once it is no longer user 0 it may not change its group either.

import sysl.process.{set_group, set_user}

set_group(100)?
set_user(1000)?

Mapping a number to a name — /etc/passwd, /etc/group — is not here: that is a system database, and a program that reads one reads the system’s own.

Becoming another program

exec replaces this program with another, in the same process, and answers only if that failed: the process keeps its id, its parent, its open descriptors and its working directory, and runs the other program from its beginning. It is what a shell’s exec builtin is, and what a login program or a wrapper that sets something up and then becomes its target needs. program, env and inherit_env mean what they mean for run, the PATH search included.

import sysl.process.exec

print("about to become echo")

val e = exec("echo", ["now I am echo"])

print("still here:", e)
about to become echo
now I am echo

What this program printed is written out first. Its output buffer lives in the image exec throws away, so the line above would otherwise be lost whenever standard output is not a terminal — which is exactly when somebody is reading a log of it.

The answer is an IoError rather than a Result, a success having nothing to carry and no one to carry it to. Everything that can fail is checked before anything is given up, so on a failure this program goes on exactly as it was:

import sysl.process.exec

print(exec("sysl-no-such-program"))
print("and this program carries on")
no such file or directory
and this program carries on

It is not @abandons. That annotation is for a call after which the program goes on without the frames below it — a thread’s exit, a jump back to a saved context — so the counts those frames hold have to be given back first. exec is the other kind: on success nothing of this program goes on at all, its memory going with its image the way an exit‘s does, and on failure it returns like any call. Its result is IoError and not never for that reason.

Process groups and the terminal

A shell’s job is a process group, and a terminal gives its keys to one group at a time. Ctrl-C is a SIGINT to every process in the terminal’s foreground group and to nothing else, so a shell starts each job in a group of its own and hands that group the terminal. run, capture, start and spawn all take the two, last, so every call written before them keeps its meaning:

parameterwhat it says
group: Group = InheritInherit leaves the child in this program’s group; New makes it the leader of a group of its own, numbered by its pid; Join(pgid) puts it in an existing group
foreground: bool = falsethe child’s group is made the controlling terminal’s foreground before the child’s program runs

Spawned.group() and Child.group() answer the group a child was started in, which is what a later stage of a pipeline Joins — so the whole pipeline is one job — and process_group() answers this program’s own; pid() on either answers the child’s own process id, the number kill and a wait by pid name, which for a pipeline’s later stages differs from the group’s:

import sysl.process.{Group, capture, process_group, spawn}
import sysl.text.Search

val job = spawn("sleep", ["5"], group = Group.New).unwrap()
val stage = capture("sh", ["-c", "ps -o pgid= -p $$"], group = Group.Join(job.group())).unwrap()

print(job.group() != process_group())
print(stage.text.trim() == s"${job.group()}")

job.kill(9).unwrap()
true
true

“Before the child’s program runs” is the whole of foreground. A shell that started the job and then called tcsetpgrp itself would leave a window in which the job runs and the shell still has the terminal, and a Ctrl-C typed in it goes to the shell. So the terminal is handed over twice, as a shell does it: by the child, before it becomes the program, and by this program the moment the child exists — whichever runs first, the job has the terminal from its first instruction. When the job ends or stops, the shell takes the terminal back with set_foreground_group, which works from the background too (the stop signal a background process would earn for it, SIGTTOU, is held back for the call):

val job = spawn(program, args, group = Group.New, foreground = true)?
val how = job.wait()?

set_foreground_group(process_group())?

foreground_group() answers which group has the terminal now. The terminal is the program’s controlling terminal — the one its session belongs to, whatever its standard streams have been pointed at — so a program with none (started by a service manager, a test runner, a pipe from another machine) is refused where it asks: foreground_group(), set_foreground_group(…) and a spawn with foreground = true all answer Other(6), POSIX’s ENXIO, and the spawn starts nothing. Handing the terminal to the group this program is already in has no meaning, so foreground = true with Inherit is refused before anything starts, as is a Join of a number that is not above zero:

import sysl.process.{Group, run}

print(run("true", foreground = true).unwrap_err().code())
print(run("true", group = Group.Join(0)).unwrap_err().code())
22
22

A group that does not exist, or that belongs to another session, is NotPermitted.

A child started in a group is a job, and a job starts with the job-control signals at their defaults. A shell ignores SIGINT, SIGQUIT, SIGTSTP, SIGTTIN and SIGTTOU for itself, so that Ctrl-C and Ctrl-Z reach the job rather than the shell — and an ignored signal stays ignored across the start of a program. So with New or Join(…) the child begins its program with those five and SIGCHLD at their default dispositions and nothing blocked, as a job-control shell’s own children do. Inherit changes nothing: a child that is not a job keeps whatever this program gave it.

import sysl.process.{Group, Status, capture, run}
import sysl.signal.{INT, ignore}
import sysl.text.Search

ignore(INT).unwrap()

print(run("sh", ["-c", "kill -INT $$"], group = Group.New).unwrap() == Status.Signalled(INT))
print(capture("sh", ["-c", "kill -INT $$; echo survived"]).unwrap().text.trim())
true
survived

A background job is asked, not waited for. A shell prints its next prompt while cmd & runs, and before each one it asks every job whether it has ended: try_wait, on a Spawned and on a Child alike, answers None while the child runs and how it ended once it has. The call that sees the end reaps the child and remembers the answer, so a later wait or try_wait answers the same at once, and dropping the handle stops nothing — the pid is the kernel’s to hand out again by then. A timeout is still wait‘s to enforce; try_wait only asks.

import sysl.process.{Group, spawn}
import sysl.posix.time.sleep
import sysl.time.*

val job = spawn("sleep", ["0.2"], group = Group.New).unwrap()

print(job.try_wait().unwrap().is_none())

var ended = job.try_wait().unwrap()

while ended.is_none()
    sleep(10.ms)
    ended = job.try_wait().unwrap()

print(ended.unwrap())
print(job.wait().unwrap() == ended.unwrap())
true
exited
true

A job-control shell starts by putting itself in a group of its own, so that a Ctrl-C at its prompt reaches its group alone rather than whatever started it; set_process_group() does that — POSIX’s setpgid(0, 0), the program made the leader of a group numbered by its own pid — and set_foreground_group(process_group()) then takes the terminal. set_process_group(pgid) moves the program into an existing group of its session instead.

import sysl.process.{capture, process_group, set_process_group}
import sysl.text.Search

set_process_group().unwrap()

val me = capture("sh", ["-c", "ps -o ppid= -p $$"]).unwrap().text.trim()

print(me == s"${process_group()}")
true

A login program goes one step further for the shell it starts: new_session(), POSIX’s setsid, makes the program the leader of a new session and of a new group in it, with no controlling terminal — the next terminal it opens becomes that session’s — and answers the session’s id, which is its pid and the new group’s number. Only a program that does not already lead a group may: one that does, because a shell started it with Group.New or because it called set_process_group(), is NotPermitted and stays where it was.

import sysl.fs.IoError
import sysl.process.{new_session, set_process_group}

set_process_group().unwrap()

print(new_session().unwrap_err() == IoError.NotPermitted)
true

On a target with no C library

Everything the module asks of the machine goes through fourteen hooks in sysl.process.sys — start a program, make a pipe, wait for it or ask whether it has ended, send it a signal, become one in place of this program, name a file for a captured stream, read or change who the program is, say which process group it is in and which group has its terminal, and move it to a group or a session of its own. On a hosted POSIX target the library answers them; on a target with no C library the program answers each one it reaches with an @export, over whatever its kernel provides, and run, capture, start, spawn, pipe and exec work unchanged above them:

hookwhat it answers
sysl_proc_spawn(path, path_len, argv, argc, envp, envc, inherit_env, dir, dir_len, stdin_fd, stdout_fd, stderr_fd, started, pgid, foreground) -> i64the child’s process id, the child started in group pgid (-1 this program’s, 0 a new one, above zero that one) and, with foreground 1, that group given the terminal before the child’s program runs
sysl_proc_pipe(read_end, write_end) -> intzero, having written the two descriptors, each closing when a program is started
sysl_proc_wait(pid, started, timeout_ms, how, value) -> intzero, having written how it ended
sysl_proc_poll(pid, how, value) -> intzero while the child runs, without waiting; one once it has ended, having reaped it and written how
sysl_proc_kill(pid, signal) -> intzero
sysl_proc_temp_path(into, room) -> isizethe length of the path it wrote
sysl_proc_get_id(which) -> i64the id: which is id_user 0 or id_group 1 (effective), id_real_user 2 or id_real_group 3
sysl_proc_set_id(which, id: u32) -> intzero, having made the program act as user or group id (which 0 or 1)
sysl_proc_exec(path, path_len, argv, argc, envp, envc, inherit_env) -> intonly a failure: a success does not return
sysl_proc_getpgrp() -> i64the program’s process group
sysl_proc_tcgetpgrp() -> i64the controlling terminal’s foreground group
sysl_proc_tcsetpgrp(pgid) -> intzero, having given the terminal to pgid, from the background too
sysl_proc_setpgid(pgid) -> intzero, having moved the program into group pgid, or with 0 into a new group it leads
sysl_proc_setsid() -> i64the new session’s id, having made the program the leader of a new session and group

Each answers zero or more for success and the IoError code negated for a failure, and UNSUPPORTED (sysl.sys, -38 on every platform, not the host’s ENOSYS; -1 is EPERM) for a call the target cannot make at all — the contract of every *.sys module. Text crosses as a pointer and a length, never a C string: argv and envp point at runs of Text, each a pointer and a length, envp‘s entries reading NAME=VALUE. A descriptor of -1 leaves the child the stream this program has. wait writes 0 into how for an exit (its code in value), 1 for a signal or a fault (its number), and 2 for a child it stopped at its deadline; a supplier that cannot bound a wait answers UNSUPPORTED when handed a timeout. A target with no users answers get_id and set_id with UNSUPPORTED, and a refused set_id answers -1, EPERM, which comes back as NotPermitted. A target with no process groups answers the five group and session hooks with UNSUPPORTED (and process_group() is then 0); they are reached only by a program that asks about groups, sessions or the terminal, so a kernel without them answers nothing it does not have. poll is reached only by try_wait, and writes how and value as wait does, never 2; a supplier that cannot ask without blocking answers UNSUPPORTED. kill and wait are reached by any program that reaches the module, Child‘s destructor being kept wherever sysl.process is, so a program asking only who it is answers those two as well. sysl.process.sys‘s own comments say the rest.

A program that reaches a hook and leaves it unanswered is refused when it is compiled, naming every hook it reached:

error: this program reaches 'sysl.process', and 'aarch64-freestanding' has no operating system under
it for the standard library to answer a process or a user with, so the program answers it: define
'sysl_proc_kill', 'sysl_proc_spawn', 'sysl_proc_temp_path', 'sysl_proc_wait' with '@export', each
taking what its 'extern' in 'sysl.process.sys' declares and answering a process id, a user or group
id, a length, one or zero, or the code of an 'IoError' negated

A program answers only the hooks it reaches. One that only calls exec answers sysl_proc_exec and nothing else: Child‘s destructor, which waits for a child, stops one and removes the file its output was captured into, is kept only where the program makes a Child.

What is not here

fork. Rust’s standard library has none and Go’s offers only a fork that execs at once, for the same reason: a fork copies one thread of a program that may have several, mid-way through whatever the others held — an allocator’s lock, a half-written buffer — together with every count of every shared value, and a reference-counted runtime has no way to make that copy safe. Starting another program is run, capture or start; becoming one is exec; and the two together cover what a fork is wanted for on a hosted system. A kernel’s own runtime keeps its fork — keel’s user runtime has one beside its execve — because it owns every thread and every count there is.

Process supervision. A running child can be held — that is start — sent a signal, and started in a process group a shell can hand the terminal to, but no pid is handed out. That covers what a build tool, an installer, a command-line front end or a shell’s job control does, including one running several compilers at once. 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.

Search

Esc
↑↓ to navigate ↵ to open Esc to close