std.proc

Run a child process, with a timeout that reaps everything it started.

import std.proc

r = proc.run("ffmpeg", ["-i", "in.mp4", "out.mp4"], timeout_ms: 5000)
if r.timed_out {
    print("gave up; the whole tree was killed")
} else {
    print("exit {r.code}")
}

std.os's exec runs a command to completion with no deadline. This adds the deadline, and makes it mean something.

Why the process group matters

Killing a process kills that process. It does not kill what that process started. So a timeout wrapped around sh -c "ffmpeg ..." kills the shell and leaves ffmpeg running, holding the CPU and the file you were writing.

Measured on a dev box: killing one shell orphaned four sleep processes.

A child started here gets its own process group, and the deadline signals the group. The whole tree goes.

On Windows there are no process groups in this sense and no signals, so the group signal becomes taskkill /PID <pid> /T /F - /T walks the child tree, so the grandchildren are still reached. What Windows cannot do is be gentle about it: term and kill both terminate. The signal name is still checked on every platform, so a typo is refused wherever you run.

Driving a long-lived child

run waits for the child to exit, so it can do everything with one except talk to it. A language server, or an MCP server over stdio, is the other shape: write a request, read the answer, repeat.

import std.io
import std.proc

p = proc.spawn("mcp-server", [])
io.write(proc.stdin(p), request + "\n")
reply = io.read_line(proc.stdout(p))
io.close(proc.stdin(p))

The three pipes are streams, so a child is read with the same verbs as a socket or a file. proc.stdout(p), proc.stderr(p) and proc.stdin(p) return the same stream on every call, so a line one read buffered is still there for the next.

A child does not outlive the program. Anything still running when the program ends is signalled on the way out, including when the program is stopped with Ctrl-C or SIGTERM rather than returning normally. A script cannot leave strays behind by forgetting to call kill.

p = proc.spawn("mcp-server", [])          # dies with the program
q = proc.spawn("daemon", [], detach: true) # deliberately survives it

Pass detach: true when a survivor is actually what you want; then it is yours to manage.

spawn opens stdin; run deliberately does not. A child reading a pipe nobody will ever write to blocks forever, and run gives you no opportunity to write, so it hands the child a closed stdin rather than a hang. Writing to a handle that has no stdin is an error, not a silent no-op.

Close stdin when there is nothing more to send. A child reading until end of input never reaches it while the pipe is open, so io.close(proc.stdin(p)) is how to say "that is all" without killing the process.

Deadlines

A pipe starts with no deadline: a read waits for the child. Set one with io.timeout(proc.stdout(p), 5000) and a read that passes it raises an error rather than returning null, so a loop reading until null cannot mistake a slow child for a finished one - which is exactly what the old proc.read_line(p, ms) could not tell you.

Capability

Needs exec. Spawning a program and signalling its process group is process control, the same authority os.exec and os.exit need. See capabilities. The three pipe accessors need it too; the io verbs on the stream they return do not, because holding the stream is the grant.

API

callresult
proc.run(cmd, args?, timeout_ms:){ code, stdout, stderr, timed_out }, after waiting.
proc.spawn(cmd, args?, detach?)A handle for something long-running. detach: true lets it outlive the program.
proc.pid(handle)The child's pid, which is also its process-group id.
proc.wait(handle, timeout_ms?)A result map, or null if still running.
proc.kill(handle, signal?)Signals the group. Default "term"; also "kill", "int", "hup".
proc.stdin(handle)The child's standard input, as a writable stream.
proc.stdout(handle)Its standard output, as a readable stream.
proc.stderr(handle)Its standard error, as a readable stream.

timeout_ms: is a whole number of milliseconds, passed by name. Left out, run waits as long as it takes.

Notes

A deadline sends SIGTERM first, then SIGKILL after a short grace. A well-behaved child gets a chance to clean up; one that ignores TERM is exactly the case a timeout exists for.

timed_out is a field, not an error. Whatever the child managed to write before it died is still on the result, which is usually the part that tells you why it hung.

proc.wait without a deadline is a poll. null means still running, so a supervisor loop can check without committing to block.

Output is drained continuously, not read after the wait. A child that fills the 64 KiB pipe buffer blocks on write, so reading late would hang rather than time out. The stream reads from that buffer, and the buffer is unbounded: a child that prints faster than you read grows memory, exactly as proc.run's capture does.

proc.wait returns what the streams did not consume. Every byte is delivered once, to whichever side asked for it first, so a program that reads proc.stdout and then waits sees the remainder on the result rather than the whole output twice.

A line is returned without its terminator, including the \r of a CRLF, and a final line with no newline at all is handed over rather than dropped.

Every write is flushed. A child reading a line at a time would otherwise get nothing until the buffer happened to fill, which reads as a hang.

Moving from the old verbs

proc.write, proc.read_line and proc.close_stdin were removed in 0.23. ecko check names the replacement for each:

wasnow
proc.write(p, data)io.write(proc.stdin(p), data)
proc.read_line(p, ms)io.read_line(proc.stdout(p)), with io.timeout for the deadline
proc.close_stdin(p)io.close(proc.stdin(p))