|

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