Subprocesses, portable.
sh runs a command to completion; spawn keeps a long-lived child with piped
stdin/stdout, which is what an MCP stdio transport needs and what sh cannot
express.
Both have an OFF SWITCH: sh takes :timeout-ms, and a spawned child takes
kill!. A subprocess is the most dangerous thing most programs do, and one
that cannot be stopped is a program that cannot be stopped.
Subprocesses, portable. `sh` runs a command to completion; `spawn` keeps a long-lived child with piped stdin/stdout, which is what an MCP stdio transport needs and what `sh` cannot express. Both have an OFF SWITCH: `sh` takes `:timeout-ms`, and a spawned child takes `kill!`. A subprocess is the most dangerous thing most programs do, and one that cannot be stopped is a program that cannot be stopped.
(alive? child)True while the child is running.
True while the child is running.
(close! child)Close the child's stdin, wait for it to exit, and return the exit code.
This is the POLITE shutdown and it WAITS: a child that ignores its stdin
closing will hang here forever. When you need a guarantee rather than a
request, use kill!.
Close the child's stdin, wait for it to exit, and return the exit code. This is the POLITE shutdown and it WAITS: a child that ignores its stdin closing will hang here forever. When you need a guarantee rather than a request, use `kill!`.
(exit-code child)The status the child exited with, or nil if it has NOT BEEN SEEN to exit yet.
Read that second clause carefully, because it is not the same as "the child is running". Reaping is ASYNCHRONOUS on both hosts: a child can be dead while this still answers nil, for as long as it takes the host to notice. A caller that treats one nil as proof of life has a race, and it is the nastiest kind — it passes constantly on a fast machine and fails on a loaded one.
So a single read is never a verdict. Poll to a deadline:
(when (nil? (proc/read-line! c))
(let [code (loop [n 0]
(or (proc/exit-code c)
(when (< n 50)
(ktime/sleep! 20)
(recur (inc n)))))]
(if code
(peer-died code) ; it exited, and `code` says how
(still-quiet c)))) ; still nothing after the deadline
(koine's own process_check has always polled like this; an earlier version
of this docstring told callers to branch on a single read, which is advice
koine did not follow itself. Corrected on cljgo's prompting, 2026-08-01.)
if-let on the result is safe from the usual trap: exit status 0 is TRUTHY in
Clojure, unlike C or a shell, so a clean exit does not read as "no exit".
This is the difference between a peer that DIED and one that went QUIET, which
read-line! alone cannot tell you — it returns nil for both. Without it the
only recourse is a timeout: wait a while, then kill the child to find out
whether it was ever going to answer. That is a guess standing in for a fact,
wrong in both directions — too short kills a slow peer, too long hangs on a
dead one. Asked for by the toolnexus port, which was carrying exactly that
guess in shipped code.
A child killed by kill! HAS exited, so a status exists afterwards; it is
whatever the host recorded for a signalled process, and the two hosts do not
agree on that number. Do not read meaning into it — that is also why kill!
itself returns nil.
The status the child exited with, or nil if it has NOT BEEN SEEN to exit yet.
Read that second clause carefully, because it is not the same as "the child
is running". Reaping is ASYNCHRONOUS on both hosts: a child can be dead while
this still answers nil, for as long as it takes the host to notice. A caller
that treats one nil as proof of life has a race, and it is the nastiest kind —
it passes constantly on a fast machine and fails on a loaded one.
So a single read is never a verdict. Poll to a deadline:
(when (nil? (proc/read-line! c))
(let [code (loop [n 0]
(or (proc/exit-code c)
(when (< n 50)
(ktime/sleep! 20)
(recur (inc n)))))]
(if code
(peer-died code) ; it exited, and `code` says how
(still-quiet c)))) ; still nothing after the deadline
(koine's own `process_check` has always polled like this; an earlier version
of this docstring told callers to branch on a single read, which is advice
koine did not follow itself. Corrected on cljgo's prompting, 2026-08-01.)
`if-let` on the result is safe from the usual trap: exit status 0 is TRUTHY in
Clojure, unlike C or a shell, so a clean exit does not read as "no exit".
This is the difference between a peer that DIED and one that went QUIET, which
`read-line!` alone cannot tell you — it returns nil for both. Without it the
only recourse is a timeout: wait a while, then kill the child to find out
whether it was ever going to answer. That is a guess standing in for a fact,
wrong in both directions — too short kills a slow peer, too long hangs on a
dead one. Asked for by the toolnexus port, which was carrying exactly that
guess in shipped code.
A child killed by `kill!` HAS exited, so a status exists afterwards; it is
whatever the host recorded for a signalled process, and the two hosts do not
agree on that number. Do not read meaning into it — that is also why `kill!`
itself returns nil.(kill! child)Force-terminate the child. Returns nil — always, on both hosts.
Returns nil rather than an exit code on purpose: a killed process did not
choose one. The JVM would report 137 and Go -1, and koine does not invent
agreement between two host-specific numbers. Ask alive? if you need to know
it is gone.
This is also the way OUT of a blocked read-line!. A reader parked on a hung
peer cannot be interrupted portably, but killing the child closes its stdout,
so the parked read-line! hits EOF and returns nil. One mechanism ends a
runaway command and frees a stuck transport — asked for by the toolnexus port
as two separate things, 2026-07-31.
Force-terminate the child. Returns nil — always, on both hosts. Returns nil rather than an exit code on purpose: a killed process did not choose one. The JVM would report 137 and Go -1, and koine does not invent agreement between two host-specific numbers. Ask `alive?` if you need to know it is gone. This is also the way OUT of a blocked `read-line!`. A reader parked on a hung peer cannot be interrupted portably, but killing the child closes its stdout, so the parked `read-line!` hits EOF and returns nil. One mechanism ends a runaway command and frees a stuck transport — asked for by the toolnexus port as two separate things, 2026-07-31.
(read-line! child)Block for one line from the child's stdout, WITHOUT the terminator. nil at EOF.
Block for one line from the child's stdout, WITHOUT the terminator. nil at EOF.
(run-async! f)Run f on a background thread that must NOT keep the program alive.
NOT future on the JVM. Clojure's future pool threads are non-daemon with a
60-second keep-alive, so a program that called ONE koine.process fn sat there
for a full minute after printing its answer — no output, no CPU, nothing to
see. (shutdown-agents) fixes it, but a library cannot demand that of a
consumer, and a library must never decide when the host program may exit.
Measured 2026-07-31: 60.5s for a sh ["true"].
On cljgo the equivalent is a goroutine, which never holds up exit.
PUBLIC, and that is the point. This was private while koine used it to fix its
own 60-second hang, which quietly handed the same hang to every consumer: a
caller running its own reader loop over read-line! reaches for future,
gets the non-daemon pool, and cannot call (shutdown-agents) from library
code either. Measured by the toolnexus port on their MCP suite — 64.9s with
future, 4.3s with the pool shut down, cljgo 4.5s either way. Fixing that
only inside koine was fixing it for koine, not for anyone using koine.
Returns immediately. f takes no arguments; its value is discarded, so
communicate through an atom or a promise. Nothing waits for it — if you need
to know it finished, deliver a promise at the end of f.
Run `f` on a background thread that must NOT keep the program alive. NOT `future` on the JVM. Clojure's future pool threads are non-daemon with a 60-second keep-alive, so a program that called ONE koine.process fn sat there for a full minute after printing its answer — no output, no CPU, nothing to see. `(shutdown-agents)` fixes it, but a library cannot demand that of a consumer, and a library must never decide when the host program may exit. Measured 2026-07-31: 60.5s for a `sh ["true"]`. On cljgo the equivalent is a goroutine, which never holds up exit. PUBLIC, and that is the point. This was private while koine used it to fix its own 60-second hang, which quietly handed the same hang to every consumer: a caller running its own reader loop over `read-line!` reaches for `future`, gets the non-daemon pool, and cannot call `(shutdown-agents)` from library code either. Measured by the toolnexus port on their MCP suite — 64.9s with `future`, 4.3s with the pool shut down, cljgo 4.5s either way. Fixing that only inside koine was fixing it for koine, not for anyone using koine. Returns immediately. `f` takes no arguments; its value is discarded, so communicate through an atom or a promise. Nothing waits for it — if you need to know it finished, deliver a promise at the end of `f`.
(send-line! child s)Write s + newline to the child's stdin and flush. Returns nil.
Write `s` + newline to the child's stdin and flush. Returns nil.
(sh command)(sh command {:keys [in dir env timeout-ms]})Run command (a vector) to completion. Returns
{:out :err :exit :timed-out?}. Never throws on a non-zero exit — that is a
normal result.
opts: :in (string on stdin) :dir :env :timeout-ms
:timeout-ms force-kills the command after n milliseconds. A killed command
reports :timed-out? true and :exit nil — NOT a number. There is no exit
code when a process is killed: the JVM would report 137 and Go -1, both of
them inventions, and a caller doing (zero? (:exit r)) must not silently read
a kill as a clean run. :timed-out? is always present, so it is safe to test
on every result. :out / :err carry whatever the command managed to write
before it died, which is best-effort and NOT guaranteed to match across hosts.
Without :timeout-ms the command runs to completion, however long that is.
Asked for by the toolnexus port: an agent's bash tool needs an off switch.
Run `command` (a vector) to completion. Returns
{:out :err :exit :timed-out?}. Never throws on a non-zero exit — that is a
normal result.
opts: :in (string on stdin) :dir :env :timeout-ms
`:timeout-ms` force-kills the command after n milliseconds. A killed command
reports `:timed-out? true` and `:exit nil` — NOT a number. There is no exit
code when a process is killed: the JVM would report 137 and Go -1, both of
them inventions, and a caller doing `(zero? (:exit r))` must not silently read
a kill as a clean run. `:timed-out?` is always present, so it is safe to test
on every result. `:out` / `:err` carry whatever the command managed to write
before it died, which is best-effort and NOT guaranteed to match across hosts.
Without `:timeout-ms` the command runs to completion, however long that is.
Asked for by the toolnexus port: an agent's `bash` tool needs an off switch.(spawn command)(spawn command {:keys [dir env]})Start command (a vector) as a LONG-LIVED child with piped stdin/stdout and
return a child handle — a map of closures, see above. This is what a
line-delimited JSON-RPC transport (MCP stdio) requires; sh cannot express it.
The child's STDERR is drained from the moment it starts — see the comment
above; not draining it deadlocks the child once the pipe buffer fills. Read it
with stderr-lines.
opts: :dir :env
Start `command` (a vector) as a LONG-LIVED child with piped stdin/stdout and return a child handle — a map of closures, see above. This is what a line-delimited JSON-RPC transport (MCP stdio) requires; `sh` cannot express it. The child's STDERR is drained from the moment it starts — see the comment above; not draining it deadlocks the child once the pipe buffer fills. Read it with `stderr-lines`. opts: :dir :env
(stderr-lines child)The child's most recent stderr lines (up to 200), oldest first, as a vector.
Always available — stderr is drained from the moment the child starts, so this is a snapshot of a buffer that is being filled for you, not a read that could block. Returns [] when the child has written nothing.
The child's most recent stderr lines (up to 200), oldest first, as a vector. Always available — stderr is drained from the moment the child starts, so this is a snapshot of a buffer that is being filled for you, not a read that could block. Returns [] when the child has written nothing.
cljdoc builds & hosts documentation for Clojure/Script libraries
| Ctrl+k | Jump to recent docs |
| ← | Move to previous article |
| → | Move to next article |
| Ctrl+/ | Jump to the search field |