All notable changes to this project will be documented in this file.
This project adheres to Semantic Versioning.
[1.3.0] - 2026-07-28
Performance and correctness pass driven by measurement. Two defects dominated every benchmark: all NIF I/O was routed through dirty IO schedulers, and the exit status of any fast-exiting command was silently discarded. Numbers below are medians on an Apple M1 Max (10 cores), OTP 29, default VM flags.
Fixed
- All NIF I/O moved off dirty IO schedulers (
~280xstdout throughput: 6.3 -> ~650 MiB/s;nif_is_os_pid_alive670 us -> 1.6 us per call). Every fd isO_NONBLOCKand readiness comes fromenif_select, so no call in the NIF can block — theERL_NIF_DIRTY_JOB_IO_BOUNDflag bought nothing and cost a thread handoff on every call, twice per streamed chunk. See ADR-6 indocs/decisions.md; do not reintroduce it.nif_create_fdnowfstats the fd and rejects anything that is not a FIFO, socket or character device ({:error, :unsupported_fd_type}). A regular file ignoresO_NONBLOCKand would stall a scheduler, so the previously-implicit invariant is now enforced.nif_read/nif_writereport work viaenif_consume_timeslice.
- Exit status of fast-exiting commands was discarded —
run(["/bin/echo", "hi"])took 5.1 s and returned137instead of0. The UDS is a byte stream: for a child that exits before the BEAM'srecvmsg, theSCM_RIGHTSiov byte,MSG_CHILD_STARTEDandMSG_CHILD_EXITEDcoalesce into one 11-byte read, andextract_child_started/2matched the first frame and threw the rest away. The tail is now carried inState.uds_carryand parsed byExec.parse_uds_message/1. Same command is now ~7 ms and returns0. - The UDS was never watched — nothing armed a
:socketselect, so the{:"$socket", …, :select, …}clause was dead code,MSG_CHILD_EXITEDcould only be read reactively after the shepherdPortdied, andMSG_ERRORframes were invisible. The socket is now armed with a:nowaitrecv and re-armed after each frame. The 5 s:force_exit_timeoutis demoted to a backstop and logs a warning when it fires. drain_uds_for_exit's blocking retry ladder removed — up to 5 x 500 ms of blocking:socket.recvinside the GenServer, during which it answered no calls, serviced no readiness and drained no stderr. Replaced with a single non-blocking sweep.- A partially-completed write restarted from offset 0 on every readiness
event —
retry_write_loop/4advanced through the payload internally but never wrote the remaining bytes back to the parked operation, so each:ready_outputre-sent the payload from the beginning. The child received the same bytes over and over and the write never completed. Latent before this release and only reachable at particular pipe capacities; enlarging the pipes (below) exposed it as a hang, observed as 5.8 GB written for a 100 KB payload across 707,801 writes.Operations.update_context/3now persists the remainder. arm_uds/1could spin on a zero-byte read — a peer at EOF is permanently readable, so recursing on{:ok, <<>>}would loop the GenServer forever and starve every parked caller. Only a non-empty read now earns another pass, and partial data delivered alongside a:selector an error tuple is retained instead of dropped.finish_exit/2could truncate buffered output — now that the exit status arrives as soon as the shepherd sends it, it can land while the child's output is still in the pipe. Parked readers were failed with{:error, :process_exited}, whichNetRunner.Streamtreats as a normal end of stream, silently losing data. Parked reads are now served from the pipe first; only what cannot be satisfied is failed.- Spawn latency recovered (152 ms -> ~3 ms median): the per-spawn
0700socket directory addedmkdir,chmodandrmdirfile syscalls to every spawn. The directory is now created once per VM, with the same traversal barrier. NetRunner.Daemondrain loop leaked stack without bound —rescue/catchclauses ondrain_loop/3wrapped the body in atry, taking the recursive call out of tail position and retaining a frame per chunk (~64 KB/s of stack per drain task, two tasks per Daemon). The defensive handling moved into asafe_read/2helper.Daemon.terminate/2's SIGKILL escalation was unreachable — the 5 sawait_exitgrace equalled theuse GenServershutdown budget, so the supervisor brutal-killed the Daemon first. Additionallyawait_exit/2is aGenServer.call, so exhausting the grace exited the caller and unwound past the escalation. Grace split into 3 s + 1 s with an exit-trapping wrapper.- Early-terminated streams stalled 5 s —
stream!(~w(yes)) |> Enum.take(1)waited out the full graceful-exit grace for a child that ignores stdin closure. Natural EOF and consumer-halt are now distinguished; a halt escalates immediately. :ownermonitored the wrong process — it captured whoever built the stream, so building in one process and consuming in another SIGKILLed the child mid-consumption.NetRunner.Process.set_owner/2re-registers from the consumer.append_stderr_tail/2copied and pinned more than the cap —tail <> datathenbinary_part/3copied up to 8 KiB per chunk (~129x amplification on line-buffered stderr), discarded the whole concat whenever the chunk already exceeded the cap, and returned a sub-binary pinning its ~72 KiB parent. Now slices without concatenating when possible and copies to release the parent.:ready_inputignored which fd fired — every stdout chunk also issued a wastedread(2)plusenif_selectre-arm on stderr. The select message's resource is now matched against the pipes.- Parked-caller monitors are refcounted per caller pid — a streaming
consumer parks once per chunk, so a
Process.monitor/demonitorpair per operation was a per-chunk cost.pop_by_monitor/2now reclaims all of a dead caller's operations at once. enif_monitor_processandenif_select(STOP)failures are no longer swallowed — a resource whose select relation is never dissolved is never destructed, so a silently-failed monitor meant a permanently leaked fd. Surfaced as{:error, :monitor_failed}/{:error, :select_failed}.- Shepherd:
kill_childno longer pollswaitpidwithusleep(100000)— a child dying 1 ms after SIGTERM cost up to 100 ms, twice. Now waits on the existing SIGCHLD self-pipe withpoll()against aCLOCK_MONOTONICdeadline. - Shepherd:
SIGPIPEis ignored so a write to a departed BEAM returnsEPIPEinstead of killing the shepherd and orphaning the child. The default disposition is restored in the child beforeexecvp, sinceSIG_IGNsurvives exec. - Shepherd: pipe buffers grown to 1 MiB on Linux (
F_SETPIPE_SZ, best-effort), cutting readiness round trips per MiB by ~16x.
Changed
-fvisibility=hiddenfor the NIF; onlynif_initneeds to be exported.- Removed
NetRunner.Stream.AbnormalExit, which was defined but never raised. Streams do not surface non-zero child exit statuses; the module implied otherwise.
Added
NetRunner.Process.set_owner/2— re-register the process whose death tears the OS process down. Replaces the previous monitor rather than stacking.NetRunner.Process.Exec.parse_uds_message/1— pure framing parser for the shepherd protocol, with tests for coalesced, truncated and unknown frames.- Regression tests:
test/exit_status_test.exs(coalesced-frame exit status, framing,set_owner/2semantics) andtest/teardown_test.exs(drain-task stack bound, Daemon shutdown budget, early-halted stream teardown, fd-type guard).
[1.2.2] - 2026-06-28
Bounded the stderr buffer and made Daemon stderr handling deterministic.
Added
NetRunner.Process.stderr_tail/1— returns the retained tail of consumed stderr, with a:stderr_tail_bytesoption (default 8 KB) controlling how much is kept. Useful for diagnosing why a command failed.
Fixed
- Unbounded
stderr_buffergrowth in:consumemode — stderr was drained to keep the child from blocking on a full pipe, but every chunk was retained for the life of the process. Retention is now capped at:stderr_tail_bytes; a cap of 0 drains and drops. Stats still count every byte. - Lost initial stderr chunk in
:consumemode —kick_stderr_readininit/1sent{:stderr_data, data}toself()but nohandle_info/2clause matched, so the first (and often only) chunk of stderr for fast-exiting processes was silently dropped. The missing handler now appends to the stderr buffer and drains any remainder. Daemonstderr interleaving — the Daemon now forcesstderr: :disabledon its child process so its own drain task is the sole reader, instead of racing the process's internal consumer for chunks.
[1.2.1] - 2026-06-06
Follow-up review pass: stderr API surface, UDS permissions, signal validation, and Daemon drain isolation.
Fixed
- UDS socket permissions — the socket lived directly in the
world-traversable tmp dir, so a same-host attacker who won the accept race
against the real shepherd would receive the child's pipe FDs via
SCM_RIGHTS. It now lives inside a per-spawn0700directory, reducing the threat to same-uid processes. write_loopspin on{:ok, 0}— if the kernel ever returned 0 bytes on a non-empty write, the GenServer would recurse forever. The NIF now maps a zero-byte write on a non-empty buffer to:eagainand registersenif_selectfor write readiness.nif_killsignal range — signals outside POSIX1..31are rejected in the NIF as well as inSignal.resolve, mirroringshepherd.c'sCMD_KILLvalidation and bounding the blast radius of a stray call.:stderroption validation — reject anything other than:consumeor:disabledat the spawn boundary rather than silently ignoring it.- O(1) demonitor for parked callers —
Operationsgained anop_monitorsreverse index so popping a parked operation no longer scans the monitor map. - Daemon drain isolation — drain tasks moved to
Task.Supervisor.async_nolink/2under a newNetRunner.TaskSupervisor, so a drain-task crash cannot take the Daemon down through a linked task.
[1.2.0] - 2026-04-17
Fixed
read_uds_messagerace — replaced the:peek+ full-recv pattern (which could time out if the payload arrived a moment after the opcode) with an opcode-first read flow and longer timeouts.- Exit status lost on a slow UDS — on slow CI runners (notably macOS) the
socket buffer could trail the shepherd
Port's{:exit_status, _}notification, so the real status was missed.drain_uds_for_exit/2retries the read instead of falling straight through to the forced timeout.
[1.1.2] - 2026-04-17
Focused code-review pass across the NIF, shepherd, and Elixir layers. Correctness-first: closes two real-world race/leak bugs, hardens the post-fork child window, and adds an AddressSanitizer + UBSan CI job.
Fixed
- FD leak in
nif_create_fdwhenenif_mutex_createfailed — the destructor previously gatedclose(fd)on a non-NULL lock, so a failed mutex allocation leaked the file descriptor and armed a NULL-deref in any laternif_close. The mutex result is checked and the dtor now closes the fd unconditionally. - Use-after-close race in NIF read/write vs. close/down
—
nif_read/nif_writecopiedres->fdunder the mutex and released the lock before the syscall; a concurrentnif_closeor owner-death callback could close the fd before the syscall ran, letting the read/write target a recycled fd. The mutex is now held across the syscall and the subsequentenif_selectregistration; the actualclose()is deferred to theio_resource_stopcallback so BEAM can drain pending selects before the fd is released. - Shepherd UDS command framing — the event loop parsed only
buf[0], discarding any coalesced or tail commands (e.g.CMD_CLOSE_STDINfollowed immediately byCMD_KILL). Frames are now length-dispatched per opcode with a carry-over buffer acrosspoll()iterations. - Post-fork child stdio and signal safety — replaced
fprintf/strerrorin the post-fork / pre-exec window with awrite(2)- basedchild_fail()helper (async-signal-safe). Everydup2,setsid, andTIOCSCTTYreturn is now checked; on failure the child exits 127 with a diagnostic instead of running with broken stdio. waitpidafter SIGKILL — replaced the unboundedwaitpid(child_pid, NULL, 0)with a bounded WNOHANG loop (~3 s cap) so the shepherd cannot hang on a child stuck in uninterruptible kernel sleep (D-state).- SIGCHLD reap loop — reap all pending children per SIGCHLD
(
while waitpid(-1, ..., WNOHANG) > 0) so a coalesced signal never leaks zombies. - Cgroup / UDS path hardening — validate every
snprintfreturn, reject too-long UDS paths, setFD_CLOEXECon the PTY master, treat user-requested cgroup setup failure as fatal, and replace the fixed 100 msusleepincgroup_cleanupwith a bounded pollingrmdir. Streamconsumer crash cleanup —Stream.resource'saftercallback is only run on normal termination. A consumer crash orphaned theNetRunner.ProcessGenServer and its OS child.NetRunner.Process.start/3now accepts an:owneroption that monitors the caller;NetRunner.Stream.stream/3passesself(), so a consumer crash SIGKILLs the OS process and stops the GenServer.- Watcher blocking on
Process.sleep— the 5 s sleep inhandle_info/2wedged the Watcher unresponsive (including to supervisor shutdown). Replaced withProcess.send_after/3and a new:escalate_to_sigkillhandler. - Parked-caller tracking in
Operations— callers parked on EAGAIN are nowProcess.monitor/1-ed; dead callers are pruned on:DOWNinstead of lingering in the pending map until process exit. cmd/argsvalidation — reject non-binary, empty, or NUL-containing cmd and args at the spawn boundary. Passing NUL bytes throughPort.open'sargs:is undefined on the C side.NetRunner.run/2error surface — previously pattern-matched{:ok, pid}fromProc.start, raisingMatchErrorwhen validation failed. Now returns{:error, reason}cleanly.File.rmcleanup of UDS socket — tolerate:enoent(shepherd may have unlinked), propagate other errors.Signal.resolveinteger range — integer signals outside POSIX1..31now return{:error, :unknown_signal}instead of being forwarded tokill(2).Signalsingle source of truth —Signal.resolvedelegates to the NIF for known-atom lookup instead of maintaining a duplicate allow-list that drifted from the C side.- Daemon drain resilience — drain-task crashes used to match a
catch-all
:DOWNhandler and silently stop draining; the pipe then filled until the child blocked. Narrowed to recognised refs with a warning log;drain_loopwrapped intry/rescue/catchso a reader or logger exception cannot take the daemon down through the linked Task. terminate/2explicitly closes the shepherdPortafter the UDS socket for deterministic teardown order.
Added
- AddressSanitizer + UBSan — opt-in build via
SANITIZE=1 make allormake asan. New CI job (sanitizers) rebuilds the NIF and shepherd with-fsanitize=address,undefined, preloadslibasan, and runs the fullmix test. The publish job depends on it. - Stale UDS socket sweep in
test/test_helper.exs(before and after the suite) — stops accumulation from test crashes beforecleanup_listener/2runs. - Regression tests for: NUL-byte validation in
cmdandargs,Signal.resolverange + type handling,:ownermonitor SIGKILL path, stderr-only fast-exit stats, binary-with-NUL round-trip, andNetRunner.run/NetRunner.streamreturning validation errors cleanly.
[1.1.0] - 2026-03-21
Added
- Command DSL —
NetRunner.Commandfor reusable command templates, withdefcommandfor compile-time definitions.NetRunner.run/2andNetRunner.stream/2accept a%NetRunner.Command{}in place of a[cmd | args]list.
[1.0.4] - 2026-03-01
Fixed
- Publish pipeline ran with the wrong
MIX_ENV, soex_docwas unavailable when building docs for Hex.
[1.0.1] - 2026-03-01
Fixed
- Security hardening and file-descriptor leak fixes across the NIF and shepherd.
[1.0.0] - 2026-02-26
Initial release.
Core
NetRunner.run/2— run a command and collect output as{output, exit_status}NetRunner.stream!/2/NetRunner.stream/2— lazy streaming I/O with backpressureNetRunner.Process— GenServer with full lifecycle control:start/3,read/2,write/2,close_stdin/1,kill/2,await_exit/2,os_pid/1,alive?/1
Shepherd Binary (C)
- Persistent watchdog process that stays alive for the child's lifetime
- Detects BEAM death via UDS
POLLHUP— guarantees child cleanup even underSIGKILL - FD passing via
SCM_RIGHTSover Unix domain sockets poll()event loop with self-pipe trick forSIGCHLDhandling- Process group kills:
setpgid(0,0)+kill(-pgid, sig)catches grandchildren - Configurable SIGTERM → SIGKILL escalation timeout (
--kill-timeout)
NIF I/O
enif_selectintegration with BEAM's epoll/kqueue for async I/O- All NIF functions on dirty IO schedulers
- Demand-driven backpressure via OS pipe buffers +
EAGAIN+ enif_select - Resource-based FD management with destructor/stop/down callbacks
Zombie Prevention (3 layers)
- Shepherd — detects BEAM crash via UDS POLLHUP, kills child process group
- Watcher — detects GenServer crash via
Process.monitor, kills child via NIF - NIF resource destructor — closes FDs on GC, child sees broken pipe
PTY Support
pty: trueoption for pseudo-terminal emulationopenpty()withsetsid()+TIOCSCTTYfor controlling terminalset_window_size/3viaioctl(TIOCSWINSZ)- Single bidirectional master FD, duped for independent stdin/stdout NIF resources
- Platform support:
<util.h>on macOS,<pty.h>on Linux
cgroup Support (Linux)
:cgroup_pathoption for cgroup v2 resource isolation- Creates cgroup directory, moves child to
cgroup.procs - Cleanup via
cgroup.kill+rmdiron process exit - No-op on macOS/BSD
Daemon Mode
NetRunner.Daemon— supervised long-running process for supervision trees- Auto-drains stdout/stderr to prevent pipe blocking
- Output handling:
:discard(default),:log, or customfun/1callback - Graceful shutdown: SIGTERM → 5s wait → SIGKILL
Stats
NetRunner.Process.stats/1— per-process I/O statistics- Tracks:
bytes_in,bytes_out,bytes_err,read_count,write_count,duration_ms,exit_status - Zero-cost integer counters in GenServer state
Safety
- Timeout enforcement on
run/2via:timeoutoption - Output size limits via
:max_output_sizeoption - Platform support: macOS (Darwin) and Linux