Processes: running a program, with an authority that says which
Status: slices 0 to 6 are built; slice 6 is letting go of a child (§4.11, #524) and signalling its process group (§4.12, #533). Nothing in this document is designed-only. Checked against
mainat the time of #536, bygrepofstd/process.cho,crates/cancho-ir/src/builtin.rsandcrates/cancho/tests/conformance/for each name:
Slice (§7) What Where it is Built 0 close-on-exec everywhere (§4.5) every backend; close_on_exec.rsyes 1 Exec,exec_spawn,child_wait,child_kill,pipe_open,pipe_read,pipe_write,pipe_nonblocking,pipe_close,child_end_close(§3.2)builtin.rs;processes.rsyes 2 poller_add_pipe,poller_add_child(§4.8)builtin.rs;processes.rsyes 3 std.process:argv/add/list/count/drop,channels,close,capture(§7.1)std/process.cho;capture.rsyes 4 the MCP server (cancho-tools#10) in cancho-tools (cancho-tools#17), on std.processyes, outside this repository 5 exec_spawn_in(§4.10),std.process.channels_with_errorsandcapture_both(§7.2)builtin.rs,std/process.cho;processes.rs,capture.rsyes 6 (edition 9) exec_spawn_detached,child_pid,child_releaseand the labelexec_detached(§4.11, #524)builtin.rs;process_release.rsyes 6 (edition 9) child_kill_group(§4.12, #533), andcaptureusing itbuiltin.rs,std/process.cho;process_group.rsyes What is still only designed or open is §9, and only that. Corrected (#536): this line used to say “slices 0 to 2 built; slices 3 and 4 designed, not built”, which was true when slice 2 merged and was never revised when
capture(#276), the MCP server (cancho-tools#17) and slice 5 (#292) were. A reader took it to meanstd.processdid not exist.Written before the code, the way
filesystem.md,net.mdandsignals.mdwere. When building it disagrees with this document, this document is corrected in place.Two askers (
CONTRIBUTING.md): #237 part (a), a test that starts a service, kills it and restarts it; and cancho-tools#10, an MCP server that runs each tool as its own process so each keeps its own authority.editions.md§6.4 has expected anExecsince edition 2.
1. Why
cancho has no way to start a process. A program that needs one declares
fork, execve and waitpid through Ffi("libc"), and
reach.md §5 is what that costs: Ffi("libc") is every
authority at once. The report says UNBOUNDED, and a supervisor holding
lex-os’s grant (filesystem, network, exec) cannot decide the third
dimension at all (under-a-grant.md §2: “exec: No”).
So the gap is the same one Net closed for the network: the authority
has no name of its own. This document gives it one, and most of the work
is saying exactly what that name does and does not bound (§2), because
running a program is unlike every capability cancho has so far.
1.1 What the two askers need
| #237 (a test harness) | cancho-tools#10 (an MCP server) | |
|---|---|---|
| start a built program with arguments | yes | yes |
| environment | yes, chosen by the test | none |
| feed standard input | sometimes | yes (write --stdin) |
| read standard output | sometimes | yes, bounded |
| kill it | yes, KILL and TERM, at a moment the test chooses | yes, on a timeout |
| its exit status | yes | yes |
| wait for it beside sockets and a deadline | yes (fake peers in the same program) | yes (a timeout) |
Neither asks for a shell, for PATH lookup, or for the child to share the
parent’s console. Both want the child gone or reaped on every path.
2. What executing a program is an authority over
Every capability so far bounds what this program does: Fs("/tmp") is
the files it touches, Net("api:443") the host it dials. Running a
program is different in kind:
Executing a program delegates. The child does whatever its own binary does, with its own authority, and the parent’s row cannot state that authority, because it is a property of a file the parent did not compile.
That leaves two honest things a capability can bound, and this design bounds both:
- Which programs may be started.
Exec("/opt/cancho-tools/bin")may start what lies under that directory and nothing else. That is a path prefix, withFs’s rules (§4.1), and it is the part a grant can check:exec("p")in a report is a static bound, asfs_read("p")is. - What the parent hands the child. A child must get no authority the
parent did not deliberately pass: no inherited descriptor (§4.5), no
inherited environment (§4.3), no inherited console (§4.4), and no
blocked signal (§4.6). Without this,
Execwould be a way to move authority out of the type system: releaseFsinmain, but leave an openDirfor a child to inherit.
What the design does not claim, stated here rather than discovered later:
- The child’s own authority is the child’s. A report that says
exec("/opt/cancho-tools/bin")composes with the reports of what lies there. When those are cancho programs, each carries its own (cancho authority, or, for cancho-tools,<tool> introspect), and the closure is computable. When they are not, it is not. The report says exec, so a supervisor knows the closure is a question. - The bound is on the name, not on the bytes. A script under the
prefix whose
#!line names/bin/shruns/bin/sh. A symbolic link under the prefix that points outside it is followed (§4.1). This isFs(prefix)’s position exactly, withfilesystem.md§2.1’s reasoning: containment against a hostile program is a sandbox’s job (lex-os), and this is containment against an honest program’s mistakes, plus a report that says what to put in the sandbox.
3. The design
Edition 7: purely additive (editions.md §5), so no
earlier file changes.
3.1 Types
| type | what it is | consumed by |
|---|---|---|
Exec("prefix") | a capability carrying a path prefix, like Fs; borrowed shared | release |
Child | a started process, not yet reaped: res, one leaf (the pid) | child_wait, or child_release (§4.11) |
Pipe | the parent’s end of a channel to a child: res, one descriptor | pipe_close |
ChildEnd | the child’s end of the same channel, before it is handed over: res | exec_spawn (as a Stdio), or child_end_close |
Stdio | enum { Null, Pipe(ChildEnd), File(File) }: what one of the child’s three streams is | exec_spawn |
Piped | enum { Ok(Pipe, ChildEnd), Failed(int) } | match |
Spawned | enum { Ok(Child), Failed(int) } (errno) | match |
Exited | enum { Code(int), Signaled(int), Failed(int) } | match |
Split (edition 7) | edition 6’s eight fields and exec: Exec("") | destructuring |
3.2 Builtins
| builtin | signature | row |
|---|---|---|
pipe_open | () -> Piped | none (an unnamed channel inside this process reaches nothing) |
exec_spawn | (&Exec("p"), path: &[byte], args: &[byte], env: &[byte], stdin: Stdio, stdout: Stdio, stderr: Stdio) -> Spawned | exec("p") |
exec_spawn_in | (&Exec("p"), &Dir, path: &[byte], args: &[byte], env: &[byte], stdin: Stdio, stdout: Stdio, stderr: Stdio) -> Spawned | exec("p"); exec_spawn whose child starts in the Dir (§4.10) |
exec_spawn_detached (edition 9) | as exec_spawn | exec("p"), exec_detached; the child leads a session of its own (§4.11) |
child_wait | (Child) -> Exited | none (ending a handle is not using one) |
child_release (edition 9) | (Child) -> int | exec_detached; the child runs on and is reaped by the runtime (§4.11) |
child_pid (edition 9) | (&Child) -> int | none |
child_kill | (&Child, signal: int) -> int | child_signal |
child_kill_group (edition 9) | (&Child, signal: int) -> int | child_signal; child_kill’s signals to the process group the child leads (§4.12) |
pipe_read | (&!Pipe, &![byte]) -> Received | pipe_read |
pipe_write | (&!Pipe, &[byte]) -> Sent | pipe_write |
pipe_nonblocking | (&!Pipe) -> int | none |
pipe_close | (Pipe) -> int | none |
child_end_close | (ChildEnd) -> int | none |
poller_add_pipe | (&!Poller, &Pipe, token, events) -> int | poll |
poller_add_child | (&!Poller, &Child, token) -> int | poll |
Received and Sent are the socket handles’ enums
(native-sockets.md §3.1): a pipe is a byte stream
to a peer, and its outcomes are a socket’s, Again included.
Discharge follows the existing pattern: owning an Exec("p") discharges
exec("p"); owning a Child discharges child_signal; owning a Pipe
discharges pipe_read and pipe_write; a World discharges exec("").
3.3 One example
The MCP server’s call, in full: start one tool with a fixed argument list, give it a request on standard input, read its answer, reap it.
edition 7;
// A sketch: the request is written and the answer read in the elided part.
fn call[&h, &x, &q](heap: &!h Heap, exec: &x Exec("/opt/cancho-tools/bin"), request: &q [byte])
-> [heap, exec("/opt/cancho-tools/bin"), pipe_read, pipe_write] (buffer.Buffer, int) {
var answer = buffer.empty(heap, 4096);
var status = 0 - 1;
match pipe_open() {
Piped::Failed(e) => { }
Piped::Ok(to_child, child_in) => {
match pipe_open() {
Piped::Failed(e) => { pipe_close(to_child); child_end_close(child_in); }
Piped::Ok(from_child, child_out) => {
// `\0`-separated (§4.2); a real caller builds this with `std.process.Argv` (§7.1).
let args = "--root\0/work\0--create\0--stdin\0notes.txt\0";
match exec_spawn(exec, "/opt/cancho-tools/bin/write", args, "",
Stdio::Pipe(child_in), Stdio::Pipe(child_out), Stdio::Null) {
Spawned::Failed(e) => { pipe_close(to_child); pipe_close(from_child); }
Spawned::Ok(child) => {
// write the request, close to signal EOF, read to the end, reap.
...
match child_wait(child) {
Exited::Code(n) => { status = n; }
Exited::Signaled(s) => { }
Exited::Failed(e) => { }
}
}
}
}
}
}
}
return (answer, status);
}
. Corrected (#275): std.process (§7, slice 3) wraps this into run(heap, exec, path, args, input, most, timeout), which is what the MCP server and a test actually
callrun cannot take an Exec narrowed
by its caller (§7.1), so the spawn stays here and std.process.capture
does the rest: the input written while the output is read, a bound, a
deadline, and the reap.
4. Semantics
4.1 The path, and the bound
pathis the program to run, absolute or relative to the working directory, never looked up inPATH.Absolute or relativeCorrected (#292, §4.10): a relative path is outside every bound but"": it does not begin with the prefix, so under a narrowedExecit traps like any path outside it (measured withExec("/bin"), pathtrue, run from/bin:SIGILL;every_refused_spawn_traps_on_both_backendshas the case). Relative paths are therefore for a program holdingExec(""), where the working directory is the parent’s or, withexec_spawn_in, aDir’s (§4.10). A program that has no environment (cancho programs have none) has noPATHto search, and a search would make the program that runs depend on something the row does not name.- It is checked against the capability’s prefix with
Fs’s rule (filesystem.md§1.1): a path prefix extends at a/or not at all, exactly the prefix is allowed (one program), and""contains everything. A path containing a..component is refused, asFsrefuses it (§4.1 there). Both are traps, forfilesystem.md§4’s reason: a path outside the bound is a program doing what its type said it would not, not an outcome to handle. - A missing program, one that is not executable, or one the kernel cannot
load is an outcome:
Spawned::Failed(errno), with the parent’sChildEnds andFiles closed by the builtin, so a failed spawn leaks nothing. argv[0]ispathas given.argsis the rest.
narrow(exec, "/opt/cancho-tools/bin") is the same narrow Fs has, at
compile time, against a literal.
4.2 Arguments are \0-separated bytes
args is one &[byte]: each argument followed by a \0. "" is no
arguments. This is C’s own encoding, so the builtin builds the argv
array from it without copying a byte, and it needs nothing the language
lacks: a slice of slices would need a region per element. An argument
cannot contain \0 in any encoding a process can receive, so the
separator excludes nothing. A malformed list (not empty, and not ending
in \0) is a trap, like an out-of-range index: it is a program bug, not
a kernel outcome. std.process.Argv builds one in a buffer (§7.1).
4.3 The environment is exactly what is passed
env is the same encoding, each entry NAME=value. The child’s
environment is exactly env: nothing is inherited, and "" is an
empty environment. A cancho program cannot read its own environment, so
it could not forward it knowingly. Inheriting it silently would hand the
child an authority (HOME, credentials in variables, LD_*) that no row
names.
One rule on what may be passed: an entry whose name starts with LD_
or DYLD_ traps. Those variables make the dynamic loader run code the
parent chose (LD_PRELOAD=/tmp/x.so), from a file outside the Exec
bound, so passing one would run a program the capability does not name.
That is §4.1’s broken promise by another route, so it is §4.1’s trap.
#237 item 4 (a preloaded shim) is therefore deliberately out of reach
through Exec; the issue already says it can stay a C file.
4.4 The three streams are what the parent gives
Each of the child’s stdin, stdout and stderr is one Stdio:
Stdio::Null:/dev/null, opened by the builtin.Stdio::Pipe(end): the child’s end of apipe_open. The builtin consumes it, so afterexec_spawnthe parent cannot still hold the child’s end. That is the classic pipe bug (the parent keeps a copy of the write end, so the reader never sees end-of-file and both wait forever), and here it is not a discipline:ChildEndisres, andexec_spawnis the only thing besideschild_end_closethat consumes one.Stdio::File(file): an openFile, for “write the child’s output to this file”. The parent needed the authority to open it, so passing it passes nothing new.
There is no Inherit. A child that inherited the parent’s descriptor
1 would write to the parent’s console, and the parent may have released
its Io: inheriting would be a way to print without io_write. Giving a
child the console is an open question (§9) because the shape that is
exact needs the parent to lend its Io, which a Stdio value cannot
carry.
pipe_open answers two ends of a socket pair (AF_UNIX,
SOCK_STREAM), not a pipe(2). The reason is SIGPIPE. A write to a
pipe(2) whose reader has gone raises SIGPIPE, which ends the parent,
and Linux has no per-write way to suppress it (MSG_NOSIGNAL is for
sockets only). A socket pair takes MSG_NOSIGNAL on Linux and
SO_NOSIGPIPE on macOS, the way Conn already does
(native-sockets.md §3), so pipe_write answers
Sent::Failed(EPIPE) like conn_write and never raises a signal. The
child sees an ordinary descriptor it reads and writes. Changing SIGPIPE’s
process-wide disposition instead was considered and refused for the same
reason native-sockets.md refused it: it is global state a library would
change behind the program’s back.
4.5 No descriptor crosses unless it is given
Today every descriptor the backends open is inherited across exec.
Measured, in a cancho program, by reading /proc/self/fdinfo: a File
(flags: 0100000), a Dir (0300000) and a Listener (02) all have
O_CLOEXEC (02000000) clear. Only the Poller and the signal
descriptor set it.
So slice 0 (§7) is a tightening in every edition: every descriptor
the backends open is opened close-on-exec (O_CLOEXEC, SOCK_CLOEXEC,
F_DUPFD_CLOEXEC, and fcntl where a platform has no flag). After it,
the child’s descriptors are exactly 0, 1 and 2, as Stdio says. It is a
tightening rather than an edition matter (editions.md
§5): the only program it changes is one that passed a descriptor to a
child through Ffi, which is the leak this closes. file-writes.md’s
“No O_CLOEXEC” note is corrected by the same change.
Built (slice 0). What each builtin’s descriptor now comes from:
builtins Linux Darwin fs_read,fs_write,open_read,open_diropenat(AT_FDCWD, path, ... | O_CLOEXEC)the same dir_enter,dir_open_*,dir_listopenat(dir, name, ... | O_CLOEXEC)the same open_write,open_append,open_new,open_rwfcntl(F_DUPFD_CLOEXEC)offopen’s descriptorthe same tcp_listen,tcp_connect,bind,connectsocket(..., SOCK_STREAM | SOCK_CLOEXEC, ...)socket, thenfcntl(F_SETFD, FD_CLOEXEC)tcp_accept,acceptaccept4(..., SOCK_CLOEXEC)accept, thenfcntlpoller_new,signals_watchepoll_create1(EPOLL_CLOEXEC),signalfd(SFD_CLOEXEC), as beforekqueue, thenfcntl
creat,openanddupare no longer called. The flag values live once, incancho_ir::open_flagsandSocketOs.Two windows remain, and both are stated rather than closed. Where Darwin has no flag,
fcntlfollows the call that made the descriptor, so anexecon another thread between the two would still inherit it; andfopen’s own descriptor is not close-on-exec for the instant before itsfclose, on both targets. Neither is reachable from a cancho program until slice 1 (only a foreignexeccan start a process, and a cancho program has one thread perspawnit wrote), and slice 1 closes the first one for spawns it makes itself:posix_spawnon Darwin can be asked to close every descriptor but the three it is given (POSIX_SPAWN_CLOEXEC_DEFAULT).Built (slice 1), and stronger than this section first said. A child started with
exec_spawnholds exactly its three streams on both kernels, whatever the parent holds: macOS withPOSIX_SPAWN_CLOEXEC_DEFAULT, Linux withposix_spawn_file_actions_addclosefrom_np(3)(glibc 2.34 and later) after the threedup2s. Close-on-exec alone could not promise that: it covers what a cancho program opens, and a descriptor the program inherited without the flag (a CI runner hands every process several, measured in slice 0’s test) would have passed straight through to a child. So both windows above are closed for every childexec_spawnstarts; they remain only for a foreignexec.Checked by
close_on_exec.rs: a program runssystem("ls /dev/fd")throughFfionce before opening anything and once holding one of everything a builtin opens (fourteen descriptors), and the two listings must be equal. Before the change the second had twelve more, on both backends. Equal rather than0 1 2, because macOS CI found the first version of the test wrong: the runner hands every process descriptors of its own (131 and up), and those pass through a program untouched.fs_readandfs_writeclose their descriptor before returning, so no child can be shown one, and the test cannot tell those two from before.Mutants: each site reverted alone, in each backend, 14 in all; 12 are killed. The two that survive are
dir_list’sO_CLOEXEC, and they are equivalent on Linux: glibc’sfdopendirsetsFD_CLOEXECon the descriptor it is given (measured:F_GETFDanswers 0 before it and 1 after). The flag stays, because it closes the moment betweenopenatandfdopendirand does not rest on what one libc does.
4.6 Signals start at their defaults
A program that claimed TERM holds it blocked on Linux and ignored on
macOS (signals.md §5), and exec keeps both a blocked
mask and an ignored disposition, so a child would be unkillable by
TERM. The spawn therefore sets the child’s signal mask to
empty and every catchable signal’s disposition to the default
(POSIX_SPAWN_SETSIGMASK, POSIX_SPAWN_SETSIGDEF). The child starts the
way a shell would start it.
4.7 A Child is reaped on every path, and a kill cannot miss
Child is res, and child_wait is the only thing that consumes it. So
a program that starts a process and returns without waiting for it does
not compile: the zombie process is a linearity error, not a leak to find
later.
The same fact makes child_kill safe. A process that has exited but not
been reaped keeps its pid, so a pid cannot be reused while its Child
exists. kill(pid, s) on a live Child can only reach that child, on
both kernels, with no pidfd needed for correctness.
child_kill(child, signal) takes one of std.signals’ fixed bits
(signals.md §2.2) and adds one only sending can use:
KILL (bit 256). STOP and the fault signals are refused
(Failed(EINVAL)): stopping a child leaves it in a state child_wait
would wait on forever. The answer is 0 or the errno; signalling a
child that has already exited answers 0.
child_wait blocks until the child ends and answers Exited::Code(n)
(it called exit(n)), Exited::Signaled(bit) (a signal ended it,
reported in std.signals’ bits, 0 for one with no bit), or
Exited::Failed(errno). It waits for its pid only, never -1, so a
thread waiting for one child cannot reap another thread’s.
Letting go (§4.11). child_release is the third way to consume a
Child: the process keeps running and the runtime reaps it, so the sentence
above holds for it too.
What linearity cannot do. If the parent traps or is killed, its
children are not killed with it. Linux could ask for that
(PR_SET_PDEATHSIG), macOS cannot, and posix_spawn cannot ask on
either. The guarantee is “reaped on every path the program completes”, and
it is stated that way.
4.8 Waiting beside everything else
poller_add_child registers a child’s exit as readable, as
poller_add_signals does a signal: a pidfd on Linux (pidfd_open,
5.3 and later), EVFILT_PROC/NOTE_EXIT on macOS. poller_add_pipe
registers a Pipe like a Conn. Together they give both askers the loop
they need: a child, its output, a deadline (poller_wait’s timeout and
the Clock), and, for #237, fake peers on Conns in the same loop.
After poller_wait reports the child, child_wait answers without
blocking.
Built (slice 2), and what building it settled.
- The
pidfdlives in theChild.epollwatches a descriptor and a pid is not one, soexec_spawnopens thepidfditself, straight after the spawn and before anything can reap the child (an unreaped pid is still its own, §4.7). AChildis still one word: the pid in the low 32 bits and thepidfdin the high 32, andchild_waitcloses it as it reaps. The alternative, opening it whenpoller_add_childis called, leaves a descriptor with no owner: thePollercannot know which to close, and theChildis consumed bychild_waitbefore thePolleris. Apidfdper live child is the price; it is one descriptor, and the same onechild_waitgives back (reaping_a_child_gives_back_its_pidfd). pidfd_opengoes throughsyscall.glibchas a wrapper only from 2.36, and the number, 434, is the same on both Linux architectures.- A refusal is the
errno, carried. Ifpidfd_openis refused (a kernel before 5.3, or a policy that forbids the call), the high half holds theerrnonegated andpoller_add_childanswers it:ENOSYS, notEBADFfor a descriptor the program never held.Corrected (#275):EMFILEis not among the reachable ones: the descriptor a child’s end held is closed before thepidfdis asked for, so one is always free.EMFILEis reachable. A spawn frees a descriptor only when it hands one over (aPipeend, aFile); aNullstream is opened in the child, so a spawn whose three streams areNull, with every descriptor in use, leaves none for thepidfd. Measured under a limit of 8 with 4 to 7 held:24; under 9:0(a_child_with_no_descriptor_to_spare_says_emfile). The child is still started and reaped; only watching it is refused. A test installs aseccompfilter answeringpidfd_openwithENOSYSto reach the other path. - A channel is watched as a
Connis.poller_add_pipeispoller_add_connfor aPipe: a channel is a socket pair, so there is nothing more to it. There is nopoller_removefor either: closing the descriptor (pipe_close,child_wait) takes it out of the set. - macOS does not take a zombie.
keventwithEVFILT_PROC/NOTE_EXITon a child that has already exited and not been reaped answersESRCH(3), where apidfdis simply readable. Measured on the first macOS CI run of slice 2, and in C on macOS 26.2 (scripts/kqueue_zombie.c): 200 children of 200, each confirmed a zombie withwaitid(WNOWAIT)first. AChildnobody has reaped still owns its pid (§4.7), soESRCHcan only mean it has exited, andpoller_add_childsays so the waykqueuesays anything is ready for the asking: anEVFILT_USERevent,EV_ADD | EV_ONESHOTwithNOTE_TRIGGER, in one change, carrying the token.poller_waitreads it as readable, as it does theEVFILT_PROCone. Corrected (#275): the first fix sent two changes, an add carrying the token and then a trigger, and the second change’sudata(0) replaced the first’s: the event was reported with token0(the second macOS CI run; reproduced in C). A later change to a knote rewrites itsudata, so the add and the trigger are one change. - Darwin reports an exit once.
EVFILT_PROCwithNOTE_EXITis registeredEV_ONESHOT, andpoller_waitreads it as readable. Thepidfdof Linux stays readable until the child is reaped (level-triggered, as the rest of the poller is); on macOS the report comes once, which is all a program that callschild_waiton it needs.
4.9 Threads
posix_spawn is safe from any thread, and nothing here is process-wide
state except what the kernel already makes so. A child started by one
thread can be waited for by another, since the Child moves.
4.10 The working directory
A child started with exec_spawn starts in its parent’s working directory.
A tool that is told to work “in this project” would otherwise have to be
handed an absolute path for everything or to cd itself, and the program
that runs it (cancho-tools’ future run) has already opened that directory
as a Dir. So there is a second builtin,
exec_spawn_in(&Exec("p"), &Dir, path, args, env, stdin, stdout, stderr) -> Spawned
which is exec_spawn with the child’s working directory the directory the
Dir holds.
- A second builtin, not an eighth parameter. Edition 7’s
exec_spawnhas callers (the MCP server’s among them), and a parameter added to it would stop them compiling. The two lower to one IR node (Expr::ExecSpawn, with anin_dirflag) and one function per backend, so nothing about the spawn is written twice. - The
Diris lent. It is&Dir: shared, not consumed, so the program keeps it and can open beneath it, list it anddir_closeit after the spawn.exec_spawnconsumes aChildEndbecause the child’s end must not stay open in the parent (§4.4); a directory descriptor held open by the parent harms nothing, and the child never holds it (below). ADirby value, or aFile, is a type error (tests/reject/exec_spawn_in_owned_dir.cho). - No new row. The row is
exec("p")and no more. TheDircame from anFsthe program already held, and §2’s point stands: starting a program is an authority over what that program can do, whatever directory it starts in. A program holdingExec("")and aDircould already start a child with a path to everything under it. - Underneath.
posix_spawn_file_actions_addfchdir_np(glibc 2.29, macOS 10.15): the descriptor is named to the kernel and the child changes directory before it runs the program. Nothing changes in the parent, so it is safe from any thread (§4.9), as achdirbefore the spawn would not be. The file actions run in the order they are added, so it is added after the three streams and before theclosefromof §4.5: theDir’s descriptor is above 2, andclosefromwould close it.
Measured (scripts/process_cwd_stderr_measure.py, ctypes into libc with
the flags exec_spawn sets; macOS 26.2 and Linux 7.0 with glibc 2.43):
| macOS | Linux | |
|---|---|---|
addfchdir_np is there | yes | yes |
the child’s pwd is the Dir’s directory; without a Dir, the parent’s | both | both |
| the parent’s working directory afterwards | unchanged | unchanged |
a relative program path (./tool) resolves against the Dir; without one, ENOENT (2) | both | both |
descriptors the child lists (ls /dev/fd, /proc/self/fd), with the Dir and without | equal | equal |
the chdir added after addclosefrom_np | (no closefrom; under CLOEXEC_DEFAULT the chdir last works) | EBADF (9): the spawn fails |
| a descriptor of a regular file | ENOTDIR (20) | ENOTDIR (20) |
| a descriptor that is not open | EBADF (9) | EBADF (9) |
| a directory opened for reading that has no search permission | EACCES (13) | EACCES (13) |
a Dir whose directory has since been removed | the spawn succeeds, the child runs in a directory that is gone (pwd exits 1) | the same |
The first three failures cannot come from a Dir the type system gave
(ENOTDIR and EBADF need a descriptor that is not a directory); EACCES
can, and it is an outcome: Spawned::Failed(13), with the parent’s
ChildEnds and Files closed as for any failed spawn (§4.1). The one case
that is not an outcome is the allocator refusing the file action
(ENOMEM), which traps as a failed malloc for the argument list does; a
dropped chdir would start the child in the wrong place, and nothing the
program can arrange reaches that path, so it has no mutant (§8).
4.11 Letting go: child_release, and a child in a session of its own
#524. A server that starts
a job (cancho-code’s MCP server starts a build that takes minutes, answers
with a run id at once, and lets a client poll) cannot be written with §4.7
alone. A Child has to be waited for, which blocks, or killed, which stops
the job, and a program that does neither does not compile. The server worked
around it with four constant sh -c scripts, ps, and a heuristic for
whether a pid is still the run’s. Three builtins, edition 9, replace that:
exec_spawn_detached(&Exec("p"), path, args, env, stdin, stdout, stderr) -> Spawned // [exec("p"), exec_detached]
child_pid(&Child) -> int // []
child_release(Child) -> int // [exec_detached]
fn start[&x](exec: &x Exec("/opt/builds/bin")) -> [exec("/opt/builds/bin"), exec_detached] int {
match exec_spawn_detached(exec, "/opt/builds/bin/build", "--all\0", "", Stdio::Null, Stdio::Null, Stdio::Null) {
Spawned::Ok(child) => {
var pid = 0;
borrow child as &c in { pid = child_pid(c); }
child_release(child); // the job runs on; nothing is owed
return pid; // the run id the client polls with
}
Spawned::Failed(e) => { return 0 - e; }
}
}
- What each does.
exec_spawn_detachedisexec_spawnwhose child leads a new session and process group of its own.child_pidreads the pid out of theChild, which is read-only and must come before the release because the release ends the handle.child_releaseconsumes theChild: the job keeps running, the parent holds no handle and owes no wait. It answers0. - Two builtins and a reader, not a changed signature.
exec_spawnhas callers, as §4.10 found, so the detached start is a second builtin that lowers to the same IR node (Expr::ExecSpawn, with adetachedflag) and the same function in each backend. A detached start with a working directory (exec_spawn_detached_in) is not built: no program has asked. - Release works on any child. A child from plain
exec_spawncan be released too, and runs on in its parent’s session. The two are independent: detached is about where the child lives, released is about whether the parent owes it a wait. A server wants both; a test that starts a service and lets the harness’s end clean up wants release alone.
A released child is not the parent’s zombie. The weak point of “just let go” is the process table: a child that ends is a zombie until its parent reaps it, and a server that releases thousands of jobs over its life would fill the table. Three mechanisms were considered:
| Mechanism | Why not |
|---|---|
Ignore SIGCHLD (or SA_NOCLDWAIT), so the kernel reaps | It is a per-process setting. With it set, waitpid for a child the program did keep fails with ECHILD, so child_wait stops working for every other child (measured, Python’s os.waitpid after SIGCHLD = SIG_IGN: No child processes, on macOS 26.2 and Linux 7.0) |
| Start the child through a double fork, so init adopts it at once | The started process is then not the parent’s child: child_wait and poller_add_child cannot work on it, and §4.7’s guarantee that a pid is not reused while its Child exists is gone. A fork in a program with threads (§4.9) may call only async-signal-safe functions before the exec |
| A thread of the runtime reaps it | Chosen. child_release closes the pidfd (§4.8) and starts a detached thread whose only work is waitpid on that pid, then ends. It never waits for -1, so it cannot take the status of a child the program kept, and nothing about child_wait changes |
Its costs, measured on macOS 26.2 (Cranelift): 200 released children still
running are 200 threads and 3.3 MB more resident memory than none (1.3 MB to
4.6 MB, about 16 KB each); 50 that ended leave no thread and no process
(ps finds nothing of any of them while the parent lives, on both backends).
The thread has a 64 KiB stack, since it makes one call. If pthread_create
fails (EAGAIN), child_release answers its error number instead of 0: the
child still runs, but it is a zombie when it ends until the parent ends. When
the parent ends the threads go with it and the kernel hands the children to
init. The thread inherits the parent’s signal mask, so a claimed signal
(signals.md) stays blocked in it too, and it installs no handler, so
nothing interrupts its waitpid.
The session. exec_spawn_detached adds POSIX_SPAWN_SETSID to the flags
of §4.6 (0x80 in glibc, 0x400 in macOS’s spawn.h, both read from the
header). The child is its own session leader and group leader (pgid and, on
Linux, sid equal to its pid) and has no controlling terminal. That is what
the use needs: a hangup of the parent’s terminal, or a kill -- -pgid of the
parent’s group by a shell or a supervisor, does not reach it. The tests start
two released children and kill the parent’s group: the plain one dies with it
and the detached one lives. What it does not do: a container’s or a service
manager’s own kill (a cgroup, a launchd job) takes every process it holds,
session or not; and nothing here keeps a plain child alive when the parent’s
group is killed, which is the point of the second builtin.
What the caller can do with the pid. child_pid answers a number, and
nothing in cancho signals a number: child_kill takes a &Child, and the
Signals capability is about the signals a program claims for itself. That
is deliberate and stays so. A kill(pid, s) taking any pid would give a
program holding only child_signal a way to signal every process of its user,
which is far more than the label says. The same effect is available today
under the authority it really is: start /bin/kill through an Exec narrowed
to it (exec("/bin/kill") in the report). Because the child leads its own
group, kill -TERM -- -<pid> stops the job and everything it started, and
kill -0 <pid> asks whether it is there. What a server must remember: a pid
is only a name. After the process is gone and reaped, the number can belong
to another one, and §4.7’s guarantee ended with the handle. A server that
polls by pid should record the pid with something that tells runs apart (a
start time, a run directory the job writes into) and not trust the number
alone. A kill-by-pid builtin, with a capability of its own, is §9’s open
question, not part of this slice.
The authority report. A new plain label, exec_detached, performed by
exec_spawn_detached and by child_release, never by child_pid. It is
path-free: which programs may be started is still exec("p"), and
narrow bounds the detached start exactly as any other (the checked path is
the same code, and a test starts one outside the prefix and sees the trap).
So a report that says
performs
exec("/opt/builds/bin")
exec_detached
tells a reader: this program starts only what is under that directory, and
what it starts may be left running when the program ends. The label sits on
the release as well as on the detached start because the release is the act
after which any child outlives the program; a program that starts a child
in its own session and waits for it is reported as well, which is the
conservative side. Owning an Exec discharges it, as it does child_signal
(§3.2), and so does a World. It is edition 9 like the three builtins:
nothing an older file can write reaches it (editions.md §5,
an addition is absent from older editions), and a file that already declares
a function called child_release is unchanged. cancho authority’s
“never touches” list counts it with exec: a program with neither still
reports “other programs” as untouched.
WebAssembly has no processes, so the three are refused there like the other
process builtins (unsupported-on-target, tests/reject/exec_spawn_detached_on_wasi.cho).
4.12 Stopping what a worker started: child_kill_group
#533, found by the cancho-forge spike
(spike/s7_grandchild.cho, spike/RESULTS.md S7). A supervisor runs a worker that itself starts subprocesses (a
compiler, a test runner) and stops it at a deadline. capture’s kill reaches the worker’s pid only: measured, with
/bin/sh -c 'sleep 77 & wait' and a 300 ms deadline, the sleep is still in state S when the supervisor has
ended. §4.11 named the way out, /bin/kill -KILL -- -<pid> through a narrowed Exec, and it costs what the issue
says: cancho authority reports exec("/bin"), which is wider than “one worker directory” and undoes the point of
a narrow bound, and the pid is a bare number.
child_kill_group(&Child, signal) -> int // [child_signal]
child_kill_group(c, signal) sends signal (the same set as child_kill, §4.7: std.signals’ bits and KILL,
256; STOP and the faults are EINVAL) to the process group the child leads: kill(-pid, native). 0, or the
errno. It is edition 9.
The shape chosen, and what it was chosen over.
| Alternative | Why not |
|---|---|
A flag in child_kill’s signal argument | child_kill is edition 7, and edition 7 is closed (§6.4 of editions.md): a value that answered EINVAL yesterday would send a signal to a group today, in a file that did not change. The argument is also a signal bit; a second meaning in the same integer is the mistake Stdio::Merge was kept out for (§5) |
A Signals bit, or a new capability | Signals is about the signals a program claims for itself (§4.11). The program already holds what this needs: a Child it started. A capability for “a group” would have to be handed around beside the Child and could be pointed at any group |
kill(pid, s) on a number (pid_kill), or a “pid with start time” handle | §4.11 and §9: it needs a capability of its own, since it would reach every process of the user. The issue asks for the signal “held by whoever holds its Child”; this is that, and nothing wider |
A distinct type for a detached child (DetachedChild), so a plain child cannot be asked | Every function that takes a Child (child_wait, poller_add_child, capture, child_release) would have to take both, or Spawned would split in two; an edition, for a question the kernel already answers (below) |
| A second builtin, same row, same signals | Chosen. The precedent is §4.11’s own: exec_spawn has callers, so the detached start is a second builtin over the same code. Here child_kill_group lowers to the same function in each backend as child_kill, with the target negated |
Only a detached child can be signalled this way, and that holds by construction, not by a check. kill(-P, s)
addresses the group whose id is P, and a group’s id is the pid of its leader. The child’s pid is P. A child
started by exec_spawn_detached leads a group of its own, so the group P exists and is its. A child started by
exec_spawn is in its parent’s group, whose id is another process’s pid; no group is named P unless the child
makes one for itself (a shell with job control does, and then the group holds only that child’s own descendants).
Linux does not hand the number P to a second process while P is in use as a pid or a group id, so there is
no group named P that is anyone else’s (macOS’s allocator is not read here; its CI run is what checks it). The answer for a plain child is therefore ESRCH (the constant is 3 on both kernels; measured on Linux), nothing
is sent, and the parent is not signalled: kill(-P) can never address the parent’s group, since that group’s id
is not P. Measured, both backends on Linux: a plain child answers 3 and the program goes on to kill the child and
print what it did (tests/programs/process_group.cho, mode r). Refusing it at compile time would need the type
split of the last row. A guard remains for what no child can have: a pid of 1 or less would make the call kill(-1)
(every process the user may signal) or kill(0) (the caller’s own group), so those answer EINVAL with no call.
No program can reach that guard (a child’s pid is never 1), and it has no test and no mutant, for that reason.
The authority report. No new label. child_kill_group performs child_signal, the label of child_kill, and a
program that owns a Child discharges it (§3.2). It does not need exec("/bin"), or any Exec at all, beyond
the one that started the worker: a function fn stop[&c](c: &c Child) -> [child_signal] int is the whole of it.
tests/accept/process_group_kill.cho starts /bin/sleep in its own group, stops the group and waits, and its report is
performs
child_signal
exec("/bin/sleep")
exec_detached
io_write
and the supervisor’s bound can be the worker’s directory. Why the same label is honest: the group a detached child
leads is in a session of its own (§4.11), and a process can join a group only from the same session, so the group
holds the child’s descendants and nothing else. A signal to it is a signal on processes the program started, which
is what child_signal says. What the label does not promise is that every descendant is in the group: one that
calls setsid or setpgid leaves it (a build tool that does is not stopped), and a group is not a cgroup. The
guarantee is the one the issue asked for, the worker’s ordinary subprocesses.
The pid-reuse hazard. The call takes a &Child, not a number. While the Child exists the leader’s pid is
held, zombie or not (§4.7), so -P cannot be another process’s group: the hazard of §4.11’s /bin/kill is
gone for this path. A group outlives its reaped leader while it has members (measured, S7: Z after the kill), and
the kernel does not reuse its number as a pid meanwhile. After the group is empty and the Child has been waited
for there is nothing to signal and no handle to signal with. The test a_leader_that_has_ended_does_not_hide_its_group
signals a group whose leader has ended and not been waited for, and reaches the grandchild.
capture uses it. A supervisor holds the Child only inside capture, which consumes it, so signalling “after
capture” is signalling a number again. capture therefore sends the group KILL before the pid’s, on the paths on
which it kills (the deadline, the output bound, a failed watch). For a plain child the first is ESRCH and nothing
more happens, so the behaviour of a plain worker is what it was. What it does not do: a worker that exits by itself
and leaves a subprocess behind is not killed (the exit ends the capture, capture’s comment says so), and the left
behind process runs on. A caller that wants it stopped holds the Child itself and calls child_kill_group before
child_wait.
Both backends, and the compiler in cancho. Cranelift and LLVM emit the call as child_kill’s with the pid negated
and the guard above (child_kill(args, group) in each). The compiler in cancho (examples/selfhost) has it as
builtin 138, cancho_child_kill_group in runtime.ll; tables.cho, builtins.cho and signatures.cho are
regenerated, so it is not in unported_builtins.txt. WebAssembly refuses it as it does the other process builtins
(tests/reject/child_kill_group_on_wasi.cho). No new rule: child_kill_group_is_edition_nine is not-a-function
(an older edition does not have the name) and child_kill_group_undeclared is effect-not-declared.
What this does not close is in §9: after a supervisor crashes there is no Child to hold, and the group id in
its journal is a number again.
5. Alternatives considered
| Alternative | Why not |
|---|---|
fork and exec as builtins | fork copies the parent’s heap and every thread’s locks into a child that runs one thread, and everything between fork and exec must be async-signal-safe. A language whose backend calls malloc cannot promise that. posix_spawn is the operation both askers mean, and it does the descriptor and signal setup (§4.4 to §4.6) in the kernel’s own order |
Keep it Ffi("libc") | The status quo. reach.md §5 and under-a-grant.md §4 are the argument: Ffi is the one capability whose label does not bound what it authorises |
One run-to-completion builtin (exec_run(..., input, output) -> code) | Serves the MCP server and not #237, which must kill a child at a moment it chooses and serve sockets meanwhile. It is the right library function, and std.process.capture is it (slice 3, §7.1: the spawn stays with the caller), built on the handles |
A list of programs as the bound (Exec("seek,write")) | Signals uses a set because signals are a closed list. Programs are files, and the natural unit a deployment grants is a directory (/opt/cancho-tools/bin). A prefix that is exactly one file already expresses “this one program” |
The child’s ends as File | A File is a regular file: no Again, no readiness, and a write to a closed pipe raises SIGPIPE. A child’s stream is a peer, and cancho already has a handle for a peer’s byte stream. Stdio::File keeps redirecting to a real file |
The parent’s ends as Conn | The same machinery, but conn_read in a row would read as network. A distinct Pipe keeps the row honest about what the program talks to, at the cost of four small builtins over one backend path |
PATH lookup (execvp) | §4.1: the program that runs would depend on an environment the row does not name |
| Inherit the environment | §4.3 |
Stdio::Inherit | §4.4: a child would print for a parent that released Io |
Stdio::Merge (2>&1) for standard error | A fourth variant of Stdio stops every edition-7 program that matches Stdio exhaustively (the process probe does) from compiling, so it is an edition, not an addition. It also gives the caller one stream where a tool that reports failures on standard error wants two. Measured (scripts/process_cwd_stderr_measure.py): with both on one socket the order the child wrote is the order read, 50 of 50 runs of 500 alternating lines on each kernel, so it would be exact; that is what is given up, and §7.2 reads a third channel instead. Open (§9) |
Spawn beneath a Dir (execveat) | Would close §2’s symbolic-link gap the way directory-handles.md did for files. Linux has execveat and macOS has nothing equivalent, and posix_spawn takes only a path. Open (§9) |
6. Underneath
Both targets have posix_spawn, posix_spawn_file_actions_* and
posix_spawnattr_*, all non-variadic, so neither backend calls a
variadic C function for the spawn (filesystem.md
§2.2). The posix_spawn_file_actions_t and posix_spawnattr_t objects
are backend-internal stack buffers, sized per target the way stat_layout
sizes struct stat (directory-listing.md), and
initialised and destroyed by the libc functions. The argv and envp
pointer arrays are built in a stack or heap buffer from the \0-separated
slices and never reach the program, the way getaddrinfo’s result never
does (net.md §4.1).
| step | Linux | macOS |
|---|---|---|
| a channel | socketpair(AF_UNIX, SOCK_STREAM | SOCK_CLOEXEC) | socketpair then fcntl(FD_CLOEXEC) and SO_NOSIGPIPE |
| the child’s 0, 1, 2 | posix_spawn_file_actions_adddup2, _addopen("/dev/null") | the same |
| the child’s working directory (§4.10) | posix_spawn_file_actions_addfchdir_np (glibc 2.29), before the closefrom | the same (macOS 10.15) |
| everything else closed | posix_spawn_file_actions_addclosefrom_np(3) (glibc 2.34), over close-on-exec everywhere (slice 0) | POSIX_SPAWN_CLOEXEC_DEFAULT, over the same (§4.5) |
| signals | POSIX_SPAWN_SETSIGMASK, POSIX_SPAWN_SETSIGDEF | the same |
| exit readiness | pidfd_open (via syscall, since glibc only wraps it from 2.36) | kqueue EVFILT_PROC, NOTE_EXIT |
| reap | waitpid(pid, &status, 0) | the same |
7. Slices
| Slice | What | Edition |
|---|---|---|
| 0 | Close-on-exec on every descriptor the backends open (§4.5), with a conformance test that asks a child what it inherited. Built | every edition (a tightening) |
| 1 | Exec, the exec, child_signal, pipe_read, pipe_write labels, Split’s ninth field, pipe_open, exec_spawn, child_wait, child_kill, the Pipe operations; both backends, both targets. Built (§8) | 7 |
| 2 | poller_add_pipe, poller_add_child. Built (§4.8, §8) | 7 |
| 3 | std.process: Args (the \0 builder) and run(heap, exec, path, args, input, most, timeout)Argv (the \0 builder), channels, and capture(heap, clock, child, to_child, from_child, input, most, timeout), a bounded capture with a deadline, built on slices 1 and 2. Corrected (#275): neither name nor signature could be built; §7.1. Built (§8) | 7 |
| 4 | cancho-tools#10: the MCP server, as a cancho program holding Exec narrowed to the tools’ directory Fs, Net or Ffi. Built (cancho-tools#17, §8) | 7 |
| 5 | The two prerequisites of a run tool in cancho-tools: exec_spawn_in (the working directory, §4.10) and std.process.capture_both (standard error beside standard output, §7.2). Built (§8) | 7 |
| 6 | Letting go of a child (§4.11, #524) and signalling its group (§4.12, #533): exec_spawn_detached, child_pid, child_release, child_kill_group, and capture killing the group. Built (§8) | 9 |
7.1 Slice 3: std.process
Two of §7’s promises could not be built, measured with the compiler of slice 2.
runcannot take theExec. A function’s parameter names one prefix, and nothing is polymorphic over it (effect-polymorphism.md). A libraryrunwould have to take&Exec(""), and a program holdingExec("/usr/bin")cannot pass it:expected "", found "/usr/bin". The MCP server’s whole point (§3.3) is holdingExecnarrowed to the tools’ directory, so arunit can call does not exist. The spawn stays with the caller, under the caller’s ownExec, which is where the report should see it, and the library does everything after it.Argsis taken, and so isarg.`Args` is a built-in type and cannot be redeclared: it is the argument capability everySplitholds. The builder isArgv, and adding to it isadd, sinceargis the builtin that reads one argument.
The surface.
pub res struct Argv { .. } // a `\0`-separated list, in a buffer.Buffer
pub fn argv[&h](heap: &!h Heap, capacity: int) -> [heap] Argv
pub fn add[&h, &s](heap: &!h Heap, list: Argv, one: &s [byte]) -> [heap] (Argv, int)
pub fn list[&a](list: &a Argv) -> [] &a [byte] // what `exec_spawn` takes
pub fn drop[&h](heap: &!h Heap, list: Argv) -> [heap] int
pub enum Channels { Ok(Pipe, ChildEnd, Pipe, ChildEnd), Failed(int) }
pub fn channels() -> [] Channels // to the child, from the child
pub fn close(to_child: Pipe, from_child: Pipe) -> [] int
pub enum Ran { Code(int), Signaled(int), TimedOut, TooMuch, Failed(int) }
pub fn capture[&h, &c, &i](heap: &!h Heap, clock: &c Clock, child: Child,
to_child: Pipe, from_child: Pipe, input: &i [byte], most: int, timeout: int)
-> [heap, clock, poll] (buffer.Buffer, Ran)
The MCP server’s call (§3.3), with it:
match process.channels() {
Channels::Failed(e) => { ... }
Channels::Ok(to_child, child_in, from_child, child_out) => {
match exec_spawn(exec, path, process.list(argv), "",
Stdio::Pipe(child_in), Stdio::Pipe(child_out), Stdio::Null) {
Spawned::Failed(e) => { process.close(to_child, from_child); ... }
Spawned::Ok(child) => {
let (answer, ran) = process.capture(heap, clock, child, to_child, from_child,
request, 1048576, 10000);
...
}
}
}
}
capture’s row is [heap, clock, poll]: owning the Child and both
Pipes discharges child_signal, pipe_read and pipe_write (§3.2;
checked: a function that owns them and kills, reads and closes declares
none of the three), and rows are exact, so poll is there because
poller_wait is. Nothing in std.process names exec: a program’s report
says exec("p") exactly where it spawns.
What capture does, and the measurement behind each choice
(scripts/process_run_measure.py, run on each kernel). All of it
on one Poller: the output channel (readable, token 1), the input channel
(writable, token 2, until the input is written) and the child (token 3).
- Input and output are interleaved, never write-all-then-read. A channel
holds 8,192 bytes on macOS 26.2 and 180,224 on Linux 6.8 before
EAGAIN(anAF_UNIXsocket pair, default buffers). Writing all the input before reading any output, tocat, finishes at 16 KiB and deadlocks at 32 KiB on macOS, and at 256 KiB and 512 KiB on Linux: the child blocks writing output nobody reads, and the parent blocks writing input nobody reads. A loop that passed every Linux test would deadlock on macOS at a size no Linux test tried, so both channels are non-blocking and both are on the poller from the start. The input end is closed once the input is written (the child’s end of file), at once for empty input, and as soon as a write answersFailed(a child that stopped reading is not an error; its exit status says what it thought). - The child’s exit ends the capture; the end of the stream does not. Once
the poller reports the child, the output is read until
AgainorEnd, and the capture stops. A write to a socket has reached the reader’s queue when it returns, so everything the child wrote is there when it exits: for a child writing 1 byte, 64 KiB and 1 MiB and exiting, the bytes captured this way were all of them in 600 runs of 600 on each kernel (and the end of the stream had already arrived in every one). Waiting for the end instead would wait for any process that inherited the end:sh -c 'sleep 2 & echo hi'exits at once and its output ends 2.0 s later, on both. A grandchild’s output after the child has exited is not the child’s, and is not captured. - A deadline is a
Clock, not a timeout per wait.poller_wait’s timeout restarts at every wake, socapturereadsclock_msonce at the start and waits each time for what is left. When nothing is left, the child is killed (KILL, which cannot be caught or blocked), reaped, and the answer isTimedOutwith what was captured so far. mostis a bound on what is kept, and exceeding it ends the child. The buffer never grows pastmostbytes. The first byte beyond it kills the child, which is reaped, and the answer isTooMuchwith the firstmostbytes. An output bound that kept reading and discarded would still let a child run unbounded in time; with a deadline that is bounded too, but a tool that is producing more than its caller will read has already failed.- Every path reaps.
child_waitis the only way out ofcapturethat ends theChild, so linearity says this.Code(n)andSignaled(bit)areExited’s;TimedOutandTooMuchsay whycapturekilled it, rather than aSignaled(256)that could have come from anywhere. - A child the poller cannot watch is killed, not waited on blind. If
poller_neworpoller_add_childfails (EMFILE§4.8,ENOSYSon Linux before 5.3), there is no way to wait for the child and the deadline at once, and a blockingchild_waitcould outlast any deadline. The child is killed and reaped and the answer isFailed(errno). A Linux before 5.3 therefore cannotcapture; it can still use the handles. - Standard error is the caller’s.
capturereads one stream: the MCP server returns a tool’s standard output unchanged with its exit code (cancho-tools#10), and #237 drives its children through the handles. A caller passesStdio::Nullor aFilefor the third stream. Extended (§7.2):capture_bothreads it too, for a caller that needs it;captureitself is unchanged.
add refuses an argument containing a \0, as an outcome, not a trap.
Such an argument would arrive as two, and the second could be anything,
--root included. exec_spawn traps on an unterminated list because that
is the program’s own mistake (§4.2); an argument is data, and the MCP
server’s arguments come from a model. So add answers 22 (EINVAL) and
adds nothing, 0 when it adds, and the caller decides.
Not in slice 3: an environment (the MCP server passes none, and #237
builds its own list with Argv and calls exec_spawn itself); capturing
standard error (built in §7.2); a capture for a child started with Stdio::Null input
(open channels and pass empty input; it costs one socket pair).
How it was planned to be checked (§8 has what was built and found;
both backends, both targets): cat fed and read 1 MiB, past both kernels’
deadlock sizes; most exceeded is TooMuch with exactly most bytes and
the child reaped; sleep 30 with a deadline of 200 ms is TimedOut within
the deadline and a margin; sleep 2 & echo hi answers hi well before 2
s; a child that never reads its input still exits Code(0); an argument
with a \0 is refused and nothing is spawned with it; a caller whose row
leaves out clock is refused (effect-not-declared; a test, since reject
fixtures are checked without --std). Mutants, as for the
slices before: the interleaving (write all, then read), the drain after the
exit, the deadline taken per wait, the bound off by one, and the kill on
each path.
7.2 capture_both: standard error beside standard output
A tool that runs a program for a model (cancho-tools’ future run) must
return both what the program printed and what it complained about, and they
are not one stream: an exit code with an empty stdout and a reason on
stderr is the common failure. capture read one channel and left the third
stream to the caller. Both channels are now captured, bounded and under the
deadline, by capture_both.
pub enum ChannelsWithErrors { Ok(Pipe, ChildEnd, Pipe, ChildEnd, Pipe, ChildEnd), Failed(int) }
pub fn channels_with_errors() -> [] ChannelsWithErrors // input, output, errors: parent's end then child's
pub fn capture_both[&h, &c, &i](heap: &!h Heap, clock: &c Clock, child: Child,
to_child: Pipe, from_child: Pipe, from_errors: Pipe, input: &i [byte],
most: int, most_errors: int, timeout: int)
-> [heap, clock, poll] (buffer.Buffer, buffer.Buffer, Ran) // output, errors
The caller passes Stdio::Pipe(child_errors) as the third stream. capture
is now a thin call of the same loop with no errors channel; nothing about
its behaviour changed (its tests and mutants are unchanged and pass).
What it does, and what was measured
(scripts/process_cwd_stderr_measure.py, part B):
- The errors channel is read on the same poller, never after the output. A
channel holds 8,192 bytes on macOS and 180,224 on Linux. A parent that
reads the output to its end and then the errors deadlocks against a child
that writes more than that to its errors first: measured, 8,192 bytes of
errors finish and 16,384 deadlock on macOS, 131,072 finish and 262,144
deadlock on Linux. On one poll set a child writing 1 MiB to each of its two
streams completes on both. So the third channel is registered beside the
other two from the start (token 4), non-blocking, and drained whenever
readable.
a_flood_of_errors_does_not_stall_the_outputis a megabyte of errors before any output. - The exit still ends the capture, and both are drained to
Againafter it. For a child writing 1 byte, 64 KiB and 1 MiB to each stream and exiting, all of both streams were captured in 200 of 200 runs per size on each kernel, with both ends of stream already present in every one. Waiting for the end of either would wait for any process that inherited it (§7.1). - Each channel has its own bound.
mostfor the output,most_errorsfor the errors: what is kept is at mostmost + most_errorsbytes of content, whatever the child writes. The first byte past either bound ends the child (KILL), and the answer isTooMuchwith both buffers as they were: the one that overran holds exactly its bound.TooMuchdoes not say which; a caller compares the sizes with its bounds (exactly the bound is not an overrun: four bytes of output under a bound of four isCode(0)inerrors_past_their_bound_end_the_child, four of errors under three is not). - One deadline for both. It is the
Clock’s, as in §7.1: a deadline that passes kills the child, reaps it and answersTimedOutwith both buffers as read so far (a_deadline_returns_both_channels_so_far:aon one andbon the other, thensleep 30under 300 ms). - A channel that has ended is closed, and nothing is spun on. A child that
closes all three of its streams and sleeps for a second costs the
capture no processor time by
wait4’s account (bound 400 ms), as forcapture. Every channel is also given back when the deadline ends the capture: sixty captures under a limit of 40 descriptors all complete. - The order between the two streams is not kept. Two channels are two queues; a caller that needs the interleaving reads one channel (the alternative of §5, and measured there: one socket keeps it exactly).
8. What it is checked by
What follows is slice 1 as built and measured; the poller (§4.8) is slice 2’s, and is checked there.
-
Refusals (
tests/reject/), each with its rule tag:exec_without_capability(capability-misused),exec_widened(Exec("/a/b")cannot becomeExec("/a")) andexec_sibling_prefix(/opt/xcannot become/opt/xevil) (capability-not-narrowable),exec_effect_undeclared(effect-not-declared),child_unwaitedandchild_end_kept(linear-value-unconsumed),child_taken_apart(linear-value-taken-apart),exec_is_edition_seven(unknown-name). One accepted program,tests/accept/process_spawn.cho, prints what/bin/echowrote down a channel. -
Conformance tests over real processes (
tests/conformance/processes.rs). Each case runs on both backends, and the two must print the same:- the child’s argument list (an empty argument, a space inside one) and environment are exactly what was passed, and none of the test’s own environment arrives;
- a channel carries the parent’s bytes to
catand back; - the child holds 0, 1 and 2 and nothing else, while the parent holds eight more channel descriptors of its own and one it inherited without close-on-exec;
KILLby its bit isSignaled(256);TERMends a child although the parent holdsTERMclaimed;- a bit naming no signal is
EINVAL; - a missing program is
Spawned::Failed(ENOENT), andexit 3isCode(3); - writing to a reaped child is
Sent::Failed(EPIPE), and the parent lives; - a child writes to
Stdio::Nullstdout and stderr as to any stream; - a path outside the bound,
.., a sibling of the bound,LD_PRELOAD,DYLD_INSERT_LIBRARIES, and an argument or environment list not ending in\0each trap:SIGILLorSIGTRAP, not merely a death by signal; - the authority report says
exec("/bin")and is bounded.
-
Mutants. 22 of them, eleven a backend, each run against the process tests:
- the path check, the loader refusal, the unterminated-list trap,
closefrom, the signal mask and default flags, the parent’s close of the ends it gave, the exit-code decode, the kill bit,Nullopened read-only, andargv[0]: all 20 are killed. The two closes are killed by a hang: a parent still holding the child’s end of its output never sees that output end. - The signal decode masked with
0x3finstead of0x7fsurvives, and is equivalent: the two masks differ only for signal numbers from 64, and no signal a child can be ended by is numbered that high on either target.
The first run left four survivors. Each had the same cause: the tests did not look at the thing the check changes.
- Without its trap, an unterminated list makes
memchrfind nothing, and the walk past the end crashes. The test asked only that the program die by a signal, so it now asks for the trap’s signal. - Without
closefrom, nothing changed, because slice 0 had already made every descriptor the parent opens close-on-exec. Only a descriptor inherited without close-on-exec tells them apart, so the test now starts the probe holding one. Nullopened read-only went unnoticed until a child wrote to it.
The equivalent mutant was killed on one backend in each of two runs. That led to a flaky case: “writing to a child that has ended” took the end of the child’s output as proof that its input was closed too. Linux releases a dying process’s descriptors through deferred work, in no promised order, and about 1 run in 10 wrote to a channel the child still held. The case now reaps the child before it writes, since reaping comes after every descriptor is released. It passed 60 runs in a row.
- the path check, the loader refusal, the unterminated-list trap,
-
Letting go (§4.11). Fixtures:
tests/accept/process_release.cho;tests/reject/child_released_then_used.cho(linear-use-after-move),child_release_undeclared.choandexec_spawn_detached_undeclared.cho(effect-not-declared),child_release_is_edition_nine.cho(unknown-name),exec_spawn_detached_on_wasi.cho(unsupported-on-target). Conformance tests over real processes, both backends (tests/conformance/process_release.rs, drivertests/programs/process_release.cho): a detached child is alive after its parent exited, leads its group and session, and a group signal reaches it; of a plain and a detached child released together only the detached one survives its parent’s group being killed; fifty released children leave no zombie while the parent lives, and a child it kept is still waited for; releasing one that has already ended answers0; fifty releases leave the descriptor count as it was (Linux); a detached start outside the prefix traps; the report carriesexec_detached, andtests/accept/process_spawn.cho’s does not. Mutants:scripts/process_release_mutants.py. -
The bound check under
-O2. The firstExecprogram built with the LLVM backend exited through the wrong arm:checked_pathloaded a byte past the bound for every index of the path, and the optimiser used that poison to delete the path. It was fixed forFsandNetas well, in #273, which this slice carries (filesystem.md§4). -
Measured on macOS (slice 1). The first macOS CI run of slice 1 (#274) passed
CLOEXEC_DEFAULT,SO_NOSIGPIPEon the socket pair and Darwin’s signal numbers. It failed one test, and the test was wrong:ls /dev/fdlists five entries there to Linux’s four, and the status line after the listing,== code 0, had been read as a descriptor.
Slice 2
- Conformance tests (
processes.rs, the waiter), each on both backends, the two to print the same:- a channel and a child watched together: while the child sleeps nothing is reported, then its output arrives, the channel ends, and the child’s exit is reported;
- a child that ended before it was registered is still reported;
poller_wait’s deadline passes with the child running, a kill follows, and the exit is reported;- sixty children come and go under a limit of 24 descriptors and the next
is still watchable: reaping gives the
pidfdback; - a kernel that refuses
pidfd_open(aseccompfilter answeringENOSYS, installed before the waiter starts) is told so bypoller_add_child, and the child is still reaped.
- Refusals:
poller_add_child_is_edition_seven(not-a-function) andpoller_add_child_effect_undeclared(effect-not-declared). - Emission, from any host: Linux asks for
syscall(thepidfd),epoll_ctlandclosefrom, Darwin forkeventand none of the three; all four triples are accepted byclang, and Cranelift builds the Mach-O object. - Mutants. Thirteen, one site each, run against the process tests with
a limit of five minutes (
scripts/process_mutants.py; Linux, aarch64, kernel 6.8, where every one but the dispatch is on the path taken): thepidfdnot closed,pidfd_openasked for pid 1, the refusal’serrnonot negated, thepidfdwatched forEPOLLOUT, a missingpidfdnot checked, andpoller_add_pipetaking the modify path, each on both backends; and thepidfdread from theChild’s low half (Cranelift). All 13 are killed. Seven fail a test: the two closes leave the sixty-child test without a descriptor, and the refusal’serrnoand the missing check are caught by both theseccomptest and theEMFILEone. Six are killed by a hang, which a mutant that watches the wrong thing can only be: pid 1’spidfd, the pid taken as a descriptor,EPOLLOUTon apidfd, and a channel never added (EPOLL_CTL_MODanswersENOENT) are never reported, and the waiter waits. The first two had been run in an earlier session and killed; that run was stopped there, and its script was not kept. This one is in the repository and runs all thirteen. - Measured on macOS (slice 2). The first macOS CI run passed the tests
of live children (output and exit; a deadline, then a kill) and failed the
two that register a child that had already ended, with
ESRCH(§4.8). The second run, with a fix sending two changes, reported that child with token0(§4.8 says why). With one change, all the process tests pass on macOS 26.2 (arm64), and the four poller tests passed 40 runs of 40 there. EMFILE(§4.8):a_child_with_no_descriptor_to_spare_says_emfilereaches it on Linux and checks that Darwin, which watches the pid, needs no descriptor.
Slice 3
-
std/process.cho(docs/standard-library.md):Argv,add,list,count,drop;Channels,channels,close;Ranandcapture, as §7.1 gives them.capture’s row is[heap, clock, poll], as §7.1 said: it callsdrain, which declarespipe_read, and owning thePipedischarges it. -
Conformance tests (
tests/conformance/capture.rs, one driver,tests/programs/process_capture.cho, both backends printing the same):- 1 MiB through
cat, every byte back in order (1,048,576 bytes, 5 to 8 ms on macOS, where 32 KiB written first deadlocks); - exactly
mostbytes isCode(0); one more isTooMuchwithmostkept, and a child that lingers after its output is killed, not waited for; sleep 30under a 200 ms deadline isTimedOut(203 ms on macOS);sleep 2 & echo higiveshiin 4 ms, not 2 s;- a child that reads none of 1 MiB of input exits with its own status;
- a child that closes both streams and sleeps for a second costs the
capture no processor time (about 0 ms by
wait4, against a bound of 400 ms): an ended channel or a gone reader is closed, not spun on; - with
pidfd_openrefused (Linux, theseccompstand-in of slice 2),captureanswersFailed(38)and killssleep 30rather than wait for it; addrefuses an entry containing a\0with22, the list runs without it;- a caller whose row leaves out
clockis refused,effect-not-declared.
- 1 MiB through
-
Mutants. Eleven (
scripts/process_mutants.py --capture, Linux aarch64): the input written blocking, no drain after the exit, the deadline taken per wait, the bound off by one, no kill on each of the three paths that need one, the input end never closed, an ended output not closed, a failed write not ending the input, and a\0let through. The first run killed 7 and left 4, and each survivor was a test that did not look at what the choice changes:- No kill past the bound survived because closing the output ends a
child that is still writing (
SIGPIPE). The child now lingers after its output, and only the kill ends it in time. - No kill when it cannot be watched survived because nothing reached
that path, and descriptor limits cannot: the most
captureholds at once (four, inchannels) comes before the poller. Theseccompfilter reaches it, so the test is Linux’s. - An ended output not closed and a failed write not ending the input each leave a channel that is always ready on the poller, and the loop spins until the child exits: the right answer, at the cost of a processor. Only the processor time shows it, so a test now measures it.
With the four sharper tests, 11 of 11 are killed.
- No kill past the bound survived because closing the output ends a
child that is still writing (
-
Found while building it.
argis a builtin as well asArgsa type, so the builder’s operation isadd(§7.1). And a qualified call is resolved against local bindings: withlet max = 3;in scope,math.max(max, 2)is refused as “maxis a local binding”. That is the compiler’s, not this slice’s, and is reported separately;std.process’s driver names its local otherwise.
Slice 4
Built in cancho-tools (#17); its design and measurements are that
repository’s docs/mcp.md. What it says about this document’s work:
- The design held. The server calls
std.process.captureonce per tool call and nothing else of slices 1 to 3 beyondexec_spawn, which it makes under its ownExec("<bin>"), as §7.1 decided. Its derived authority is bounded:exec("<bin>"),child_signal,pipe_read,pipe_write,poll,clock, the console andargs, and noFs,NetorFfi. The row’s“nothing that writes”is corrected above: the server writes its answers to standard output, and writes no file. - The bound is a literal at build time.
narrowtakes one, so the server’s source names/opt/cancho-tools/binand a build script bakes another directory in, as cancho-tools’ D14 variant bakes--root– the open question of §9’s “a bound chosen at deployment”, met forExec. - A missing flush is invisible to batch tests. A stdio server’s answers
sit in libc’s buffer until
flush_out; tests that send every request and close the input see them at exit anyway. Only a client that waits for each answer with its input open catches it, so the server’s tests include one. - The qualified-call shadowing (
let name = ...; tools.name(k)is refused) was met twice more there; it is reported against the compiler.
Slice 5
The working directory (§4.10) and capture_both (§7.2): the two things a
run tool in cancho-tools needs beneath it. Measured on macOS 26.2 (aarch64)
and Linux 7.0 with glibc 2.43 (x86-64), by
scripts/process_cwd_stderr_measure.py; the Linux tests and mutants ran on
the x86-64 host, the macOS tests on the Mac.
- Code.
Builtin::ExecSpawnInand anin_dirflag onExpr::ExecSpawn;exec_spawnin both backends takes the flag and addsposix_spawn_file_actions_addfchdir_np(declared in the LLVM backend’semit.rs) between the three streams and theclosefrom.std/process.cho:ChannelsWithErrors,channels_with_errors,capture_both, and the shared loopgather, whichcapturenow calls. - Refusals. Two fixtures under
tests/reject/:exec_spawn_in_owned_dir(type-mismatch) andexec_spawn_in_arity(arity-mismatch). No new rule. - Conformance tests, both backends, both targets.
processes.rs(drivertests/programs/process_cwd.cho): the child’spwdis theDir’s and without aDirthe parent’s, and a second plain child afterwards shows the parent has not moved anddir_closestill answers0;./toolis found beneath theDirand isENOENTwithout one; the descriptors the child lists are equal with and without theDir; a directory that does not open starts nothing; a directory without search permission isSpawned::Failed(13); a relative program path underExec("/bin")traps.capture.rs(drivertests/programs/process_capture.cho, new modes): output and errors kept apart with the exit status; a megabyte of errors before any output; each bound its own (an exact bound is no overrun, one byte past is) and the child killed; the deadline returns both buffers; no processor spent on an ended errors channel (wait4); sixty timed-out captures under a limit of 40 descriptors. - Mutants (
scripts/process_mutants.py, Linux x86-64): 15 new, 15 killed, and the eleven of §7.1 re-run against the refactored loop (--capture: 17 of 17 killed); the thirteen of slice 2 were not re-run in full (the first seven were, and were killed), since that code did not change. The spawn: no chdir, the chdir after theclosefrom, the chdir to descriptor 0, theDirclosed after the spawn, the wrong argument taken for theDir(both backends, nine in all).capture_both: the errors never read, read under the output’s token, bounded by the output’s bound, an overrun not ending the child, an ended channel not closed, the channel not consumed at the end. Two things the first run taught:- A mutant I wrote was equivalent (
0 * poller_add_pipe(...)still registers; it only dropped the check of the answer). It is replaced by one that registers under the wrong token. - The errors drain after the exit has no mutant, on purpose. Skipping it survives, and so would skipping the output’s. It is redundant by construction: the poller is level-triggered and a child’s last write precedes its exit, so a channel still holding bytes is in the batch that reports the exit, and the event loop has read it before the drain is reached. It stays as a guard; no test can tell it from its absence, which is the honest reason it is not in the list.
- The last, “left open at the finish”, is killed by the compiler:
errorsisres, so a path that does not consume it does not compile (§4.7’s argument forChild). The 60-capture test is what checks the paths that do consume it all do so before returning.
- A mutant I wrote was equivalent (
- Not mutated: the trap on a refused
addfchdir_np(ENOMEM), which no program can provoke (§4.10); the macOS side of the ordering (aclosefromis Linux’s), which the Mac’s tests exercise (the samepwd, relative-path and descriptor cases pass there) but a mutant run on Linux does not. - Found. §4.1 said a path may be relative to the working directory; under
a narrowed
Execit traps (corrected in place, with a test). A removed directory is not refused: the child starts in it (measured on both).Stdio::Mergeis an edition (a new variant breaks every exhaustive match ofStdio), so it was not built.
Slice 6
Letting go (§4.11) was checked in its own PR (#525); this is what #533 (§4.12) added, measured on Linux 6.18 x86-64 in a
container with no init to adopt orphans, so a killed grandchild stays a zombie (Z) and the tests call a process dead
when it is gone or Z. macOS: the code is the same call with the pid negated, and was not run.
- Code.
Builtin::ChildKillGroup(edition 9, rowchild_signal, region count 1, refused on WASI);child_kill(args, group)in both backends negates the pid and answersEINVALwith no call for a pid of 1 or less;std/process.chois edition 9 andcapturesends the groupKILLbefore the pid’s. The compiler in cancho: code 138 incodes.cho,netcode.choandexprs.cho,cancho_child_kill_groupinruntime.ll, and the generated tables. - Refusals.
tests/reject/child_kill_group_is_edition_nine(not-a-function),child_kill_group_undeclared(effect-not-declared),child_kill_group_on_wasi(unsupported-on-target). No new rule.tests/accept/process_group_kill.cho. - Conformance (
process_group.rs, drivertests/programs/process_group.cho, both backends): the S7 scenario run for real: a detached workersh -c 'sleep 77 & echo $!; wait', a 300 ms deadline throughcapture, and the grandchild (pid printed by the worker) gone orZafter the driver ends; the same worker started plain, whose grandchild is stillS(the control: without it the first test could pass for any reason); the answers ofchild_kill_groupfor a detached child (0, thenSignaled(256)), a plain child (3, and the program goes on), and a signal that is none (22); a worker already ended and not waited for, with a grandchild still running, whose group is still reached; and the authority report (child_signal,exec("/bin/sleep"), noexec("/bin"), nokill). - Mutants, each run to a red conformance test and then reverted. (1)
capturewithout the group kill: the S7 test fails,the grandchild survived: Some("S"). (2) Cranelift with the pid not negated: three tests fail (the plain child answers0instead of3; both grandchildren survive). (3) The Rust LLVM backend (CANCHO_COMPILER=rust), the same mutation: the same three fail. (4) The compiler in cancho’sruntime.llwith the pid not negated: the same three fail on thellvmbackend. Not mutated: thepid > 1guard, which no program can reach (§4.12). - Found.
docs/wasm.md’s builtin counts (162, 70 refused) were already stale since #524 (165 and 73) and are 166 and 74 now, corrected there in place.--backend llvmbuilds through the compiler in cancho by default, so a test run withoutCANCHO_COMPILER=rustnever exercises the Rust LLVM backend’s code: the third mutant needed the variable.
9. Open
| Question | Why it waits |
|---|---|
| Giving a child the console | §4.4. The exact shape needs the parent to lend &!Io for the life of the child, and a Stdio value cannot carry a borrow. Neither asker needs it |
Spawning beneath a Dir | §5’s last row. Linux only, so it needs a decision about a capability that exists on one target |
Built (§4.10): exec_spawn_in. Open still: a working directory that is not a Dir (a path), which needs the Fs rule’s check and has no asker | |
Stdio::Merge | §5. Needs an edition; capture_both serves the asker who wanted standard error |
| A bound chosen at deployment | narrow takes a literal, so a server whose tools’ directory is chosen when it is installed holds Exec("") or a literal it was built with. Fs has the same question (authority.md §2.3, lines.cho), and it should be answered for both at once |
| Children of a parent that dies | §4.7. PR_SET_PDEATHSIG exists on Linux only. §4.11 is the opposite request, and a detached child is meant to outlive its parent. Narrowed by §4.12 (#533), not closed: while the supervisor lives it can stop a worker and what the worker started, with the Child it holds and no /bin/kill. After the supervisor crashes nothing changes: the Child died with it, an orphaned worker’s group is a number in a journal, and stopping it is the next row. Not measured: a supervisor that traps with workers running (the issue’s “related, not measured”) |
| Signalling a pid the program holds only as a number | §4.11, §4.12. A kill(pid) builtin needs a capability of its own, since child_signal is about a Child; today /bin/kill through a narrowed Exec does it, and says so in the report. Narrowed (#533): for a worker the program still holds, child_kill_group does it with no Exec and no number. What stays open is the orphan after a crash and the pid-reuse hazard that comes with a recorded number (record it with something that tells runs apart, §4.11); it needs a capability or a handle that survives the process, and no program has asked for more than the issue does |
child_kill_group reaching a descendant that left the group | §4.12. A subprocess that calls setsid or setpgid is outside the group, and no group signal reaches it. A cgroup (Linux) or a job object would; neither exists on both targets, and no asker has needed it |
exec_spawn_detached with a working directory | §4.11. No program has asked: exec_spawn_in does not detach, and the combination is the same node with two flags |
| One reaper thread for all released children | §4.11. A thread per running released child costs about 16 KB (measured); a single reaper needs a table shared between threads, which no program needs yet |
Mapping exec("p") onto lex-os’s exec level | The same bridge agent-toolbox.md §2.5 found missing for net_out. The report carries what the bridge needs |