Running External Programs¶
Kaappi can start other programs, talk to them over pipes, and wait for them
to finish — without blocking anything else your program is doing. The
library is (kaappi process).
Availability
(kaappi process) arrived after v0.25.0. On an older binary the
(library (kaappi process)) gate below is simply false, and
kaappi features reports what your build has.
(import (scheme base) (kaappi process))
(call-with-values
(lambda () (run-process '("git" "rev-parse" "HEAD")))
(lambda (status commit err)
(if (= status 0)
(display commit)
(display err (current-error-port)))))
Two entry points, and most programs only need the first:
run-processruns a program to completion and hands you its exit status and its output.spawn-processstarts a program and returns immediately, so you can stream to and from it.
The command is a list, not a command line¶
Every procedure here takes a list of strings. The first is the program, the rest are its arguments, and they reach the program exactly as written.
If filename is "my file; rm -rf ~", that deletes a single oddly-named
file. Nothing is word-split, nothing is glob-expanded, and no shell is
involved — the string is one argument because you put it in one list
element.
You can still ask for a shell, but you have to say so, and then the quoting is your problem:
Prefer the list form. Reach for sh -c only when you actually want shell
features like pipelines or globbing.
Capturing output¶
run-process returns three values: the exit status, standard output, and
standard error.
A non-zero exit code is a value, not an error — programs signal failure that way routinely, and it is up to you what to do about it:
(define (git . args)
(call-with-values (lambda () (run-process (cons "git" args)))
(lambda (status out err)
(if (= status 0)
out
(error "git failed" args status err)))))
On POSIX a child killed by a signal reports the pair (signaled . n)
instead of an integer. Windows has no signal delivery, so a status there is
always an integer.
Failing to start the program is different from the program failing. A
missing or non-executable file raises a file error, catchable with
file-error?:
(guard (e ((file-error? e) (display "no such program\n")))
(run-process '("/opt/definitely-not-installed")))
This holds for a bare program name looked up on PATH as much as for an
explicit path, on every platform — including OpenBSD, whose libc
posix_spawn cannot report the child's exec failure and used to leave a
mistyped command indistinguishable from a program that ran and exited 127
(fixed in v0.26.0; see
kaappi#2456).
Feeding input¶
'input: writes a string (or a bytevector) to the child's stdin and then
closes it:
kaappi> (call-with-values
(lambda () (run-process '("sort") 'input: "pear\napple\n"))
list)
;=> (0 "apple\npear\n" "")
Without 'input:, the child's stdin is empty rather than inherited — a
program that reads stdin sees end-of-file immediately instead of blocking
on your terminal.
This is the part that is hard to get right by hand. Writing to a child's
stdin and then reading its stdout deadlocks as soon as either side fills a
pipe buffer: you are blocked writing, the child is blocked writing to a
stdout nobody is reading, and neither of you will move again. Reading
stdout to end-of-file before touching stderr has the same failure. Other
languages solve this with a thread per stream; run-process does it with
fibers, inside the call, so it simply does not happen:
;; child reads 1 MB, writes 1 MB to stdout and 1 MB to stderr — no deadlock
(run-process '("sh" "-c" "cat >/dev/null; yes o | head -c 1000000; yes e | head -c 1000000 1>&2")
'input: (make-string 1000000 #\i))
If you build your own loop with spawn-process, this hazard is yours to
handle — read each pipe in its own fiber.
Windows: a large input: blocks the thread while it drains
On Windows an input: larger than the 4 KiB pipe buffer is fed with a
blocking OS write rather than a parked one, so the other fibers on that
thread pause until the child has read it. A child that writes more than
a pipe buffer of output before it has finished reading its input can
therefore fill its own stdout and wedge both sides. For such filters,
write the input to a temporary file and pass its path. POSIX is
unaffected. See
kaappi#2459.
Binary output¶
run-process decodes output as UTF-8 by default. For a program that emits
something else, ask for bytes:
(call-with-values
(lambda () (run-process '("gzip" "-c") 'input: "text" 'output: 'bytevector))
(lambda (status out err) (bytevector-length out)))
Timeouts¶
'timeout: bounds the whole call in seconds. If the program has not
finished by then it is killed, along with anything it started, and a
condition is raised carrying whatever it managed to write:
(guard (e ((process-timeout? e)
(display "gave up; it had said: ")
(display (process-timeout-stdout e))
(newline)))
(run-process '("./slow-report") 'timeout: 30))
The kill is unconditional (SIGKILL), because a timeout is a bound and not
a request, and it goes to the whole process group so a child's own children
die with it. The partial output lives only on the condition — the normal
three-value return never happens.
'timeout: therefore implies 'new-group: #t, and passing 'new-group: #f
alongside it is an error rather than a silently unbounded call: a child-only
kill cannot reach a grandchild still holding the pipes, and the wait would
never end.
Environment and working directory¶
'directory: sets the child's working directory. 'env: replaces its
environment rather than adding to it, so build on
(process-environment) when you mean "the same, plus one":
(run-process '("make" "test")
'directory: "/src/project"
'env: (cons (cons "CI" "1") (process-environment)))
Replacing wholesale is occasionally what you want — a deliberately minimal environment for an untrusted program — but dropping the platform's own variables will stop many programs from starting at all, especially on Windows.
Streaming: a long-lived child¶
When the program is a server, a REPL, or anything you exchange messages
with over time, spawn-process gives you the pipes directly.
(import (scheme base) (kaappi process) (kaappi fibers))
(define p (spawn-process '("sort") 'stdin: 'pipe 'stdout: 'pipe))
(spawn (lambda () ; a fiber owns the reading side
(let loop ()
(let ((line (read-line (process-stdout p))))
(unless (eof-object? line)
(display line) (newline)
(loop))))))
(write-string "pear\napple\ncherry\n" (process-stdin p))
(close-port (process-stdin p)) ; sort produces nothing until EOF
(process-wait p)
process-stdin, process-stdout and process-stderr are ordinary ports —
every port procedure works on them. A read or write that would block parks
only the calling fiber; the rest of your program keeps running. That is the
same behavior socket ports already have, and it is why
process-wait can wait for a
five-minute child without freezing anything.
'stderr: 'stdout merges the two streams onto the stdout pipe when you do
not care which one a line came from. 'null discards a stream entirely.
To stop a child, process-kill
sends SIGTERM by default; 'group: #t reaches its descendants, which
requires having spawned it with 'new-group: #t.
(define p (spawn-process '("./watcher") 'new-group: #t))
...
(process-kill p 'group: #t)
(process-wait p)
Where the library is not available¶
(kaappi process) is absent in two places:
- The WebAssembly build, including the browser playground and tour. WASI has no process model at all.
--sandboxmode, which excludes it deliberately along with the filesystem,(kaappi ffi)and OS threads.
Portable code checks for the library rather than for a platform:
(cond-expand
((library (kaappi process))
(import (kaappi process))
(define (version-of prog)
(call-with-values (lambda () (run-process (list prog "--version")))
(lambda (status out err) (and (= status 0) out)))))
(else
(define (version-of prog) #f)))
There is no kaappi-process feature identifier — the (library ...)
requirement above is the check, and it answers correctly under --sandbox
as well. See Conformance.
Safety notes¶
- Never build a command line by string concatenation. The list form exists so that a filename, a branch name, or a user-supplied search term cannot become an argument you did not intend — or a second command.
- The child inherits only stdin, stdout and stderr. Every other file, socket and pipe Kaappi holds is closed before the program starts, so a child cannot reach your database connection or your listening socket.
- A process object belongs to the thread that spawned it. Using one from another SRFI-18 thread raises; pass results over a channel instead.
--sandboxblocks the whole library, so a sandboxed script cannot escape through a subprocess. See Security.
See also¶
- Subprocess procedures — the full reference
- Run External Programs — a worked recipe
- Concurrency — fibers, channels, and how parking works