Packages

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

Current section

6 Versions

Jump to

Compare versions

16 files changed
+852 additions
-211 deletions
  @@ -4,6 +4,116 @@ All notable changes to this project will be documented in this file.
4 4
5 5 This project adheres to [Semantic Versioning](https://semver.org/).
6 6
7 + ## [Unreleased]
8 +
9 + Focused code-review pass across the NIF, shepherd, and Elixir layers.
10 + Correctness-first: closes two real-world race/leak bugs, hardens the
11 + post-fork child window, and adds an AddressSanitizer + UBSan CI job.
12 +
13 + ### Fixed
14 +
15 + - **FD leak in `nif_create_fd`** when `enif_mutex_create` failed
16 + — the destructor previously gated `close(fd)` on a non-NULL lock,
17 + so a failed mutex allocation leaked the file descriptor and armed
18 + a NULL-deref in any later `nif_close`. The mutex result is checked
19 + and the dtor now closes the fd unconditionally.
20 + - **Use-after-close race in NIF read/write vs. close/down**
21 + `nif_read`/`nif_write` copied `res->fd` under the mutex and
22 + released the lock before the syscall; a concurrent `nif_close` or
23 + owner-death callback could close the fd before the syscall ran,
24 + letting the read/write target a recycled fd. The mutex is now held
25 + across the syscall and the subsequent `enif_select` registration;
26 + the actual `close()` is deferred to the `io_resource_stop` callback
27 + so BEAM can drain pending selects before the fd is released.
28 + - **Lost initial stderr chunk in `:consume` mode**
29 + `kick_stderr_read` in `init/1` sent `{:stderr_data, data}` to
30 + `self()` but no `handle_info/2` clause matched, so the first (and
31 + often only) chunk of stderr for fast-exiting processes was silently
32 + dropped. The missing handler now appends to the stderr buffer and
33 + drains any remainder.
34 + - **`write_loop` spin on `{:ok, 0}`** — if the kernel ever returned
35 + 0 bytes on a non-empty write, the GenServer would recurse forever
36 + on the dirty scheduler. Bounded with a 1 ms sleep-retry.
37 + - **Shepherd UDS command framing** — the event loop parsed only
38 + `buf[0]`, discarding any coalesced or tail commands (e.g.
39 + `CMD_CLOSE_STDIN` followed immediately by `CMD_KILL`). Frames are
40 + now length-dispatched per opcode with a carry-over buffer across
41 + `poll()` iterations.
42 + - **Post-fork child stdio and signal safety** — replaced `fprintf` /
43 + `strerror` in the post-fork / pre-exec window with a `write(2)`-
44 + based `child_fail()` helper (async-signal-safe). Every `dup2`,
45 + `setsid`, and `TIOCSCTTY` return is now checked; on failure the
46 + child exits 127 with a diagnostic instead of running with broken
47 + stdio.
48 + - **`waitpid` after SIGKILL** — replaced the unbounded
49 + `waitpid(child_pid, NULL, 0)` with a bounded WNOHANG loop
50 + (~3 s cap) so the shepherd cannot hang on a child stuck in
51 + uninterruptible kernel sleep (D-state).
52 + - **SIGCHLD reap loop** — reap all pending children per SIGCHLD
53 + (`while waitpid(-1, ..., WNOHANG) > 0`) so a coalesced signal
54 + never leaks zombies.
55 + - **Cgroup / UDS path hardening** — validate every `snprintf` return,
56 + reject too-long UDS paths, set `FD_CLOEXEC` on the PTY master,
57 + treat user-requested cgroup setup failure as fatal, and replace
58 + the fixed 100 ms `usleep` in `cgroup_cleanup` with a bounded
59 + polling `rmdir`.
60 + - **`Stream` consumer crash cleanup**`Stream.resource`'s `after`
61 + callback is only run on normal termination. A consumer crash
62 + orphaned the `NetRunner.Process` GenServer and its OS child.
63 + `NetRunner.Process.start/3` now accepts an `:owner` option that
64 + monitors the caller; `NetRunner.Stream.stream/3` passes `self()`,
65 + so a consumer crash SIGKILLs the OS process and stops the
66 + GenServer.
67 + - **Watcher blocking on `Process.sleep`** — the 5 s sleep in
68 + `handle_info/2` wedged the Watcher unresponsive (including to
69 + supervisor shutdown). Replaced with `Process.send_after/3` and a
70 + new `:escalate_to_sigkill` handler.
71 + - **Parked-caller tracking in `Operations`** — callers parked on
72 + EAGAIN are now `Process.monitor/1`-ed; dead callers are pruned on
73 + `:DOWN` instead of lingering in the pending map until process
74 + exit.
75 + - **`read_uds_message` race** — replaced the `:peek` + full-recv
76 + pattern (which could time out if the payload arrived a moment
77 + after the opcode) with an opcode-first read flow and longer
78 + timeouts.
79 + - **`cmd` / `args` validation** — reject non-binary, empty, or
80 + NUL-containing cmd and args at the spawn boundary. Passing NUL
81 + bytes through `Port.open`'s `args:` is undefined on the C side.
82 + - **`NetRunner.run/2` error surface** — previously pattern-matched
83 + `{:ok, pid}` from `Proc.start`, raising `MatchError` when
84 + validation failed. Now returns `{:error, reason}` cleanly.
85 + - **`File.rm` cleanup of UDS socket** — tolerate `:enoent`
86 + (shepherd may have unlinked), propagate other errors.
87 + - **`Signal.resolve` integer range** — integer signals outside
88 + POSIX `1..31` now return `{:error, :unknown_signal}` instead of
89 + being forwarded to `kill(2)`.
90 + - **`Signal` single source of truth**`Signal.resolve` delegates
91 + to the NIF for known-atom lookup instead of maintaining a duplicate
92 + allow-list that drifted from the C side.
93 + - **Daemon drain resilience** — drain-task crashes used to match a
94 + catch-all `:DOWN` handler and silently stop draining; the pipe
95 + then filled until the child blocked. Narrowed to recognised refs
96 + with a warning log; `drain_loop` wrapped in `try/rescue/catch` so
97 + a reader or logger exception cannot take the daemon down through
98 + the linked Task.
99 + - **`terminate/2`** explicitly closes the shepherd `Port` after the
100 + UDS socket for deterministic teardown order.
101 +
102 + ### Added
103 +
104 + - **AddressSanitizer + UBSan** — opt-in build via `SANITIZE=1 make all`
105 + or `make asan`. New CI job (`sanitizers`) rebuilds the NIF and
106 + shepherd with `-fsanitize=address,undefined`, preloads `libasan`,
107 + and runs the full `mix test`. The publish job depends on it.
108 + - **Stale UDS socket sweep** in `test/test_helper.exs` (before and
109 + after the suite) — stops accumulation from test crashes before
110 + `cleanup_listener/2` runs.
111 + - **Regression tests** for: NUL-byte validation in `cmd` and `args`,
112 + `Signal.resolve` range + type handling, `:owner` monitor SIGKILL
113 + path, stderr-only fast-exit stats, binary-with-NUL round-trip, and
114 + `NetRunner.run` / `NetRunner.stream` returning validation errors
115 + cleanly.
116 +
7 117 ## [1.0.0] - 2026-02-26
8 118
9 119 Initial release.
  @@ -16,7 +16,21 @@ ERL_INTERFACE_LIB_DIR ?= $(shell erl -noshell -eval "io:format(\"~ts\", [code:li
16 16 UNAME_S := $(shell uname -s)
17 17
18 18 CC ?= cc
19 - CFLAGS_BASE = -O2 -Wall -Wextra -Werror -std=c99 -fstack-protector-strong -D_FORTIFY_SOURCE=2
19 +
20 + # Opt-in sanitizer build. Usage:
21 + # make clean && SANITIZE=1 make all
22 + # mix test (from Elixir — the NIF and shepherd are rebuilt with ASan/UBSan)
23 + #
24 + # Requires LD_PRELOAD of libasan at runtime on Linux when the BEAM isn't
25 + # built with sanitizers; see ci.yml for the invocation.
26 + ifeq ($(SANITIZE),1)
27 + # _FORTIFY_SOURCE is incompatible with ASan (ASan already intercepts
28 + # memcpy/etc.). Disable optimisation to -O1 and skip FORTIFY.
29 + SAN_FLAGS = -fsanitize=address,undefined -fno-omit-frame-pointer -g
30 + CFLAGS_BASE = -O1 -Wall -Wextra -Werror -std=c99 -fstack-protector-strong $(SAN_FLAGS)
31 + else
32 + CFLAGS_BASE = -O2 -Wall -Wextra -Werror -std=c99 -fstack-protector-strong -D_FORTIFY_SOURCE=2
33 + endif
20 34
21 35 ifeq ($(UNAME_S),Darwin)
22 36 # macOS needs _DARWIN_C_SOURCE for SCM_RIGHTS, CMSG_SPACE, etc.
  @@ -31,6 +45,11 @@ else
31 45 NIF_EXT = .so
32 46 endif
33 47
48 + ifeq ($(SANITIZE),1)
49 + NIF_LDFLAGS += $(SAN_FLAGS)
50 + SHEPHERD_LDFLAGS += $(SAN_FLAGS)
51 + endif
52 +
34 53 NIF_CFLAGS = $(CFLAGS) -I$(ERTS_INCLUDE_DIR) -I$(C_SRC_DIR) -fPIC
35 54
36 55 # Targets
  @@ -45,10 +64,15 @@ NIF_OBJ = $(C_SRC_DIR)/net_runner_nif.o
45 64
46 65 HEADERS = $(C_SRC_DIR)/protocol.h $(C_SRC_DIR)/utils.h
47 66
48 - .PHONY: all clean
67 + .PHONY: all clean asan
49 68
50 69 all: $(PRIV_DIR) $(SHEPHERD) $(NIF_LIB)
51 70
71 + # Convenience: force a sanitizer rebuild. Same as SANITIZE=1 make clean all.
72 + asan:
73 + $(MAKE) clean
74 + $(MAKE) SANITIZE=1 all
75 +
52 76 $(PRIV_DIR):
53 77 mkdir -p $(PRIV_DIR)
  @@ -83,6 +83,45 @@ Enum.to_list(stream)
83 83 NetRunner.run(~w(my_server), kill_timeout: 2000, timeout: 10_000)
84 84 ```
85 85
86 + ## Input Validation and Error Returns
87 +
88 + `run/2` and `stream/2` return tagged errors for bad input instead of
89 + crashing. NUL bytes inside `cmd` or `args` are rejected early (they
90 + are undefined in `argv` on the C side).
91 +
92 + ```elixir
93 + # Empty executable
94 + {:error, {:invalid_cmd, _}} = NetRunner.run([""])
95 +
96 + # NUL byte in an argument
97 + {:error, {:invalid_args, _}} = NetRunner.run(["echo", "he\0llo"])
98 +
99 + # Same behaviour for streaming
100 + {:error, {:invalid_args, _}} = NetRunner.stream(["echo", "he\0llo"])
101 +
102 + # Unknown signal atoms come back as tagged errors, not raises
103 + {:error, :unknown_signal} = NetRunner.Signal.resolve(:sigwhatever)
104 + {:error, :unknown_signal} = NetRunner.Signal.resolve(99)
105 + ```
106 +
107 + ## Working with Binary Output
108 +
109 + stdout is delivered as a BEAM binary, not a String. It is safe to pass
110 + bytes containing NUL, high-bit, or anything else through the pipeline.
111 +
112 + ```elixir
113 + # NUL bytes round-trip unchanged
114 + {out, 0} = NetRunner.run(["sh", "-c", ~S|printf 'a\0b\0c'|])
115 + byte_size(out) # => 5
116 + out == "a\0b\0c" # => true
117 +
118 + # UTF-8 boundaries straddle chunks fine — just concatenate and then
119 + # decode.
120 + "héllo\n" =
121 + NetRunner.stream!(~w(echo héllo))
122 + |> Enum.join()
123 + ```
124 +
86 125 ## Process API
87 126
88 127 For fine-grained control over the OS process lifecycle:
  @@ -164,12 +203,45 @@ Proc.await_exit(pid)
164 203 stats = Proc.stats(pid)
165 204 stats.bytes_in # => 5 (bytes written to stdin)
166 205 stats.bytes_out # => 5 (bytes read from stdout)
206 + stats.bytes_err # => 0 (bytes read from stderr, :consume mode)
167 207 stats.read_count # => 1 (number of read calls)
168 208 stats.write_count # => 1 (number of write calls)
169 209 stats.duration_ms # => 3 (wall-clock time)
170 210 stats.exit_status # => 0 (exit code)
171 211 ```
172 212
213 + ### Tying an OS process to an owner
214 +
215 + If the calling process crashes, the OS process it launched should go
216 + with it. Pass `:owner` to have the Process GenServer monitor a pid;
217 + on `:DOWN` it SIGKILLs the child and stops cleanly. `NetRunner.stream/2`
218 + does this automatically with `self()`.
219 +
220 + ```elixir
221 + # Spawn a long-lived command tied to the caller
222 + parent = self()
223 +
224 + spawn(fn ->
225 + {:ok, pid} = Proc.start("sleep", ["30"], owner: self())
226 + send(parent, {:os_pid, Proc.os_pid(pid)})
227 + exit(:boom) # caller dies → Process SIGKILLs sleep, stops itself
228 + end)
229 + ```
230 +
231 + ### Per-call kill timeout
232 +
233 + Tune the SIGTERM→SIGKILL escalation window per-process. Useful when a
234 + command has its own graceful shutdown hook you want to honour, or when
235 + you need a fast hard-kill.
236 +
237 + ```elixir
238 + # Give my_server 10s to drain on SIGTERM before SIGKILL
239 + {:ok, pid} = Proc.start("my_server", [], kill_timeout: 10_000)
240 +
241 + # Or make it effectively immediate for tests
242 + {:ok, pid} = Proc.start("sleep", ["100"], kill_timeout: 100)
243 + ```
244 +
173 245 ## PTY Mode
174 246
175 247 Run commands with a pseudo-terminal for programs that require a TTY. PTY mode is designed for **interactive and long-running programs** — shells, REPLs, curses apps.
  @@ -268,6 +340,59 @@ Isolate child processes in a cgroup v2 hierarchy for resource control:
268 340
269 341 The shepherd creates the cgroup directory, moves the child into it, and cleans up on exit (kills all processes via `cgroup.kill`, then removes the directory). No-op on macOS.
270 342
343 + ## Command DSL
344 +
345 + Bundle an executable, default args, and default options into a reusable
346 + `%NetRunner.Command{}`. Both `run/2` and `stream/2` accept it, and
347 + call-site options override the defaults.
348 +
349 + ```elixir
350 + alias NetRunner.Command
351 +
352 + # Inline construction
353 + cmd = Command.new("curl", ["-sS"], timeout: 30_000)
354 + {body, 0} = NetRunner.run(cmd, args: ["https://example.com"])
355 +
356 + # Extend at call time (args append; opts merge with runtime winning)
357 + listing = Command.new("ls", ["-la"])
358 + {out, 0} = NetRunner.run(listing, args: ["/tmp"])
359 +
360 + # `defcommand` in your own module captures a reusable template:
361 + defmodule MyCmds do
362 + use NetRunner.Command
363 +
364 + defcommand :curl, "curl", ["-sS", "--max-time", "30"]
365 + defcommand :echo, "echo"
366 + end
367 +
368 + {out, 0} = NetRunner.run(MyCmds.echo(["hi"]))
369 + {:ok, stream} = NetRunner.stream(MyCmds.curl(["https://example.com"]))
370 + ```
371 +
372 + ## Error Handling Cheatsheet
373 +
374 + ```elixir
375 + case NetRunner.run(["my_tool", arg], timeout: 5_000) do
376 + {output, 0} ->
377 + {:ok, output}
378 +
379 + {_partial, status} when status != 0 ->
380 + {:error, {:nonzero_exit, status}}
381 +
382 + {:error, :timeout} ->
383 + {:error, :took_too_long}
384 +
385 + {:error, {:max_output_exceeded, partial}} ->
386 + {:error, {:too_much_output, byte_size(partial)}}
387 +
388 + {:error, {:invalid_cmd, msg}} ->
389 + {:error, {:bad_cmd, msg}}
390 +
391 + {:error, {:invalid_args, msg}} ->
392 + {:error, {:bad_args, msg}}
393 + end
394 + ```
395 +
271 396 ## Parallel Execution
272 397
273 398 Every NetRunner process is fully independent — no shared state, no singleton bottleneck:
  @@ -38,13 +38,17 @@ static ErlNifResourceType *io_resource_type = NULL;
38 38 static void io_resource_dtor(ErlNifEnv *env, void *obj) {
39 39 (void)env;
40 40 io_resource_t *res = (io_resource_t *)obj;
41 + /* Close fd even if mutex construction failed — otherwise ENOMEM during
42 + * nif_create_fd would leak the underlying FD. */
41 43 if (res->lock) {
42 44 enif_mutex_lock(res->lock);
43 - if (!res->closed && res->fd >= 0) {
44 - close(res->fd);
45 - res->fd = -1;
46 - res->closed = 1;
47 - }
45 + }
46 + if (!res->closed && res->fd >= 0) {
47 + close(res->fd);
48 + res->fd = -1;
49 + res->closed = 1;
50 + }
51 + if (res->lock) {
48 52 enif_mutex_unlock(res->lock);
49 53 enif_mutex_destroy(res->lock);
50 54 res->lock = NULL;
  @@ -55,30 +59,36 @@ static void io_resource_stop(ErlNifEnv *env, void *obj, ErlNifEvent event,
55 59 int is_direct_call) {
56 60 (void)env;
57 61 (void)obj;
58 - (void)event;
59 62 (void)is_direct_call;
60 - /* enif_select stop callback - FD is being deselected */
63 + /* BEAM guarantees no further use of this event by the NIF is in flight
64 + * when this callback runs. Safe to close the underlying fd here. */
65 + if ((int)event >= 0) {
66 + close((int)event);
67 + }
61 68 }
62 69
63 70 static void io_resource_down(ErlNifEnv *env, void *obj, ErlNifPid *pid,
64 71 ErlNifMonitor *mon) {
65 - (void)env;
66 72 (void)pid;
67 73 (void)mon;
68 74 io_resource_t *res = (io_resource_t *)obj;
69 - /* Owner process died - close the FD */
75 + int fd_to_stop = -1;
70 76 if (res->lock) {
71 77 enif_mutex_lock(res->lock);
72 78 if (!res->closed && res->fd >= 0) {
73 - enif_select(env, (ErlNifEvent)res->fd, ERL_NIF_SELECT_STOP,
74 - obj, NULL, enif_make_atom(env, "undefined"));
75 - close(res->fd);
79 + fd_to_stop = res->fd;
76 80 res->fd = -1;
77 81 res->closed = 1;
78 82 }
79 83 res->monitor_active = 0;
80 84 enif_mutex_unlock(res->lock);
81 85 }
86 + if (fd_to_stop >= 0) {
87 + /* Hand fd off to the stop callback — it will close it after any
88 + * in-flight enif_select completes. */
89 + enif_select(env, (ErlNifEvent)fd_to_stop, ERL_NIF_SELECT_STOP,
90 + obj, NULL, enif_make_atom(env, "undefined"));
91 + }
82 92 }
83 93
84 94 static ErlNifResourceTypeInit io_resource_init = {
  @@ -167,6 +177,13 @@ static ERL_NIF_TERM nif_create_fd(ErlNifEnv *env, int argc,
167 177 res->owner = owner;
168 178 res->monitor_active = 0;
169 179
180 + if (!res->lock) {
181 + /* Mutex allocation failed — release resource (dtor will close fd) */
182 + enif_release_resource(res);
183 + return enif_make_tuple2(env, atom_error,
184 + MAKE_ATOM(env, "mutex_failed"));
185 + }
186 +
170 187 /* Monitor the owner process */
171 188 if (enif_monitor_process(env, res, &owner, &res->monitor) == 0) {
172 189 res->monitor_active = 1;
  @@ -199,42 +216,51 @@ static ERL_NIF_TERM nif_read(ErlNifEnv *env, int argc,
199 216 }
200 217 if (max_bytes > 1048576) max_bytes = 1048576; /* Cap at 1MB */
201 218
202 - enif_mutex_lock(res->lock);
203 - if (res->closed) {
204 - enif_mutex_unlock(res->lock);
205 - return enif_make_tuple2(env, atom_error, MAKE_ATOM(env, "closed"));
206 - }
207 - int fd = res->fd;
208 - enif_mutex_unlock(res->lock);
209 -
210 219 ErlNifBinary bin;
211 220 if (!enif_alloc_binary(max_bytes, &bin)) {
212 221 return enif_make_tuple2(env, atom_error, MAKE_ATOM(env, "alloc_failed"));
213 222 }
214 223
224 + /* Hold the lock across read() + enif_select so that a concurrent
225 + * nif_close / down callback cannot close the fd mid-syscall. read() is
226 + * non-blocking (O_NONBLOCK) so the lock is held only briefly. */
227 + enif_mutex_lock(res->lock);
228 + if (res->closed || res->fd < 0) {
229 + enif_mutex_unlock(res->lock);
230 + enif_release_binary(&bin);
231 + return enif_make_tuple2(env, atom_error, MAKE_ATOM(env, "closed"));
232 + }
233 + int fd = res->fd;
234 +
215 235 ssize_t n = read(fd, bin.data, bin.size);
236 + int saved_errno = errno;
237 +
216 238 if (n > 0) {
239 + enif_mutex_unlock(res->lock);
217 240 enif_realloc_binary(&bin, (size_t)n);
218 241 return enif_make_tuple2(env, atom_ok, enif_make_binary(env, &bin));
219 - } else if (n == 0) {
242 + }
243 + if (n == 0) {
244 + enif_mutex_unlock(res->lock);
220 245 enif_release_binary(&bin);
221 246 return atom_eof;
222 - } else {
223 - enif_release_binary(&bin);
224 - if (errno == EAGAIN || errno == EWOULDBLOCK) {
225 - /* Register for select notification */
226 - int sel_ret = enif_select(env, (ErlNifEvent)fd,
227 - ERL_NIF_SELECT_READ, res, NULL,
228 - atom_undefined);
229 - if (sel_ret < 0) {
230 - return enif_make_tuple2(env, atom_error,
231 - MAKE_ATOM(env, "select_failed"));
232 - }
233 - return enif_make_tuple2(env, atom_error, atom_eagain);
234 - }
235 - return enif_make_tuple2(env, atom_error,
236 - MAKE_ATOM(env, errno_to_atom(errno)));
237 247 }
248 + if (saved_errno == EAGAIN || saved_errno == EWOULDBLOCK) {
249 + int sel_ret = enif_select(env, (ErlNifEvent)fd,
250 + ERL_NIF_SELECT_READ, res, NULL,
251 + atom_undefined);
252 + enif_mutex_unlock(res->lock);
253 + enif_release_binary(&bin);
254 + if (sel_ret < 0) {
255 + return enif_make_tuple2(env, atom_error,
256 + MAKE_ATOM(env, "select_failed"));
257 + }
258 + return enif_make_tuple2(env, atom_error, atom_eagain);
259 + }
260 + enif_mutex_unlock(res->lock);
261 + enif_release_binary(&bin);
262 + return enif_make_tuple2(env, atom_error,
263 + MAKE_ATOM(env, errno_to_atom(saved_errno)));
238 264 }
239 265
240 266 /*
  @@ -261,34 +287,39 @@ static ERL_NIF_TERM nif_write(ErlNifEnv *env, int argc,
261 287 return enif_make_tuple2(env, atom_ok, enif_make_int(env, 0));
262 288 }
263 289
290 + /* Hold the lock across write() + enif_select so that a concurrent
291 + * close cannot reap the fd mid-syscall. */
264 292 enif_mutex_lock(res->lock);
265 - if (res->closed) {
293 + if (res->closed || res->fd < 0) {
266 294 enif_mutex_unlock(res->lock);
267 295 return enif_make_tuple2(env, atom_error, MAKE_ATOM(env, "closed"));
268 296 }
269 297 int fd = res->fd;
270 - enif_mutex_unlock(res->lock);
271 298
272 299 ssize_t n = write(fd, bin.data, bin.size);
300 + int saved_errno = errno;
301 +
273 302 if (n >= 0) {
303 + enif_mutex_unlock(res->lock);
274 304 return enif_make_tuple2(env, atom_ok, enif_make_int64(env, (int64_t)n));
275 - } else {
276 - if (errno == EAGAIN || errno == EWOULDBLOCK) {
277 - int sel_ret = enif_select(env, (ErlNifEvent)fd,
278 - ERL_NIF_SELECT_WRITE, res, NULL,
279 - atom_undefined);
280 - if (sel_ret < 0) {
281 - return enif_make_tuple2(env, atom_error,
282 - MAKE_ATOM(env, "select_failed"));
283 - }
284 - return enif_make_tuple2(env, atom_error, atom_eagain);
285 - }
286 - if (errno == EPIPE) {
287 - return enif_make_tuple2(env, atom_error, MAKE_ATOM(env, "epipe"));
288 - }
289 - return enif_make_tuple2(env, atom_error,
290 - MAKE_ATOM(env, errno_to_atom(errno)));
291 305 }
306 + if (saved_errno == EAGAIN || saved_errno == EWOULDBLOCK) {
307 + int sel_ret = enif_select(env, (ErlNifEvent)fd,
308 + ERL_NIF_SELECT_WRITE, res, NULL,
309 + atom_undefined);
310 + enif_mutex_unlock(res->lock);
311 + if (sel_ret < 0) {
312 + return enif_make_tuple2(env, atom_error,
313 + MAKE_ATOM(env, "select_failed"));
314 + }
315 + return enif_make_tuple2(env, atom_error, atom_eagain);
316 + }
317 + enif_mutex_unlock(res->lock);
318 + if (saved_errno == EPIPE) {
319 + return enif_make_tuple2(env, atom_error, MAKE_ATOM(env, "epipe"));
320 + }
321 + return enif_make_tuple2(env, atom_error,
322 + MAKE_ATOM(env, errno_to_atom(saved_errno)));
292 323 }
293 324
294 325 /*
  @@ -315,27 +346,19 @@ static ERL_NIF_TERM nif_close(ErlNifEnv *env, int argc,
315 346 res->closed = 1;
316 347 res->fd = -1;
317 348
318 - /* Deregister from enif_select before closing */
319 - enif_select(env, (ErlNifEvent)fd, ERL_NIF_SELECT_STOP, res, NULL,
320 - atom_undefined);
321 -
322 349 if (res->monitor_active) {
323 350 enif_demonitor_process(env, res, &res->monitor);
324 351 res->monitor_active = 0;
325 352 }
326 353
327 - /* Close FD inside critical section to prevent TOCTOU race:
328 - * a concurrent nif_read/nif_write on a dirty scheduler could copy the FD
329 - * under lock then use it after we release the lock but before close(). */
330 - int close_ret = close(fd);
331 - int close_errno = errno;
332 -
333 354 enif_mutex_unlock(res->lock);
334 355
335 - if (close_ret != 0 && close_errno != EINTR) {
336 - return enif_make_tuple2(env, atom_error,
337 - MAKE_ATOM(env, errno_to_atom(close_errno)));
338 - }
356 + /* Hand fd off to the stop callback — BEAM waits for any in-flight select
357 + * registration to drain before calling stop, which then close()s the fd.
358 + * Concurrent nif_read/nif_write serialize on res->lock; once they observe
359 + * closed==1 they early-out without touching the fd. */
360 + enif_select(env, (ErlNifEvent)fd, ERL_NIF_SELECT_STOP, res, NULL,
361 + atom_undefined);
339 362
340 363 return atom_ok;
341 364 }
  @@ -45,6 +45,28 @@
45 45 /* Self-pipe for signal handling */
46 46 static int signal_pipe[2] = {-1, -1};
47 47
48 + /*
49 + * Async-signal-safe post-fork failure path. Between fork() and exec*(),
50 + * POSIX allows only async-signal-safe functions, so we cannot call
51 + * fprintf / strerror / malloc. This helper uses write(2) on a stack
52 + * buffer only.
53 + */
54 + static void child_fail(const char *tag, const char *detail) {
55 + if (tag) {
56 + size_t i = 0;
57 + while (i < 64 && tag[i] != '\0') i++;
58 + (void)!write(STDERR_FILENO, tag, i);
59 + (void)!write(STDERR_FILENO, ": ", 2);
60 + }
61 + if (detail) {
62 + size_t i = 0;
63 + while (i < 256 && detail[i] != '\0') i++;
64 + (void)!write(STDERR_FILENO, detail, i);
65 + }
66 + (void)!write(STDERR_FILENO, "\n", 1);
67 + _exit(127);
68 + }
69 +
48 70 static void sigchld_handler(int sig) {
49 71 (void)sig;
50 72 int saved_errno = errno;
  @@ -75,7 +97,10 @@ static int send_fds(int uds_fd, int *fds, int nfds) {
75 97
76 98 size_t cmsg_space = CMSG_SPACE((size_t)nfds * sizeof(int));
77 99 char *cmsg_buf = calloc(1, cmsg_space);
78 - if (!cmsg_buf) return -1;
100 + if (!cmsg_buf) {
101 + ERROR_LOG("send_fds: calloc(%zu) failed", cmsg_space);
102 + return -1;
103 + }
79 104
80 105 struct msghdr msg = {0};
81 106 msg.msg_iov = &iov;
  @@ -85,6 +110,7 @@ static int send_fds(int uds_fd, int *fds, int nfds) {
85 110
86 111 struct cmsghdr *cmsg = CMSG_FIRSTHDR(&msg);
87 112 if (!cmsg) {
113 + ERROR_LOG("send_fds: CMSG_FIRSTHDR returned NULL");
88 114 free(cmsg_buf);
89 115 return -1;
90 116 }
  @@ -93,9 +119,18 @@ static int send_fds(int uds_fd, int *fds, int nfds) {
93 119 cmsg->cmsg_len = CMSG_LEN((size_t)nfds * sizeof(int));
94 120 memcpy(CMSG_DATA(cmsg), fds, (size_t)nfds * sizeof(int));
95 121
96 - ssize_t ret = sendmsg(uds_fd, &msg, 0);
97 - free(cmsg_buf);
98 - return ret > 0 ? 0 : -1;
122 + /* Retry on EINTR; treat anything other than a full 1-byte send as error. */
123 + for (;;) {
124 + ssize_t ret = sendmsg(uds_fd, &msg, 0);
125 + if (ret == 1) {
126 + free(cmsg_buf);
127 + return 0;
128 + }
129 + if (ret < 0 && errno == EINTR) continue;
130 + ERROR_LOG("sendmsg failed: ret=%zd errno=%s", ret, strerror(errno));
131 + free(cmsg_buf);
132 + return -1;
133 + }
99 134 }
100 135
101 136 /*
  @@ -175,12 +210,19 @@ static int cgroup_setup(pid_t child_pid) {
175 210 char full_path[512];
176 211 char procs_path[576];
177 212
178 - /* Create cgroup directory */
179 - snprintf(full_path, sizeof(full_path), "/sys/fs/cgroup/%s", cgroup_path);
213 + int n = snprintf(full_path, sizeof(full_path), "/sys/fs/cgroup/%s",
214 + cgroup_path);
215 + if (n < 0 || (size_t)n >= sizeof(full_path)) {
216 + ERROR_LOG("cgroup path too long");
217 + return -1;
218 + }
180 219 mkdir(full_path, 0755); /* ignore error if exists */
181 220
182 - /* Move child to cgroup */
183 - snprintf(procs_path, sizeof(procs_path), "%s/cgroup.procs", full_path);
221 + n = snprintf(procs_path, sizeof(procs_path), "%s/cgroup.procs", full_path);
222 + if (n < 0 || (size_t)n >= sizeof(procs_path)) {
223 + ERROR_LOG("cgroup procs path too long");
224 + return -1;
225 + }
184 226 FILE *f = fopen(procs_path, "w");
185 227 if (!f) {
186 228 ERROR_LOG("failed to open %s: %s", procs_path, strerror(errno));
  @@ -197,21 +239,26 @@ static void cgroup_cleanup(void) {
197 239 char full_path[512];
198 240 char kill_path[576];
199 241
200 - snprintf(full_path, sizeof(full_path), "/sys/fs/cgroup/%s", cgroup_path);
242 + int n = snprintf(full_path, sizeof(full_path), "/sys/fs/cgroup/%s",
243 + cgroup_path);
244 + if (n < 0 || (size_t)n >= sizeof(full_path)) return;
201 245
202 246 /* Kill all processes in the cgroup via cgroup.kill (cgroup v2) */
203 - snprintf(kill_path, sizeof(kill_path), "%s/cgroup.kill", full_path);
247 + n = snprintf(kill_path, sizeof(kill_path), "%s/cgroup.kill", full_path);
248 + if (n < 0 || (size_t)n >= sizeof(kill_path)) return;
204 249 FILE *f = fopen(kill_path, "w");
205 250 if (f) {
206 251 fprintf(f, "1\n");
207 252 fclose(f);
208 253 }
209 254
210 - /* Wait briefly for processes to die */
211 - usleep(100000);
212 -
213 - /* Remove cgroup directory */
214 - rmdir(full_path);
255 + /* Poll for rmdir success rather than a fixed sleep — the kernel needs
256 + * a moment to reap the killed processes. Bail after ~1s (10 * 100ms). */
257 + for (int i = 0; i < 10; i++) {
258 + if (rmdir(full_path) == 0) return;
259 + if (errno != EBUSY && errno != ENOTEMPTY) return; /* real error */
260 + usleep(100000);
261 + }
215 262 }
216 263 #else
217 264 static int cgroup_setup(pid_t child_pid) {
  @@ -250,17 +297,40 @@ static void kill_child(pid_t child_pid) {
250 297
251 298 /* Escalate to SIGKILL the whole process group */
252 299 kill(-child_pid, SIGKILL);
253 - waitpid(child_pid, NULL, 0);
300 +
301 + /* Bounded WNOHANG reap loop — avoid hanging forever if the child is
302 + * stuck in uninterruptible kernel sleep (D-state). After the bound
303 + * elapses we return anyway; cgroup cleanup + the kernel eventually
304 + * reap. */
305 + int sigkill_iters = 30; /* ~3s total at 100ms per iteration */
306 + for (int i = 0; i < sigkill_iters; i++) {
307 + pid_t ret = waitpid(child_pid, NULL, WNOHANG);
308 + if (ret > 0 || (ret < 0 && errno == ECHILD)) break;
309 + usleep(100000);
310 + }
254 311
255 312 /* Cleanup cgroup (kills any remaining processes, removes dir) */
256 313 cgroup_cleanup();
257 314 }
258 315
259 316 /*
260 - * Handle a command received from BEAM over UDS.
317 + * Length of a single framed command given its opcode. Returns 0 if the
318 + * opcode is unknown (in which case the parser will skip one byte).
319 + */
320 + static size_t command_length(uint8_t opcode) {
321 + switch (opcode) {
322 + case CMD_KILL: return 2;
323 + case CMD_CLOSE_STDIN: return 1;
324 + case CMD_SET_WINSIZE: return 5;
325 + default: return 0;
326 + }
327 + }
328 +
329 + /*
330 + * Handle a single command frame.
261 331 */
262 332 static void handle_command(int uds_fd, pid_t child_pid, int stdin_w,
263 - uint8_t *buf, ssize_t len) {
333 + uint8_t *buf, size_t len) {
264 334 (void)uds_fd;
265 335 if (len < 1) return;
266 336
  @@ -300,6 +370,36 @@ static void handle_command(int uds_fd, pid_t child_pid, int stdin_w,
300 370 }
301 371 }
302 372
373 + /*
374 + * Parse and dispatch all framed commands present in buf. Returns the number
375 + * of bytes consumed (may be less than len if a tail command is truncated).
376 + */
377 + static size_t handle_commands(int uds_fd, pid_t child_pid, int *stdin_w,
378 + uint8_t *buf, size_t len) {
379 + size_t off = 0;
380 + while (off < len) {
381 + size_t clen = command_length(buf[off]);
382 + if (clen == 0) {
383 + /* Unknown opcode: skip one byte to make progress rather than
384 + * stalling the parser on bad input. */
385 + DEBUG_LOG("unknown command opcode 0x%02x, skipping", buf[off]);
386 + off += 1;
387 + continue;
388 + }
389 + if (off + clen > len) {
390 + /* Partial tail — caller must carry this over to the next read. */
391 + break;
392 + }
393 + uint8_t op = buf[off];
394 + handle_command(uds_fd, child_pid, *stdin_w, &buf[off], clen);
395 + if (op == CMD_CLOSE_STDIN) {
396 + *stdin_w = -1;
397 + }
398 + off += clen;
399 + }
400 + return off;
401 + }
402 +
303 403 /*
304 404 * Main event loop using poll().
305 405 *
  @@ -312,6 +412,11 @@ static int event_loop(int uds_fd, pid_t child_pid, int stdin_w) {
312 412 int child_status = -1;
313 413 int child_exited = 0;
314 414
415 + /* Carry-over buffer for partially-framed commands across reads. Max
416 + * incoming frame is CMD_SET_WINSIZE (5 bytes); keep a little slack. */
417 + uint8_t cbuf[64];
418 + size_t cbuf_used = 0;
419 +
315 420 fds[0].fd = uds_fd;
316 421 fds[0].events = POLLIN;
317 422 fds[1].fd = signal_pipe[0];
  @@ -336,19 +441,25 @@ static int event_loop(int uds_fd, pid_t child_pid, int stdin_w) {
336 441 }
337 442
338 443 if (fds[0].revents & POLLIN) {
339 - uint8_t buf[16];
340 - ssize_t n = read(uds_fd, buf, sizeof(buf));
444 + ssize_t n = read(uds_fd, cbuf + cbuf_used, sizeof(cbuf) - cbuf_used);
341 445 if (n > 0) {
342 - handle_command(uds_fd, child_pid, stdin_w, buf, n);
343 - /* If CMD_CLOSE_STDIN was handled, mark stdin as closed */
344 - if (buf[0] == CMD_CLOSE_STDIN) {
345 - stdin_w = -1;
446 + cbuf_used += (size_t)n;
447 + size_t consumed = handle_commands(uds_fd, child_pid, &stdin_w,
448 + cbuf, cbuf_used);
449 + if (consumed > 0 && consumed < cbuf_used) {
450 + memmove(cbuf, cbuf + consumed, cbuf_used - consumed);
346 451 }
452 + cbuf_used -= consumed;
347 453 } else if (n == 0) {
348 454 /* BEAM closed the socket */
349 455 DEBUG_LOG("BEAM closed UDS, killing child %d", child_pid);
350 456 kill_child(child_pid);
351 457 return -1;
458 + } else if (errno != EAGAIN && errno != EWOULDBLOCK &&
459 + errno != EINTR) {
460 + ERROR_LOG("read(uds) failed: %s", strerror(errno));
461 + kill_child(child_pid);
462 + return -1;
352 463 }
353 464 }
354 465
  @@ -358,10 +469,16 @@ static int event_loop(int uds_fd, pid_t child_pid, int stdin_w) {
358 469 char drain[64];
359 470 while (read(signal_pipe[0], drain, sizeof(drain)) > 0) {}
360 471
361 - /* Reap child */
472 + /* Reap any and all children that have exited. SIGCHLD is
473 + * coalesced by the kernel — multiple pending exits can deliver
474 + * as a single SIGCHLD. Loop until no more reapable children
475 + * remain. We only flip child_exited when the managed child is
476 + * reaped; other reapees (should be none today, future-proof)
477 + * are still cleaned up. */
362 478 int status;
363 - pid_t ret_pid = waitpid(child_pid, &status, WNOHANG);
364 - if (ret_pid > 0) {
479 + pid_t ret_pid;
480 + while ((ret_pid = waitpid(-1, &status, WNOHANG)) > 0) {
481 + if (ret_pid != child_pid) continue;
365 482 child_exited = 1;
366 483 if (WIFEXITED(status)) {
367 484 child_status = WEXITSTATUS(status);
  @@ -468,6 +585,12 @@ int main(int argc, char *argv[]) {
468 585 struct sockaddr_un addr;
469 586 memset(&addr, 0, sizeof(addr));
470 587 addr.sun_family = AF_UNIX;
588 + if (strlen(uds_path) >= sizeof(addr.sun_path)) {
589 + fprintf(stderr, "error: uds_path too long (max %zu bytes)\n",
590 + sizeof(addr.sun_path) - 1);
591 + close(uds_fd);
592 + return 1;
593 + }
471 594 strncpy(addr.sun_path, uds_path, sizeof(addr.sun_path) - 1);
472 595
473 596 if (connect(uds_fd, (struct sockaddr *)&addr, sizeof(addr)) != 0) {
  @@ -506,27 +629,37 @@ int main(int argc, char *argv[]) {
506 629 close(signal_pipe[1]);
507 630 close(master_fd);
508 631
509 - /* Create new session and set controlling terminal */
510 - setsid();
511 - ioctl(slave_fd, TIOCSCTTY, 0);
632 + /* Create new session (required before acquiring controlling tty) */
633 + if (setsid() == (pid_t)-1) child_fail("setsid", NULL);
634 + if (ioctl(slave_fd, TIOCSCTTY, 0) != 0) child_fail("TIOCSCTTY", NULL);
512 635
513 - dup2(slave_fd, STDIN_FILENO);
514 - dup2(slave_fd, STDOUT_FILENO);
515 - dup2(slave_fd, STDERR_FILENO);
636 + if (dup2(slave_fd, STDIN_FILENO) != STDIN_FILENO ||
637 + dup2(slave_fd, STDOUT_FILENO) != STDOUT_FILENO ||
638 + dup2(slave_fd, STDERR_FILENO) != STDERR_FILENO) {
639 + child_fail("dup2", NULL);
640 + }
516 641 if (slave_fd > STDERR_FILENO) close(slave_fd);
517 642
518 643 setpgid(0, 0);
519 644 execvp(cmd, cmd_args);
520 - fprintf(stderr, "execvp failed: %s: %s\n", cmd, strerror(errno));
521 - _exit(127);
645 + child_fail("execvp", cmd);
522 646 }
523 647
524 648 /* === Shepherd (PTY) === */
525 649 close(slave_fd);
526 650 pty_master_fd = master_fd;
651 + set_cloexec(master_fd);
527 652
528 - /* Move child to cgroup (Linux only, no-op elsewhere) */
529 - cgroup_setup(child_pid);
653 + /* Move child to cgroup (Linux only, no-op elsewhere). If the user
654 + * requested a cgroup path and setup failed, isolation is not
655 + * available — treat as fatal. */
656 + if (cgroup_setup(child_pid) != 0) {
657 + send_error(uds_fd, "cgroup setup failed");
658 + kill_child(child_pid);
659 + close(master_fd);
660 + close(uds_fd);
661 + return 1;
662 + }
530 663
531 664 /* Send single master FD to BEAM (used for both read and write) */
532 665 int fds_to_send[1] = {master_fd};
  @@ -611,9 +744,11 @@ int main(int argc, char *argv[]) {
611 744 close(stdout_pipe[0]);
612 745 close(stderr_pipe[0]);
613 746
614 - dup2(stdin_pipe[0], STDIN_FILENO);
615 - dup2(stdout_pipe[1], STDOUT_FILENO);
616 - dup2(stderr_pipe[1], STDERR_FILENO);
747 + if (dup2(stdin_pipe[0], STDIN_FILENO) != STDIN_FILENO ||
748 + dup2(stdout_pipe[1], STDOUT_FILENO) != STDOUT_FILENO ||
749 + dup2(stderr_pipe[1], STDERR_FILENO) != STDERR_FILENO) {
750 + child_fail("dup2", NULL);
751 + }
617 752
618 753 close(stdin_pipe[0]);
619 754 close(stdout_pipe[1]);
  @@ -621,8 +756,7 @@ int main(int argc, char *argv[]) {
621 756
622 757 setpgid(0, 0);
623 758 execvp(cmd, cmd_args);
624 - fprintf(stderr, "execvp failed: %s: %s\n", cmd, strerror(errno));
625 - _exit(127);
759 + child_fail("execvp", cmd);
626 760 }
627 761
628 762 /* === Shepherd (pipe) === */
  @@ -630,8 +764,18 @@ int main(int argc, char *argv[]) {
630 764 close(stdout_pipe[1]);
631 765 close(stderr_pipe[1]);
632 766
633 - /* Move child to cgroup (Linux only, no-op elsewhere) */
634 - cgroup_setup(child_pid);
767 + /* Move child to cgroup (Linux only, no-op elsewhere). If the user
768 + * requested a cgroup path and setup failed, isolation is not
769 + * available — treat as fatal. */
770 + if (cgroup_setup(child_pid) != 0) {
771 + send_error(uds_fd, "cgroup setup failed");
772 + kill_child(child_pid);
773 + close(stdin_pipe[1]);
774 + close(stdout_pipe[0]);
775 + close(stderr_pipe[0]);
776 + close(uds_fd);
777 + return 1;
778 + }
635 779
636 780 int fds_to_send[3] = {stdin_pipe[1], stdout_pipe[0], stderr_pipe[0]};
637 781 if (send_fds(uds_fd, fds_to_send, 3) != 0) {
Loading more files…