Skip to content

std.process

std.process runs a shell command as a child process and reads its output back line by line, added in 1.4.2. It is four functions and one struct: you open a readable pipe to a command with proc_open, pull lines off it with proc_read_line, and close it with proc_close, which reaps the child and hands you its exit code. When you want the whole output at once and none of the bookkeeping, run_capture does all three for you. The module lives at lib/std/process.dusk.

@import std.process

Imported names are flat: after @import std.process you call proc_open, run_capture, and the rest with no prefix. See stdlib overview for how imports work in general.

FunctionDescription
proc_open(cmd: string) -> (Proc, error)Runs cmd through the platform shell and opens a readable pipe to its stdout. The error carries strerror’s text on failure.
proc_read_line(p: *Proc) -> (string, bool)The next line of p’s output without its trailing newline, and whether one was found.
proc_close(p: *Proc) -> (int64, error)Closes p, reaps the child, and returns its decoded exit code.
run_capture(cmd: string) -> (string, int64, error)Runs cmd, reads every line into one newline joined string, closes the stream, and returns the output alongside the decoded exit code.

Proc is the open stream, a struct with a single int64 field:

export struct Proc {
h: int64,
}

proc_open opens its pipe with popen(cmd, "r"), and popen hands cmd to the platform shell’s own /bin/sh -c. Your string is a shell line, not an argv array. That is what makes run_capture("sh -c 'echo world; exit 7'") work at all, and it is also the sharp edge: if you build a command out of untrusted input, you are composing a shell line for an interpreter that will happily read a quote, a semicolon, or a backtick in that input as syntax. There is no argv taking variant here, so a caller who interpolates untrusted text into cmd owns that risk. std.os’s quote is the tool for it: it wraps one argument in POSIX single quotes so the shell reads it as a single literal word, and it composes fine with proc_open, since cmd is only a string. Read what quote does and does not cover before you lean on it, because it quotes an argument rather than a command and it is one layer deep.

The module’s foreign block binds three C runtime shims, cool_popen, cool_fgets, and cool_pclose, rather than libc’s popen, fgets, and pclose directly. The reason is that a FILE* has no way to ride home into Dusk as a pointer a wrapper could check. popen reports failure by returning NULL, and testing for NULL means comparing a pointer, which Dusk rejects outright on every pointer type: “pointers do not compare; compare the values they point to”. A *void stream coming back from a raw popen binding would be a value you could not test.

So the shims move the failure onto a channel Dusk can read. cool_popen takes an out parameter and returns an int64 status, and Proc wraps the stream as one int64 field holding the pointer’s bit pattern. That is exactly the shape std.fs’s Dir carries for its DIR*, and the same convention std.fs and std.os follow throughout: a call that can fail reports it through a separate status rather than a nullable pointer, and you read os_errno() immediately after the call that may have set it, before another foreign call overwrites it.

Proc’s h is an opaque token. It is meaningful only to proc_read_line and proc_close, and Dusk code never reads or compares it directly. When proc_open fails it hands back a zeroed Proc alongside the error, and that one is never valid to read from or close.

proc_read_line takes p by pointer rather than by value, so the stream cannot be copied and read from two aliases. A Proc names a single OS stream that only one owner may advance.

The internal read chunk is 4096 bytes, but that is not a line length limit. A line longer than the chunk is reassembled across as many cool_fgets calls as it takes, so no line silently truncates no matter how long it runs. What you get back is the line without its trailing newline.

The second value is false, with the empty string first, once the stream is exhausted. A hard read error also reports false, because proc_read_line carries no error channel of its own, so a mid-read failure reads as a clean end of stream. That is the same tradeoff std.fs’s dir_next makes for a failure mid-walk. Reading from an already closed Proc, whose handle proc_close zeroed, reports that same clean end of stream rather than dereferencing a stale FILE*.

proc_close closes the stream, reaps the child, and decodes pclose’s wait status exactly the way std.os’s run decodes system’s. The low 7 bits name a terminating signal: when they are non-zero the child died on a signal, and the code comes back as 128 plus the signal number. Otherwise the child exited normally and the code is the exit status shifted down out of the signal byte, so exit 7 reads back as 7.

pclose reports its own failure, a bad handle or a wait4 failure, by returning exactly -1, a value no real wait status equals. That one case comes back as -1 with an error rather than being decoded as a status.

proc_close also takes p by pointer, and it zeroes the handle in place. A second proc_close on the same Proc sees the zeroed handle and reports an error instead of handing an already reaped FILE* back to pclose, which would be a double close. You still own the Proc allocation itself and free it once, after the last close.

run_capture is the whole cycle in one call: it opens, reads every line into one newline joined string, closes, and hands back the output alongside the decoded exit code. A failure to open reports its error immediately with a -1 exit code and no output. A failure to close reports its own error alongside whatever output the read loop already captured, so you never lose the lines you already read to a close that went wrong.

process_tour.dusk
@paradigm procedural
@import std.process
func main() -> int32 {
// run_capture opens the stream, reads every line into one newline joined
// string, closes it, and hands back the decoded exit code.
out, code, e := run_capture("echo hello")
e.ignore()
println(out) // hello
println(code) // 0
// A non-zero exit decodes the same way std.os's run decodes system's.
out2, code2, e2 := run_capture("sh -c 'echo world; exit 7'")
e2.ignore()
println(out2) // world
println(code2) // 7
// The long way, when you want each line as it arrives rather than the
// whole output at once. proc_read_line and proc_close take *Proc.
pv, open_err := proc_open("printf 'a\nb\n'")
if open_err.exists() {
println("open failed")
return 1
}
p: *Proc = alloc(pv)
mut more: bool = true
mut count: int64 = 0
while more {
line, found := proc_read_line(p)
if found {
println(line) // a, then b
count = count + 1
} else {
more = false
}
}
println(count) // 2
close_code, close_err := proc_close(p)
close_err.ignore()
println(close_code) // 0
free(p)
return 0
}
  • std.fs: the file and directory module whose Dir carries its DIR* as the same int64 handle, for the same reason.
  • Foreign functions: the boundary std.process binds its three C shims across.
  • stdlib overview: the full module list and how imports resolve.