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 (~280x stdout throughput: 6.3 -> ~650 MiB/s; nif_is_os_pid_alive 670 us -> 1.6 us per call). Every fd is O_NONBLOCK and readiness comes from enif_select, so no call in the NIF can block — the ERL_NIF_DIRTY_JOB_IO_BOUND flag bought nothing and cost a thread handoff on every call, twice per streamed chunk. See ADR-6 in docs/decisions.md; do not reintroduce it.
    • nif_create_fd now fstats the fd and rejects anything that is not a FIFO, socket or character device ({:error, :unsupported_fd_type}). A regular file ignores O_NONBLOCK and would stall a scheduler, so the previously-implicit invariant is now enforced.
    • nif_read/nif_write report work via enif_consume_timeslice.
  • Exit status of fast-exiting commands was discardedrun(["/bin/echo", "hi"]) took 5.1 s and returned 137 instead of 0. The UDS is a byte stream: for a child that exits before the BEAM's recvmsg, the SCM_RIGHTS iov byte, MSG_CHILD_STARTED and MSG_CHILD_EXITED coalesce into one 11-byte read, and extract_child_started/2 matched the first frame and threw the rest away. The tail is now carried in State.uds_carry and parsed by Exec.parse_uds_message/1. Same command is now ~7 ms and returns 0.
  • The UDS was never watched — nothing armed a :socket select, so the {:"$socket", …, :select, …} clause was dead code, MSG_CHILD_EXITED could only be read reactively after the shepherd Port died, and MSG_ERROR frames were invisible. The socket is now armed with a :nowait recv and re-armed after each frame. The 5 s :force_exit_timeout is 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.recv inside 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 eventretry_write_loop/4 advanced through the payload internally but never wrote the remaining bytes back to the parked operation, so each :ready_output re-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/3 now persists the remainder.
  • arm_uds/1 could 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 :select or an error tuple is retained instead of dropped.
  • finish_exit/2 could 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}, which NetRunner.Stream treats 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 0700 socket directory added mkdir, chmod and rmdir file syscalls to every spawn. The directory is now created once per VM, with the same traversal barrier.
  • NetRunner.Daemon drain loop leaked stack without boundrescue/ catch clauses on drain_loop/3 wrapped the body in a try, 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 a safe_read/2 helper.
  • Daemon.terminate/2's SIGKILL escalation was unreachable — the 5 s await_exit grace equalled the use GenServer shutdown budget, so the supervisor brutal-killed the Daemon first. Additionally await_exit/2 is a GenServer.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 sstream!(~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.
  • :owner monitored 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/2 re-registers from the consumer.
  • append_stderr_tail/2 copied and pinned more than the captail <> data then binary_part/3 copied 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_input ignored which fd fired — every stdout chunk also issued a wasted read(2) plus enif_select re-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/demonitor pair per operation was a per-chunk cost. pop_by_monitor/2 now reclaims all of a dead caller's operations at once.
  • enif_monitor_process and enif_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_child no longer polls waitpid with usleep(100000) — a child dying 1 ms after SIGTERM cost up to 100 ms, twice. Now waits on the existing SIGCHLD self-pipe with poll() against a CLOCK_MONOTONIC deadline.
  • Shepherd: SIGPIPE is ignored so a write to a departed BEAM returns EPIPE instead of killing the shepherd and orphaning the child. The default disposition is restored in the child before execvp, since SIG_IGN survives 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=hidden for the NIF; only nif_init needs 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/2 semantics) and test/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_bytes option (default 8 KB) controlling how much is kept. Useful for diagnosing why a command failed.

Fixed

  • Unbounded stderr_buffer growth in :consume mode — 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 :consume modekick_stderr_read in init/1 sent {:stderr_data, data} to self() but no handle_info/2 clause 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.
  • Daemon stderr interleaving — the Daemon now forces stderr: :disabled on 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-spawn 0700 directory, reducing the threat to same-uid processes.
  • write_loop spin 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 :eagain and registers enif_select for write readiness.
  • nif_kill signal range — signals outside POSIX 1..31 are rejected in the NIF as well as in Signal.resolve, mirroring shepherd.c's CMD_KILL validation and bounding the blast radius of a stray call.
  • :stderr option validation — reject anything other than :consume or :disabled at the spawn boundary rather than silently ignoring it.
  • O(1) demonitor for parked callersOperations gained an op_monitors reverse index so popping a parked operation no longer scans the monitor map.
  • Daemon drain isolation — drain tasks moved to Task.Supervisor.async_nolink/2 under a new NetRunner.TaskSupervisor, so a drain-task crash cannot take the Daemon down through a linked task.

[1.2.0] - 2026-04-17

Fixed

  • read_uds_message race — 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/2 retries 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_fd when enif_mutex_create failed — the destructor previously gated close(fd) on a non-NULL lock, so a failed mutex allocation leaked the file descriptor and armed a NULL-deref in any later nif_close. The mutex result is checked and the dtor now closes the fd unconditionally.
  • Use-after-close race in NIF read/write vs. close/downnif_read/nif_write copied res->fd under the mutex and released the lock before the syscall; a concurrent nif_close or 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 subsequent enif_select registration; the actual close() is deferred to the io_resource_stop callback 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_STDIN followed immediately by CMD_KILL). Frames are now length-dispatched per opcode with a carry-over buffer across poll() iterations.
  • Post-fork child stdio and signal safety — replaced fprintf / strerror in the post-fork / pre-exec window with a write(2)- based child_fail() helper (async-signal-safe). Every dup2, setsid, and TIOCSCTTY return is now checked; on failure the child exits 127 with a diagnostic instead of running with broken stdio.
  • waitpid after SIGKILL — replaced the unbounded waitpid(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 snprintf return, reject too-long UDS paths, set FD_CLOEXEC on the PTY master, treat user-requested cgroup setup failure as fatal, and replace the fixed 100 ms usleep in cgroup_cleanup with a bounded polling rmdir.
  • Stream consumer crash cleanupStream.resource's after callback is only run on normal termination. A consumer crash orphaned the NetRunner.Process GenServer and its OS child. NetRunner.Process.start/3 now accepts an :owner option that monitors the caller; NetRunner.Stream.stream/3 passes self(), so a consumer crash SIGKILLs the OS process and stops the GenServer.
  • Watcher blocking on Process.sleep — the 5 s sleep in handle_info/2 wedged the Watcher unresponsive (including to supervisor shutdown). Replaced with Process.send_after/3 and a new :escalate_to_sigkill handler.
  • Parked-caller tracking in Operations — callers parked on EAGAIN are now Process.monitor/1-ed; dead callers are pruned on :DOWN instead of lingering in the pending map until process exit.
  • cmd / args validation — reject non-binary, empty, or NUL-containing cmd and args at the spawn boundary. Passing NUL bytes through Port.open's args: is undefined on the C side.
  • NetRunner.run/2 error surface — previously pattern-matched {:ok, pid} from Proc.start, raising MatchError when validation failed. Now returns {:error, reason} cleanly.
  • File.rm cleanup of UDS socket — tolerate :enoent (shepherd may have unlinked), propagate other errors.
  • Signal.resolve integer range — integer signals outside POSIX 1..31 now return {:error, :unknown_signal} instead of being forwarded to kill(2).
  • Signal single source of truthSignal.resolve delegates 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 :DOWN handler and silently stop draining; the pipe then filled until the child blocked. Narrowed to recognised refs with a warning log; drain_loop wrapped in try/rescue/catch so a reader or logger exception cannot take the daemon down through the linked Task.
  • terminate/2 explicitly closes the shepherd Port after the UDS socket for deterministic teardown order.

Added

  • AddressSanitizer + UBSan — opt-in build via SANITIZE=1 make all or make asan. New CI job (sanitizers) rebuilds the NIF and shepherd with -fsanitize=address,undefined, preloads libasan, and runs the full mix 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 before cleanup_listener/2 runs.
  • Regression tests for: NUL-byte validation in cmd and args, Signal.resolve range + type handling, :owner monitor SIGKILL path, stderr-only fast-exit stats, binary-with-NUL round-trip, and NetRunner.run / NetRunner.stream returning validation errors cleanly.

[1.1.0] - 2026-03-21

Added

[1.0.4] - 2026-03-01

Fixed

  • Publish pipeline ran with the wrong MIX_ENV, so ex_doc was 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

Shepherd Binary (C)

  • Persistent watchdog process that stays alive for the child's lifetime
  • Detects BEAM death via UDS POLLHUP — guarantees child cleanup even under SIGKILL
  • FD passing via SCM_RIGHTS over Unix domain sockets
  • poll() event loop with self-pipe trick for SIGCHLD handling
  • Process group kills: setpgid(0,0) + kill(-pgid, sig) catches grandchildren
  • Configurable SIGTERM → SIGKILL escalation timeout (--kill-timeout)

NIF I/O

  • enif_select integration 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: true option for pseudo-terminal emulation
  • openpty() with setsid() + TIOCSCTTY for controlling terminal
  • set_window_size/3 via ioctl(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_path option for cgroup v2 resource isolation
  • Creates cgroup directory, moves child to cgroup.procs
  • Cleanup via cgroup.kill + rmdir on 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 custom fun/1 callback
  • 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/2 via :timeout option
  • Output size limits via :max_output_size option
  • Platform support: macOS (Darwin) and Linux