Packages

Safe OS process execution for Elixir with NIF-based backpressure, zero zombie processes, PTY support, and cgroup isolation.

Current section

Files

Jump to
net_runner CHANGELOG.md
Raw

CHANGELOG.md

# Changelog
All notable changes to this project will be documented in this file.
This project adheres to [Semantic Versioning](https://semver.org/).
## [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 `fstat`s 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 discarded**`run(["/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
event**`retry_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 bound**`rescue`/
`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 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.
- **`: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 cap**`tail <>
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` mode**
`kick_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 callers**`Operations` 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/down**
`nif_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 cleanup**`Stream.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 truth**`Signal.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
- **Command DSL**`NetRunner.Command` for reusable command templates, with
`defcommand` for compile-time definitions. `NetRunner.run/2` and
`NetRunner.stream/2` accept 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`, 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
- `NetRunner.run/2` — run a command and collect output as `{output, exit_status}`
- `NetRunner.stream!/2` / `NetRunner.stream/2` — lazy streaming I/O with backpressure
- `NetRunner.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 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