main/ctlsocket: fix bug 004 — non-PID-1 invocation clobbers live control socket
main/ctlsocket: fix bug 004 — non-PID-1 invocation clobbers live control socket Three defenses (all three suggested fixes): 1. Version short-circuit (-v/-V/--version) now gated on not is_pid_1(). -v prints version + exits for shell invocations, but NEVER for PID 1: the kernel passes -v (verbose) as a boot-arg to init, and exiting there would make the kernel re-exec init in a loop. 2. create_socket() connect()-probes the path first and REFUSES to unlink a socket a live server is listening on; main() exits cleanly when a non-PID-1 instance cannot own the socket (PID 1 keeps running without it). 3. Non-PID-1 supervisor mode requires an explicit --supervisor-test flag, so a bare 'zyginit ...' on a host where zyginit is already PID 1 is inert. Harnesses (run_tests.sh, test_shutdown.sh) + CLAUDE.md dev-run pass the flag. Adds integration Suite 'BUG-004: non-PID-1 socket safety'; 104/104 pass. Builds clean on Reef 0.7.7.
Author:
Chris Tusa <chris.tusa@leafscale.com>
Date:
Jul 21, 2026 14:38
Changeset:
2c9056d3e170377be27c261233a252b4ace5b3ad
Branch:
default
Diff
diff -r cfb4a678d291 -r 2c9056d3e170 CLAUDE.md --- a/CLAUDE.md Tue Jul 21 14:18:37 2026 -0500 +++ b/CLAUDE.md Tue Jul 21 14:38:45 2026 -0500 @@ -195,11 +195,19 @@ To run zyginit non-PID-1 for development (without replacing init): ```bash -ZYGINIT_CONFIG_DIR=/tmp/zygsvc /path/to/zyginit +ZYGINIT_CONFIG_DIR=/tmp/zygsvc /path/to/zyginit --supervisor-test ``` It detects non-PID-1 via `getpid() != 1` and runs in "supervisor mode" (no PID-1-specific setup, normal exit on shutdown signal). +The `--supervisor-test` flag is **required** for a non-PID-1 run +(bug 004): without it a bare `zyginit` on a host where zyginit is +already PID 1 refuses to start, so it can't clobber the live control +socket. Use a distinct `ZYGINIT_SOCKET` for dev runs too — and even +with the flag, `create_socket` refuses to unlink a socket a live +server is listening on. `zyginit -v` / `--version` print the version +and exit (only when not PID 1; the kernel passes `-v` as a verbose +boot-arg to PID 1, so that path never short-circuits). ## Source Layout diff -r cfb4a678d291 -r 2c9056d3e170 bugs/004-nonpid1-invocation-clobbers-live-control-socket.md --- a/bugs/004-nonpid1-invocation-clobbers-live-control-socket.md Tue Jul 21 14:18:37 2026 -0500 +++ b/bugs/004-nonpid1-invocation-clobbers-live-control-socket.md Tue Jul 21 14:38:45 2026 -0500 @@ -72,3 +72,34 @@ `zyginit-no-exec-init-binary` (the known "don't exec the init binary on a zyginit host — clobbers the socket, recover via `virsh destroy && virsh start`" note). + +## Resolution (2026-07-21, zyginit 0.2.7-dev) + +All three suggested fixes implemented (defense in depth): + +1. **`-v` / unknown-flag safety** (`src/main.reef`). `-v`, `-V`, and + `--version` now print the version and exit — **but only when + `not is_pid_1()`**. This is a deliberately narrower gate than the report + proposed: the Hammerhead kernel passes `-v` (verbose) as a boot-arg to PID 1 + (`boot-args=-v -m verbose`), so treating `-v` as "print version and exit" in + the PID-1 case would make init return from `main()` and the kernel would + re-exec it in a tight loop. Shell invocations (getpid() != 1) short-circuit; + PID 1 never does. +2. **Live-instance socket guard** (`src/ctlsocket.reef::create_socket`). Before + unlinking/binding, it `connect()`s the path; if a live server answers it + logs `REFUSING` and returns -1 instead of clobbering. A stale socket file + (no listener) fails to connect and is cleaned up as before. `main.reef` + additionally exits cleanly when a non-PID-1 instance cannot own the socket + (PID 1 keeps running without a control socket — losing zygctl beats halting + init). +3. **Opt-in for non-PID-1 supervisor mode** (`src/main.reef`). A non-PID-1 run + now requires `--supervisor-test`; a bare `zyginit ...` on a host where a real + zyginit owns PID 1 refuses and exits before any setup. The integration + harnesses (`run_tests.sh`, `test_shutdown.sh`) and the CLAUDE.md dev + invocation pass the flag. + +Verified by integration suite (`run_tests.sh`, Suite "BUG-004: non-PID-1 socket +safety", 104/104 passing): `zyginit -v` prints the version and creates no +socket; a bare `zyginit` refuses without the flag; and a second instance +launched against a live daemon's socket refuses to clobber it, leaving the +original reachable. diff -r cfb4a678d291 -r 2c9056d3e170 src/ctlsocket.reef --- a/src/ctlsocket.reef Tue Jul 21 14:18:37 2026 -0500 +++ b/src/ctlsocket.reef Tue Jul 21 14:38:45 2026 -0500 @@ -116,6 +116,19 @@ // Create and bind a Unix domain socket for listening. // Returns the server fd on success, -1 on error. fn create_socket(path: string): int + // Guard (bug 004): never unlink a socket that a live server is actively + // listening on. If connect() succeeds, a real zyginit already owns this + // path — refuse rather than unlink+rebind and hijack control away from the + // live instance. A stale socket file (no listener) fails to connect, so we + // fall through and clean it up as before. + let probe = unix.unix_connect(path) + if res.is_ok(probe) + let pfd = res.unwrap_ok(probe) + let _ = unix.unix_close(pfd) + println("socket: REFUSING to bind " + path + " — a live server is already listening there") + return 0 - 1 + end if + // Remove stale socket file if it exists. unlink failure is non-fatal — // the subsequent listen will fail loudly if the path is unusable. let unlink_rc = unix.unix_unlink(path) diff -r cfb4a678d291 -r 2c9056d3e170 src/main.reef --- a/src/main.reef Tue Jul 21 14:18:37 2026 -0500 +++ b/src/main.reef Tue Jul 21 14:38:45 2026 -0500 @@ -937,14 +937,33 @@ process.exit_now(ui.ui_demo(scenario)) end if - // --version / -V short-circuits before any PID-1 setup. Manual invocations - // (zyginit --version on a shell) have stdio fds; the kernel never passes - // --version when exec'ing init as PID 1 (it passes -s, -m, etc. instead). - if args.has_flag("version") or args.has_flag("V") + // Version short-circuit — ONLY when NOT PID 1. A human checking the version + // always runs from a shell (getpid() != 1). This MUST be gated on + // not is_pid_1(): when the kernel exec()s init as PID 1 it passes boot-args + // like `-v` (verbose) and `-m verbose`. Treating `-v` as "print version and + // exit" in the PID-1 case would make init return from main(), and the + // kernel would re-exec init in a tight loop. For shell invocations, + // `-v`/`-V`/`--version` all print the version and exit before any setup. + // See bugs/archive/004-... (the `-v` foot-gun). + if not is_pid_1() and (args.has_flag("version") or args.has_flag("V") or args.has_flag("v")) println("zyginit " + version.VERSION()) return end if + // Non-PID-1 supervisor runs exist ONLY for integration testing. On a + // running host a real zyginit owns PID 1; a stray `zyginit ...` from a + // shell must not spin up a second supervisor — it would bind (and clobber) + // the control socket, load services, and enter the event loop, silently + // breaking zygctl control of the live system. Require an explicit opt-in + // flag so a bare `zyginit ...` on a production host is inert. See bug 004. + if not is_pid_1() and not args.has_flag("supervisor-test") + println("zyginit: refusing to run as a non-PID-1 supervisor without --supervisor-test") + println("zyginit: a live zyginit already owns PID 1 on a running system;") + println("zyginit: a second instance would clobber " + SOCKET_PATH()) + println("zyginit: to print the version use: zyginit --version") + return + end if + // PID-1 fd setup MUST run before any println — when the kernel exec()s // init, stdin/stdout/stderr are not preopened. See setup_pid1_console // for the full explanation. @@ -1124,6 +1143,17 @@ // Unix domain socket for zygctl communication let socket_fd = ctlsocket.create_socket(SOCKET_PATH()) + // If we could not own the control socket AND we're not PID 1, a live + // zyginit already holds it — create_socket refuses to clobber a socket a + // server is actively listening on (see bug 004). A non-PID-1 instance with + // no control socket is useless and must not linger, so exit cleanly. PID 1 + // keeps running even without a control socket: losing zygctl is far better + // than halting init. + if socket_fd < 0 and not is_pid_1() + println("zyginit: control socket unavailable (a live server owns it); exiting") + return + end if + // ---- Phase 6: Start services ---- if svc_count_clamped > 0 diff -r cfb4a678d291 -r 2c9056d3e170 tests/integration/run_tests.sh --- a/tests/integration/run_tests.sh Tue Jul 21 14:18:37 2026 -0500 +++ b/tests/integration/run_tests.sh Tue Jul 21 14:38:45 2026 -0500 @@ -262,7 +262,8 @@ # ============================================================================ start_daemon() { - "$ZYGINIT" > "$DAEMON_LOG" 2>&1 & + # --supervisor-test: opt in to non-PID-1 supervisor mode (bug 004 gate). + "$ZYGINIT" --supervisor-test > "$DAEMON_LOG" 2>&1 & DAEMON_PID=$! # Wait for socket to appear local retries=0 @@ -1166,6 +1167,53 @@ assert_equals "$OUT" "zygctl $EXPECTED_VERSION" "zygctl version subcommand output" # ============================================================================ +# Test Suite: BUG-004 — non-PID-1 invocation must not clobber the live socket +# ============================================================================ + +section "BUG-004: non-PID-1 socket safety" + +rm -rf "$CONFIG_DIR" "$LOG_DIR" +mkdir -p "$CONFIG_DIR" "$ENABLED_DIR" +create_service "sleeper" "daemon" "$BIN_DIR/sleeper.sh" +enable_service "sleeper" + +# --- fix 1: `zyginit -v` prints version and exits, touching no socket --- +B4_SOCK="$TEST_DIR/bug004.sock" +rm -f "$B4_SOCK" +OUT="$(ZYGINIT_SOCKET="$B4_SOCK" "$ZYGINIT" -v 2>&1)" +assert_contains "$OUT" "zyginit $EXPECTED_VERSION" "bug004: zyginit -v prints version" +if [ ! -e "$B4_SOCK" ]; then + pass "bug004: zyginit -v created/clobbered no socket" +else + fail "bug004: zyginit -v created/clobbered no socket" "no socket" "socket present" +fi + +# --- fix 3: bare `zyginit` (no --supervisor-test) refuses and exits --- +rm -f "$B4_SOCK" +OUT="$(ZYGINIT_SOCKET="$B4_SOCK" "$ZYGINIT" 2>&1)" +assert_contains "$OUT" "refusing to run as a non-PID-1 supervisor" \ + "bug004: bare zyginit refuses without --supervisor-test" +if [ ! -e "$B4_SOCK" ]; then + pass "bug004: refused invocation created/clobbered no socket" +else + fail "bug004: refused invocation created/clobbered no socket" "no socket" "socket present" +fi + +# --- fix 2: a second instance must not clobber a live server's socket --- +start_daemon # daemon A on $SOCKET (launched with --supervisor-test) +OUT="$(zygctl status sleeper)" +assert_contains "$OUT" "sleeper" "bug004: live daemon A reachable before second instance" + +# Second instance targets the SAME socket; it must refuse and self-exit, +# leaving A's socket intact. +SECOND="$(ZYGINIT_SOCKET="$SOCKET" "$ZYGINIT" --supervisor-test 2>&1)" +assert_contains "$SECOND" "REFUSING" "bug004: second instance refuses to clobber live socket" + +OUT="$(zygctl status sleeper)" +assert_contains "$OUT" "sleeper" "bug004: daemon A still reachable after second instance refused" +stop_daemon + +# ============================================================================ # Results # ============================================================================ diff -r cfb4a678d291 -r 2c9056d3e170 tests/integration/test_shutdown.sh --- a/tests/integration/test_shutdown.sh Tue Jul 21 14:18:37 2026 -0500 +++ b/tests/integration/test_shutdown.sh Tue Jul 21 14:38:45 2026 -0500 @@ -54,7 +54,7 @@ ZYGINIT_CONFIG_DIR="$CONFIG_DIR" \ ZYGINIT_SOCKET="$TEST_DIR/zyginit.sock" \ ZYGINIT_LOG_DIR="$LOG_DIR/zyg" \ - "$ZYGINIT" > "$LOG_DIR/zyginit.out" 2>&1 & + "$ZYGINIT" --supervisor-test > "$LOG_DIR/zyginit.out" 2>&1 & ZPID=$! # Wait for socket to appear