/****************************************************************************** __ ____ __ / / ___ ____ _/ __/_____________ _/ /__ / / / _ \/ __ `/ /_/ ___/ ___/ __ `/ / _ \ / /___/ __/ /_/ / __(__ ) /__/ /_/ / / __/ /_____/\___/\__,_/_/ /____/\___/\__,_/_/\___/ (C)opyright 2025-2026, Leafscale, LLC - https://www.leafscale.com Project: zyginit Filename: main.reef Authors: Chris Tusa License: Description: Main entry point — event loop for zyginit init daemon ******************************************************************************/ module zyginit import config import depgraph import contract import supervisor import socket import shutdown import replace import ui import io.dir import sys.process as process import sys.signal as signal import sys.poll as poll import sys.fd as fd import time.time as time import time.clock as clock import sys.env as env import sys.args as args import core.str import version // FFI: helpers.c — push ldterm + ttcompat onto a fd's STREAMS stack. // On /dev/console without these modules, \n is LF only (no CR), so // userspace println output is column-shifted relative to kernel writes. extern "C" fn zyginit_push_ldterm(fd: int): int // FFI: write boot/runlevel utmpx records so who -b and who -r report // correctly. Standard illumos init does these — without them uptime // shows stale data and `who -r` returns nothing useful. extern "C" fn zyginit_write_boot_utmpx(): int extern "C" fn zyginit_write_runlvl_utmpx(level_char: int): int // FFI: run a shell command synchronously via libc system(). Used in // the shutdown path to force `zpool sync` + `umountall -l` so ZFS // metadata (esp. our socket-file unlink) is durably committed to // disk before the child process triggers uadmin's reboot. extern "C" fn zyginit_run_cmd(cmd: string): int // FFI: fsync a path's parent directory (or the path itself if it is a // directory). Used to durably flush directory-entry changes (unlink, // rename) without needing a writeable fd on the target path. extern "C" fn zyginit_fsync_path(path: string): int // FFI: unlink(2) wrapper. Returns 0 on success, -1 on error. extern "C" fn zyginit_unlink(path: string): int // ============================================================================ // Constants // ============================================================================ fn SOCKET_PATH(): string return env.get_env_or("ZYGINIT_SOCKET", "/var/run/zyginit.sock") end SOCKET_PATH fn CONFIG_DIR(): string return env.get_env_or("ZYGINIT_CONFIG_DIR", "/etc/zyginit") end CONFIG_DIR fn MAX_SERVICES(): int return 128 end MAX_SERVICES // Grace period (ms) between the last boot tier completing and painting the // final boot card. Daemons that fork successfully (entering RUNNING) but // die immediately produce a contract-empty event that may arrive 10-100ms // after the tier settle loop declares them RUNNING. Without this window, // ui_boot_complete would see "0 failed" while the failure log appears // moments later. 1000ms is enough headroom for the fastest-failing daemons // observed in production (acpihpd, ~30ms post-fork) with a comfortable // safety margin. Adjust here if needed. fn BOOT_SETTLE_MS(): int return 1000 end BOOT_SETTLE_MS // Returns true when running as PID 1 (i.e., actual init). When not PID 1, // the integration-test path: SIGTERM → stop services → normal exit // (no uadmin call). When PID 1, shutdown must call uadmin or the kernel // panics. fn is_pid_1(): bool return process.getpid() == 1 end is_pid_1 // When the kernel exec()s init, fd 0/1/2 are NOT pre-opened — there's no // stdin/stdout/stderr inherited from a shell. Without this fixup, every // println() silently drops, and pipe()/socket() syscalls allocate the // lowest-available fds (0, 1, 2), corrupting the implicit assumption // elsewhere in the code that fd 0/1/2 == stdin/stdout/stderr. That's // what bricked our first PID-1 boot attempt: setup_signal_pipe()'s // `if pipe_fds[0] <= 0` check tripped on a legitimate fd 0, returned // false, main() returned early via the FATAL path, kernel re-exec'd in // a tight loop. // // Open /dev/console RDWR, dup it onto 0/1/2, close the original. // Returns true on success, false if /dev/console couldn't be opened. fn setup_pid1_console(): bool // O_NOCTTY (0x800 on Hammerhead) — open /dev/console for read/write // BUT do not claim it as zyginit's controlling terminal. Without // this, the console-login service's ttymon (which runs in a new // session via setsid) cannot claim /dev/console as its own // controlling tty, exits 1 silently, and the local-console login // restart-loops into MAINTENANCE. let cfd = fd.fd_open("/dev/console", fd.O_RDWR() + 0x800, 0) if cfd < 0 return false end if // Push ldterm + ttcompat onto the STREAMS stack. Standard illumos // boot has /sbin/autopush -f /etc/iu.ap run via inittab :sysinit: // before svc.startd opens /dev/console; that auto-pushes these // modules per the wc-driver entry. zyginit is PID 1, so there's // no userland path to autopush before we open /dev/console — push // explicitly. Without this, \n is LF-only and println output is // column-shifted vs kernel cmn_err writes (visible alignment mess // during early boot before kernel device init quiets down). let _push = zyginit_push_ldterm(cfd) let _0 = fd.fd_dup2(cfd, 0) let _1 = fd.fd_dup2(cfd, 1) let _2 = fd.fd_dup2(cfd, 2) if cfd > 2 let _ = fd.fd_close(cfd) end if return true end setup_pid1_console // ============================================================================ // Signal self-pipe // ============================================================================ // Signal self-pipe fds — written to by signal handler polling, // read by main event loop to wake up from poll(). mut g_signal_pipe_read: int = 0 - 1 mut g_signal_pipe_write: int = 0 - 1 // Requested shutdown type (set by signal handler or socket command). // SHUT_NONE = no shutdown in progress. mut g_shutdown_type: int = 0 // = shutdown.SHUT_NONE() — but can't call fn in init // Human-readable shutdown reason for ui_shutdown_start ("halt", "reboot", "poweroff"). // Set when g_shutdown_type is set. mut g_shutdown_reason: string = "halt" // Current runlevel — 0 = MULTI (default), 1 = SINGLE. // Initialized at boot from kernel boot args (-s for single-user) and // changed at runtime by runlevel-transition socket commands. Services // with [runlevel] mode = "always" run in both; "single"-only services // run only when g_runlevel == SINGLE; "multi"-only services run only // when g_runlevel == MULTI. mut g_runlevel: int = 0 // 0 = MULTI (matches config.RUNLEVEL_MULTI()) // Monotonic timestamp (ms) when the boot sequence began — used to compute // total boot elapsed time for ui_boot_complete. mut g_boot_start_ms: int = 0 // PID-1 process start time, used as the boot-id sentinel for live-replace. // Read from /proc/1/stat (Linux) or /proc/1/psinfo (illumos) at startup. // Survives execve (same proc_t) but changes on real reboot (new PID 1). // state.toml is only accepted if its boot_time field matches this value, // ensuring a stale state file from a previous boot is rejected. // Initialized to -1; set during main() before recover_state. mut g_boot_time: int = 0 - 1 fn RUNLEVEL_MULTI(): int return 0 end RUNLEVEL_MULTI fn RUNLEVEL_SINGLE(): int return 1 end RUNLEVEL_SINGLE // Returns true if a service should be in the active set for the given // runlevel. "always" services run in any runlevel; "single"-mode runs // only when target == SINGLE; "multi"-mode runs only when target == MULTI. fn service_in_runlevel(svc_def: config.ServiceDef, target_runlevel: int): bool let mode = config.svc_runlevel_mode(svc_def) if mode == config.RUNLEVEL_ALWAYS() return true elif mode == config.RUNLEVEL_SINGLE() return target_runlevel == RUNLEVEL_SINGLE() else // mode == config.RUNLEVEL_MULTI() return target_runlevel == RUNLEVEL_MULTI() end if end service_in_runlevel // Parse boot args for runlevel selection. Returns RUNLEVEL_SINGLE if // the kernel passed -s, otherwise RUNLEVEL_MULTI. Hammerhead's // /boot/loader.conf may set boot-args="-v -m verbose -s" to request // single-user; the kernel forwards these to /sbin/init's argv. fn parse_boot_runlevel(): int if args.has_flag("s") return RUNLEVEL_SINGLE() end if return RUNLEVEL_MULTI() end parse_boot_runlevel // Set up the self-pipe for signal notification. // Returns true on success. fn setup_signal_pipe(): bool let pipe_fds = fd.fd_pipe() if pipe_fds[0] <= 0 println("zyginit: failed to create signal pipe") return false end if g_signal_pipe_read = pipe_fds[0] g_signal_pipe_write = pipe_fds[1] // Make both ends non-blocking fd.fd_set_nonblocking(g_signal_pipe_read, true) fd.fd_set_nonblocking(g_signal_pipe_write, true) return true end setup_signal_pipe // Write a byte to the signal pipe to wake up poll(). proc signal_pipe_notify() fd.fd_write(g_signal_pipe_write, "1") end signal_pipe_notify // Drain the signal pipe (read all pending bytes). proc signal_pipe_drain() fd.fd_read(g_signal_pipe_read, 64) end signal_pipe_drain // ============================================================================ // Service startup by tier // ============================================================================ // Start all services in boot order (tier by tier). // Services within a tier are started in sequence; the tier as a whole must // settle (oneshots reach STOPPED, daemons reach RUNNING) before we move on // to tier N+1. Without this wait, downstream services start before their // upstream `requires` are satisfied — e.g. rpcbind in tier 4 looking for // the loopback datalink before network in tier 3 has created it. There's // a per-tier deadline (60 seconds = 120 × 500ms) so a single broken // oneshot doesn't wedge the boot forever; we warn and proceed if the // tier hasn't settled by then. proc start_services_by_tier(table: supervisor.ServiceTable, tiers: [string], tier_counts: [int], num_tiers: int) mut tier_offset = 0 mut tier = 0 while tier < num_tiers let tier_size = tier_counts[tier] if ui.ui_mode() == ui.MODE_PLAIN() println("zyginit: starting tier " + int_to_str(tier) + " (" + int_to_str(tier_size) + " services)") end if ui.ui_tier_start(tier) // Phase A: kick off all services in this tier whose [runlevel] mode // matches the current g_runlevel and which aren't already running. // Skipping the running check makes this safe to reuse from // transition_runlevel (only newly-active services get started). mut j = 0 while j < tier_size let name = tiers[tier_offset + j] let idx = supervisor.find_service(table, name) if idx >= 0 let rt = supervisor.get_runtime(table, idx) let def = supervisor.rt_def(rt) let state = supervisor.rt_state(rt) if service_in_runlevel(def, g_runlevel) and state != supervisor.STATE_RUNNING() supervisor.start_service(table, idx) elif not service_in_runlevel(def, g_runlevel) and state == supervisor.STATE_WAITING() supervisor.mark_runlevel_filtered(table, idx) end if else println("zyginit: warning: service in boot order not in table: " + name) end if j = j + 1 end while // Phase B: wait for the tier to settle. Acceptable terminal states: // - oneshot: STATE_STOPPED (success or failure both count as // "settled" — we proceed regardless to surface the // cascade rather than wedging boot) // - daemon: STATE_RUNNING (or terminal MAINTENANCE/FAILED) // Transitional states (STATE_STARTING, etc.) keep us waiting. mut waiting = 1 mut waits = 0 while waiting > 0 and waits < 120 waiting = 0 mut k = 0 while k < tier_size let name2 = tiers[tier_offset + k] let idx2 = supervisor.find_service(table, name2) if idx2 >= 0 let rt = supervisor.get_runtime(table, idx2) let state = supervisor.rt_state(rt) let def2 = supervisor.rt_def(rt) let svc_type = config.svc_type(def2) // Skip services not in active runlevel — they were // never started so we shouldn't wait for them. if not service_in_runlevel(def2, g_runlevel) k = k + 1 continue end if mut settled = false if svc_type == config.SERVICE_TYPE_ONESHOT() if state == supervisor.STATE_STOPPED() or state == supervisor.STATE_FAILED() or state == supervisor.STATE_MAINTENANCE() settled = true end if else // Daemon (or transient): RUNNING is the goal; // MAINTENANCE/FAILED also counts as settled so we // surface the failure rather than block boot. if state == supervisor.STATE_RUNNING() or state == supervisor.STATE_FAILED() or state == supervisor.STATE_MAINTENANCE() settled = true end if end if if not settled waiting = waiting + 1 end if end if k = k + 1 end while if waiting > 0 clock.sleep_millis(500) // Process exits and contract events while we wait so state // can actually transition. reap_children(table) supervisor.check_stop_timeouts(table) // Advance the UI tick so spinner animation continues while // we are blocked in this settle-wait loop. ui.ui_tick() waits = waits + 1 end if end while if waiting > 0 if ui.ui_mode() == ui.MODE_PLAIN() println("zyginit: warning: tier " + int_to_str(tier) + " did not settle within 60s (" + int_to_str(waiting) + " services still transitioning); proceeding anyway") end if end if ui.ui_tier_done(tier) tier_offset = tier_offset + tier_size tier = tier + 1 end while end start_services_by_tier // ============================================================================ // Apply state from a previous zyginit instance (live replace). // For each entry in `recovered`, locate the service in `table` and // patch in runtime fields via supervisor.adopt_runtime. Services in // state.toml that are no longer in enabled.d/ get their contract // abandoned but their member processes left running. After patching, // run the post-recovery empty-contract check to catch services that // exited during the exec gap. proc apply_recovered_state(table: supervisor.ServiceTable, recovered: replace.RecoveredState) if replace.rs_is_empty(recovered) return end if let n = replace.rs_count(recovered) println("zyginit: replace: applying " + int_to_str(n) + " recovered services") mut i = 0 while i < n let rs = replace.rs_service(recovered, i) let name = replace.svc_name(rs) let ctid = replace.svc_contract_id(rs) let idx = supervisor.find_service(table, name) if idx < 0 // Operator removed the symlink between old start and replace. // Don't kill — abandon the contract; member processes // continue running unsupervised. let abrc = contract.abandon_contract(ctid) println("zyginit: replace: orphaned " + name + " (no longer enabled, contract " + int_to_str(ctid) + " abandon rc=" + int_to_str(abrc) + ")") else // Patch runtime fields. PID is unknown (not serialized); contract // path is sufficient for supervision. supervisor.adopt_runtime(table, idx, supervisor.STATE_RUNNING(), 0 - 1, ctid, replace.svc_restart_count(rs), replace.svc_last_exit_code(rs), replace.svc_last_start_time(rs)) println("zyginit: replace: re-attached " + name + " (ctid=" + int_to_str(ctid) + ", restarts=" + int_to_str(replace.svc_restart_count(rs)) + ")") // Idempotency: did the contract go empty during the exec gap? if contract.is_contract_empty(ctid) println("zyginit: replace: " + name + " contract empty post-recovery, applying restart policy") supervisor.handle_contract_event(table, ctid, 0 - 1) end if end if i = i + 1 end while end apply_recovered_state // Operator-driven live replace. Triggered by zygctl replace; the // socket handler sets g_replace_requested and we pick it up on the // next event-loop tick. After this returns successfully, control // transfers to the new /sbin/init process; this fn does not return // on success. // // On precondition failure or any I/O error before exec, we log and // return; the caller clears the flag and continues normally. proc replace_self(table: supervisor.ServiceTable, boot_time: int, wait_seconds: int) println("zyginit: replace: requested (wait=" + int_to_str(wait_seconds) + ")") // Pre-condition check, with optional wait window. // Drive reap_children + check_stop_timeouts inside the wait loop so // transient services (STARTING, STOPPING, WAITING) can actually // transition to stable states. Mirrors the pattern in // start_services_by_tier and shutdown_services. mut elapsed_ms = 0 let wait_ms = wait_seconds * 1000 mut report = replace.check_preconditions(table) while str.length(report) > 0 and elapsed_ms < wait_ms clock.sleep_millis(500) reap_children(table) supervisor.check_stop_timeouts(table) elapsed_ms = elapsed_ms + 500 report = replace.check_preconditions(table) end while if str.length(report) > 0 println("zyginit: replace: blocked: " + report) return end if // Serialize state to /var/run/zyginit/state.toml. if not replace.serialize_state(table, boot_time) println("zyginit: replace: state serialization failed; aborting") return end if println("zyginit: replace: state written to " + replace.STATE_FILE_PATH()) // Unlink the socket. Proceed even on failure — new zyginit will // overwrite. zyginit_fsync_path falls back to fsync'ing the parent // dir when the path itself doesn't exist, which is exactly what we // want after unlink — the rename of the directory entry gets // committed. let sock_path = SOCKET_PATH() let urc = zyginit_unlink(sock_path) if urc != 0 println("zyginit: replace: warning: socket unlink failed (rc=" + int_to_str(urc) + ")") end if let _ = zyginit_fsync_path(sock_path) // Exec /sbin/init in place. argv[0] = the binary path; argv has // one element. process.process_exec replaces the current process // image; on success it does not return. let target = "/sbin/init" let argv = new [string](1) argv[0] = target println("zyginit: replace: execve " + target) let _ = process.process_exec(target, argv) // If we reach here, exec failed. We have already written state.toml // and unlinked the socket — recovery requires reboot. println("zyginit: replace: FATAL: execve returned (kernel could not load " + target + ")") end replace_self // Configuration reload (SIGHUP) // ============================================================================ // Re-scan enabled.d/, compare with current table, add new / disable removed. // V1: does not detect in-place config changes to existing services. proc reload_services(table: supervisor.ServiceTable) let config_dir = CONFIG_DIR() let max = MAX_SERVICES() // Re-scan enabled services let new_services = new [config.ServiceDef](max) let new_count = config.load_enabled_services(config_dir, new_services, max) // Build a list of names currently in the new scan let new_names = new [string](max) mut ni = 0 while ni < new_count new_names[ni] = config.svc_name(new_services[ni]) ni = ni + 1 end while // Pass 1: Disable services that are no longer enabled let current_count = supervisor.service_count(table) mut ci = 0 while ci < current_count let rt = supervisor.get_runtime(table, ci) let state = supervisor.rt_state(rt) // Skip already-disabled services if state != supervisor.STATE_DISABLED() let name = config.svc_name(supervisor.rt_def(rt)) mut found = false mut j = 0 while j < new_count if new_names[j] == name found = true break end if j = j + 1 end while if not found println("zyginit: reload: disabling " + name) supervisor.disable_service(table, ci) end if end if ci = ci + 1 end while // Pass 2: Add new services that are not in the table mut added = 0 mut si = 0 while si < new_count let name = new_names[si] let idx = supervisor.find_service(table, name) if idx < 0 // New service — add and start let new_idx = supervisor.add_service(table, new_services[si]) if new_idx >= 0 println("zyginit: reload: adding " + name) supervisor.start_service(table, new_idx) added = added + 1 end if elif supervisor.rt_state(supervisor.get_runtime(table, idx)) == supervisor.STATE_DISABLED() // Was disabled, re-enable and start println("zyginit: reload: re-enabling " + name) supervisor.start_service(table, idx) added = added + 1 end if si = si + 1 end while println("zyginit: reload complete (" + int_to_str(added) + " services added/re-enabled)") end reload_services // ============================================================================ // Signal handling // ============================================================================ // Check for received signals and handle them. // Returns false if zyginit should shut down. fn handle_signals(table: supervisor.ServiceTable): bool // SIGTERM / SIGINT — initiate shutdown if signal.signal_received(signal.SIGTERM()) or signal.signal_received(signal.SIGINT()) println("zyginit: received shutdown signal") // Default: halt (conservative). Socket commands can override. if g_shutdown_type == shutdown.SHUT_NONE() g_shutdown_type = shutdown.SHUT_HALT() g_shutdown_reason = "halt" end if return false end if // SIGHUP — reload config if signal.signal_received(signal.SIGHUP()) println("zyginit: SIGHUP received, reloading configuration") reload_services(table) end if // SIGCHLD — reap exited children if signal.signal_received(signal.SIGCHLD()) reap_children(table) end if return true end handle_signals // Reap all exited children and dispatch to supervisor. proc reap_children(table: supervisor.ServiceTable) // Try to reap children in a loop until no more have exited. // We scan the service table for running PIDs and try_wait each. let count = supervisor.service_count(table) mut i = 0 while i < count let rt = supervisor.get_runtime(table, i) let pid = supervisor.rt_pid(rt) if pid > 0 and supervisor.rt_state(rt) >= 2 if process.process_try_wait(pid) let exit_code = process.process_exit_code() let ctid = supervisor.rt_contract_id(rt) if ctid >= 0 supervisor.handle_contract_event(table, ctid, exit_code) else supervisor.handle_child_exit(table, pid, exit_code) end if end if end if i = i + 1 end while // Catch-all: reap any other zombie children we don't track in the // service table. Daemonize-style services may leave intermediate // PIDs as zombies after the parent exits and we cleared rt.pid; // PID 1 must reap them or they accumulate as entries. mut zpid = process.process_wait_any_nohang() while zpid > 0 zpid = process.process_wait_any_nohang() end while end reap_children // ============================================================================ // Contract event handling // ============================================================================ // Read and dispatch contract events from the bundle fd. proc handle_contract_events(table: supervisor.ServiceTable, bundle_fd: int) let out_ctid = new [int](1) let out_type = new [int](1) // Read events in a loop until no more are available while contract.read_event(bundle_fd, out_ctid, out_type) let ctid = out_ctid[0] let evtype = out_type[0] // CT_PR_EV_EMPTY (1) = all processes in contract exited if evtype == contract.CT_PR_EV_EMPTY() supervisor.handle_contract_event(table, ctid, 0 - 1) contract.abandon_contract(ctid) end if end while end handle_contract_events // ============================================================================ // Shutdown // ============================================================================ // Stop all services in reverse tier order. // - Daemons in RUNNING: stop via contract SIGTERM (existing stop_service path). // - Oneshots in STOPPED with a declared exec.stop: run the stop command. // - Oneshots without stop: skip. proc shutdown_services(table: supervisor.ServiceTable, tiers: [string], tier_counts: [int], num_tiers: int) println("zyginit: stopping all services...") // Compute cumulative offsets per tier so we can scan each tier's slice. let offsets = new [int](num_tiers) mut off = 0 mut i = 0 while i < num_tiers offsets[i] = off off = off + tier_counts[i] i = i + 1 end while // Walk tiers in reverse. mut tier = num_tiers - 1 while tier >= 0 let tier_off = offsets[tier] let tier_sz = tier_counts[tier] if ui.ui_mode() == ui.MODE_PLAIN() println("zyginit: shutting down tier " + int_to_str(tier) + " (" + int_to_str(tier_sz) + " services)") end if mut j = 0 while j < tier_sz let name = tiers[tier_off + j] let idx = supervisor.find_service(table, name) if idx >= 0 let rt = supervisor.get_runtime(table, idx) let state = supervisor.rt_state(rt) if state == supervisor.STATE_RUNNING() // Running daemon: issue contract stop. supervisor.stop_service(table, idx) elif state == supervisor.STATE_STOPPED() // Oneshot that already exited: run its stop if declared. let _ = supervisor.stop_oneshot(table, idx) end if end if j = j + 1 end while // Drain phase: the stop-issuing j-loop above ran to completion before // we get here. We now wait for daemons in STATE_STOPPING to reach // STATE_STOPPED. KEEP THIS SEPARATE FROM THE STOP-ISSUING LOOP — merging // them would risk re-issuing stops to services that are merely waiting // to exit. // // Between tiers: wait for daemons in this tier to reach STOPPED // so we don't teardown upstream deps while downstream is still alive. // Loop with a short sleep until all RUNNING in this tier are gone, // with a safety deadline of 30 seconds (60 x 500ms). mut remaining = 1 mut waits = 0 while remaining > 0 and waits < 60 remaining = 0 mut k = 0 while k < tier_sz let name2 = tiers[tier_off + k] let idx2 = supervisor.find_service(table, name2) if idx2 >= 0 let rt2 = supervisor.get_runtime(table, idx2) if supervisor.rt_state(rt2) == supervisor.STATE_STOPPING() remaining = remaining + 1 end if end if k = k + 1 end while if remaining > 0 clock.sleep_millis(500) // Also reap any child exits that happened during the wait, // and escalate stop timeouts to SIGKILL if configured. reap_children(table) supervisor.check_stop_timeouts(table) // Advance the UI tick so spinner animation continues while // we are blocked in this shutdown drain-wait loop. ui.ui_tick() waits = waits + 1 end if end while if remaining > 0 if ui.ui_mode() == ui.MODE_PLAIN() println("zyginit: tier " + int_to_str(tier) + " shutdown timed out with " + int_to_str(remaining) + " services still STOPPING; proceeding anyway") end if end if tier = tier - 1 end while if ui.ui_mode() == ui.MODE_PLAIN() println("zyginit: all service tiers stopped") end if end shutdown_services // ============================================================================ // Runlevel transitions (zygctl single / zygctl multi) // ============================================================================ // Transition the system from current g_runlevel to target_level. Called // from the main event loop when socket.runlevel_requested() returns a // non-sentinel value. Stops services no longer in the active set // (reverse tier order) and starts services newly in the active set // (forward tier order). Updates g_runlevel and writes a RUN_LVL utmpx // record so who(1) -r reports the new level. proc transition_runlevel(table: supervisor.ServiceTable, tiers: [string], tier_counts: [int], num_tiers: int, target_level: int) if target_level == g_runlevel println("zyginit: already in requested runlevel, ignoring") return end if let lvl_name = "multi" if target_level == RUNLEVEL_SINGLE() let lvl_name = "single" end if println("zyginit: transitioning to " + lvl_name + "-user mode") // Compute cumulative offsets per tier (reused for both passes). let offsets = new [int](num_tiers) mut off = 0 mut i = 0 while i < num_tiers offsets[i] = off off = off + tier_counts[i] i = i + 1 end while // Pass 1: Stop services NOT in the new active set, reverse tier order. mut tier = num_tiers - 1 while tier >= 0 let tier_off = offsets[tier] let tier_sz = tier_counts[tier] mut j = 0 while j < tier_sz let name = tiers[tier_off + j] let idx = supervisor.find_service(table, name) if idx >= 0 let rt = supervisor.get_runtime(table, idx) let def = supervisor.rt_def(rt) if not service_in_runlevel(def, target_level) let state = supervisor.rt_state(rt) if state == supervisor.STATE_RUNNING() supervisor.stop_service(table, idx) end if end if end if j = j + 1 end while // Drain: wait for STOPPING services in this tier to reach STOPPED. mut remaining = 1 mut waits = 0 while remaining > 0 and waits < 60 remaining = 0 mut k = 0 while k < tier_sz let n2 = tiers[tier_off + k] let i2 = supervisor.find_service(table, n2) if i2 >= 0 let r2 = supervisor.get_runtime(table, i2) if supervisor.rt_state(r2) == supervisor.STATE_STOPPING() remaining = remaining + 1 end if end if k = k + 1 end while if remaining > 0 clock.sleep_millis(500) reap_children(table) supervisor.check_stop_timeouts(table) waits = waits + 1 end if end while tier = tier - 1 end while // Update g_runlevel BEFORE starting new services, so service_in_runlevel // checks during start phase use the new level. g_runlevel = target_level // Pass 2: Start services newly in the active set, forward tier order. // Reuses start_services_by_tier's per-tier kick + settle pattern by // simply calling it; it already filters by g_runlevel and skips // anything currently RUNNING. start_services_by_tier(table, tiers, tier_counts, num_tiers) // Write RUN_LVL utmpx record. if is_pid_1() if target_level == RUNLEVEL_SINGLE() let _ = zyginit_write_runlvl_utmpx(83) // 'S' else let _ = zyginit_write_runlvl_utmpx(51) // '3' end if end if println("zyginit: transition to " + lvl_name + "-user mode complete") end transition_runlevel // ============================================================================ // Main entry point // ============================================================================ proc main() // --ui-demo : run a canned UI scenario for snapshot testing. if args.has_flag("ui-demo") let scenario = args.get_flag_value("ui-demo") let demo_mode = ui.detect_rich_mode() ui.ui_init(demo_mode) // Detect window size so demo uses real terminal dimensions. // ZYGINIT_FORCE_80x25=1 in ui_tests.sh overrides this for // snapshot stability. ui.ui_detect_winsize() 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") println("zyginit " + version.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. if is_pid_1() let _ = setup_pid1_console() end if // Children inherit TERM from us. Set it to match Hammerhead's // kernel tem (framebuffer console). Services that need a different // TERM can override via their [exec].environment in TOML. if is_pid_1() let _ = env.set_env("TERM", "sun-color") end if // Initialize UI renderer. Must happen before any ui_event_* calls. // detect_rich_mode_for_main returns MODE_PLAIN when not PID 1 or when // stdout is not a terminal (integration tests set ZYGINIT_NO_UI=1). let rich_mode = ui.detect_rich_mode_for_main() ui.ui_init(rich_mode) // Detect terminal size. Must run after setup_pid1_console() (so fd 1 is // /dev/console) and after ui_init (so g_mode is set for future redraws). // On Linux dev builds or when stdout is a pipe, TIOCGWINSZ returns -1 // and ui_detect_winsize falls back to 80x25 defaults. ui.ui_detect_winsize() if ui.ui_mode() == ui.MODE_PLAIN() println("zyginit v" + version.VERSION() + " starting") if is_pid_1() println("zyginit: running as PID 1 (init mode)") else println("zyginit: running as PID " + int_to_str(process.getpid()) + " (non-init mode)") end if end if // Determine boot runlevel from kernel-passed args. -s in boot-args // selects single-user mode; otherwise multi-user. Stored in g_runlevel. g_runlevel = parse_boot_runlevel() if ui.ui_mode() == ui.MODE_PLAIN() if g_runlevel == RUNLEVEL_SINGLE() println("zyginit: booting into single-user mode (-s in boot args)") else println("zyginit: booting into multi-user mode") end if end if // Write BOOT_TIME and RUN_LVL utmpx records so who(1) -b/-r and // uptime(1) report this boot. Best-effort; non-fatal on failure // (e.g., /var/adm/utmpx doesn't yet exist on a freshly-installed // BE — filesystem service will create the dir, this gets caught // by our utmpd service later or admin can touch the file). if is_pid_1() let _ = zyginit_write_boot_utmpx() // 'S' = 83, '3' = 51 (ASCII). Pass as int to the FFI helper. if g_runlevel == RUNLEVEL_SINGLE() let _ = zyginit_write_runlvl_utmpx(83) else let _ = zyginit_write_runlvl_utmpx(51) end if end if // Cache PID 1's process start time as the boot-id sentinel for // live-replace. /proc/1/start survives execve (same proc_t) but // changes on real reboot (new PID 1). utmpx BOOT_TIME was used // previously but pututxline updates rather than appends, so the // new zyginit overwrites the original timestamp at startup, // defeating the staleness check. if is_pid_1() g_boot_time = replace.read_boot_time() if g_boot_time < 0 // Fallback: use time_now(). This degrades the staleness // check (a real reboot might collide if seconds-resolution // happens to repeat), but it's better than refusing all // replaces. g_boot_time = time.time_now() println("zyginit: warning: /proc/1/start read failed; using time_now() = " + int_to_str(g_boot_time)) end if else // Non-PID-1 (supervisor mode for testing): stamp a fresh value. g_boot_time = time.time_now() end if // ---- Phase 1: Load service definitions ---- let services = new [config.ServiceDef](MAX_SERVICES()) let svc_count = config.load_enabled_services(CONFIG_DIR(), services, MAX_SERVICES()) if ui.ui_mode() == ui.MODE_PLAIN() if svc_count == 0 println("zyginit: no enabled services found in " + CONFIG_DIR()) println("zyginit: nothing to do, entering idle loop") else println("zyginit: loaded " + int_to_str(svc_count) + " services") end if end if // ---- Phase 2: Build dependency graph ---- let graph = depgraph.new_depgraph() let graph_ok = depgraph.build_graph(graph, services, svc_count) if not graph_ok println("zyginit: FATAL: dependency cycle detected, cannot boot") return end if let tiers = new [string](256) let tier_counts = new [int](32) let num_tiers = depgraph.topo_sort(graph, tiers, tier_counts, 32) if ui.ui_mode() == ui.MODE_PLAIN() println("zyginit: boot order has " + int_to_str(num_tiers) + " tiers") end if // ---- Create log directory ---- let log_path = supervisor.log_dir() if not dir.dir_exists(log_path) dir.create_dir_all(log_path) end if // ---- Phase 3: Create service table ---- let table = supervisor.new_service_table(MAX_SERVICES()) mut i = 0 while i < svc_count supervisor.add_service(table, services[i]) i = i + 1 end while // ---- Phase 3.5: Apply state from previous zyginit (live replace) ---- // // If state.toml exists and is fresh (boot_time matches), the new // zyginit was just exec'd from a `zygctl replace` — re-attach // running services to their existing process contracts. Otherwise // recover_state returns an empty RecoveredState and we proceed // as a normal fresh boot. let recovered = replace.recover_state(g_boot_time) if not replace.rs_is_empty(recovered) apply_recovered_state(table, recovered) end if // ---- Phase 4: Set up signal handling ---- signal.signal_init() signal.signal_handle(signal.SIGCHLD()) signal.signal_handle(signal.SIGTERM()) signal.signal_handle(signal.SIGINT()) signal.signal_handle(signal.SIGHUP()) signal.signal_ignore(signal.SIGPIPE()) if not setup_signal_pipe() println("zyginit: FATAL: could not create signal pipe") return end if // ---- Phase 5: Open event sources ---- // Contract bundle fd (will be -1 on Linux — that's expected) let bundle_fd = contract.open_bundle() // Unix domain socket for zygctl communication let socket_fd = socket.create_socket(SOCKET_PATH()) // ---- Phase 6: Start services ---- if svc_count > 0 if ui.ui_mode() == ui.MODE_PLAIN() println("") println("zyginit: starting services...") end if mut runlevel_name = "multi-user" if g_runlevel == RUNLEVEL_SINGLE() runlevel_name = "single-user" end if g_boot_start_ms = ui.zyginit_monotonic_ms() ui.ui_boot_start(svc_count, num_tiers, runlevel_name) start_services_by_tier(table, tiers, tier_counts, num_tiers) // ---- Boot settle period ---- // Drain contract/socket events for BOOT_SETTLE_MS() before painting // the final card. Daemons that fork successfully (enter RUNNING) but // die immediately produce a contract-empty event ~10-100ms after the // tier loop declares them settled. Without this window, // ui_boot_complete would show "0 failed" while the failure log // appears moments later. let settle_until = ui.zyginit_monotonic_ms() + BOOT_SETTLE_MS() while ui.zyginit_monotonic_ms() < settle_until // 200ms slices keep the spinner animation smooth and give // poll() a chance to return early on real contract events. let settle_ok = poll_once(table, bundle_fd, socket_fd, 200) if not settle_ok // Shutdown signal during settle — bail before the card. break end if end while let boot_elapsed = ui.zyginit_monotonic_ms() - g_boot_start_ms let boot_stats = build_boot_stats(table, boot_elapsed) ui.ui_boot_complete(boot_stats) if ui.ui_mode() == ui.MODE_PLAIN() println("") end if end if // ---- Phase 7: Main event loop ---- if ui.ui_mode() == ui.MODE_PLAIN() println("zyginit: entering event loop") end if mut running = true while running // Core poll iteration: signals, contract events, socket accept, // child reaping, stop-timeout checks, UI tick — 1 second timeout. running = poll_once(table, bundle_fd, socket_fd, 1000) if not running break end if // Check for reload requested via socket if socket.reload_requested() socket.clear_reload_flag() println("zyginit: reload requested via socket") reload_services(table) end if // Check for replace requested via socket if socket.replace_requested() let wait = socket.replace_wait_seconds() socket.clear_replace_flag() replace_self(table, g_boot_time, wait) // If replace_self exec'd successfully, we never get here. // If it returned, log and continue running normally. end if // Check for shutdown requested via socket (halt/reboot/poweroff) let req = socket.shutdown_requested() if req != 0 socket.clear_shutdown_request() g_shutdown_type = req g_shutdown_reason = shutdown_type_to_reason(req) running = false println("zyginit: shutdown requested via socket (type " + int_to_str(req) + ")") end if // Check for runlevel transition requested via socket (single/multi) let rl_req = socket.runlevel_requested() if rl_req >= 0 socket.clear_runlevel_request() transition_runlevel(table, tiers, tier_counts, num_tiers, rl_req) end if // Periodic restart-delay check (complements check_stop_timeouts // already called inside poll_once). supervisor.check_restart_delays(table) end while // ---- Shutdown ---- if ui.ui_mode() == ui.MODE_PLAIN() println("") end if ui.ui_shutdown_start(g_shutdown_reason) let shutdown_start_ms = ui.zyginit_monotonic_ms() shutdown_services(table, tiers, tier_counts, num_tiers) let shutdown_elapsed_ms = ui.zyginit_monotonic_ms() - shutdown_start_ms ui.ui_shutdown_complete(g_shutdown_reason, shutdown_elapsed_ms) // Clean up if g_signal_pipe_read >= 0 fd.fd_close(g_signal_pipe_read) fd.fd_close(g_signal_pipe_write) end if if bundle_fd >= 0 fd.fd_close(bundle_fd) end if if socket_fd >= 0 socket.destroy_socket(SOCKET_PATH(), socket_fd) end if println("zyginit: shutdown complete") // As PID 1, we MUST NOT return normally — kernel panics on "init died". // Call sync() then uadmin() based on the requested shutdown type. if is_pid_1() mut shut_type = g_shutdown_type if shut_type == shutdown.SHUT_NONE() shut_type = shutdown.SHUT_HALT() // defensive default end if println("zyginit: sync()") shutdown.do_sync() // Force ZFS to commit all pending transactions to disk. illumos // sync(2) just *schedules* writes; ZFS may delay the txg commit // until its periodic flush. The kernel reboot in uadmin's // mdboot races with that commit — without this explicit // synchronous sync, the next boot can see stale state (e.g., // an unlinked socket file appearing back). This is a data // integrity concern beyond the socket file: any pending writes // to root could be lost. println("zyginit: zpool sync (force ZFS metadata commit)") let _zsync = zyginit_run_cmd("/sbin/zpool sync 2>/dev/null") // Best-effort unmount of non-root local filesystems. Mirrors // svc.startd's do_uadmin sequence (cmd/svc/startd/graph.c). // For our setup this is mostly a no-op since everything is on // root, but it doesn't hurt and matches the canonical pattern. println("zyginit: umountall -l (best-effort)") let _umt = zyginit_run_cmd("/sbin/umountall -l 2>/dev/null") // One more sync after the umounts. shutdown.do_sync() let fcn = shutdown.to_ad_code(shut_type) println("zyginit: uadmin(A_SHUTDOWN, " + int_to_str(fcn) + ", 0)") // Fork a child to call uadmin. uadmin from PID 1 hits the kernel's // restart_init path: it marks PID 1 as exiting (releases vm, // closes fds, etc), and although uadmin's killall() spares the // calling process, init's death races with mdboot — the kernel // re-execs /sbin/init via restart_init() before mdboot's actual // hardware reset takes effect. Net result: init "restarts" but // the kernel never reboots; old child processes survive as // orphans of the new init, producing the cascading port-22-in-use // and stale-socket symptoms we observed (uptime stays at the // original boot, while PID 1 STIME advances). svc.startd avoids // this by being a regular non-PID-1 process; we mirror that here // by forking a child that does the actual syscall. let child_pid = process.process_fork() if child_pid == 0 // CHILD: do the uadmin call. Tiny pause so the parent's // println above flushes to console before we vanish. clock.sleep_millis(100) let _ = shutdown.do_shutdown(fcn) // Should not reach here — mdboot is supposed to reset the // hardware. If it does return, exit so we don't keep running. process.exit_now(0) end if // PARENT (PID 1): infinite sleep — the child's uadmin will reset // the kernel within milliseconds. We MUST NOT return from main() // here because restart_init would re-exec us into a partial state. while true clock.sleep_seconds(3600) end while end if // Non-PID-1 path falls through and returns from main() normally. end main // ============================================================================ // Helpers // ============================================================================ // Run one poll iteration: add fds, wait timeout_ms, drain signal pipe, // process signals, dispatch contract events, reap children, advance UI tick. // Returns false if a shutdown signal was received and the caller should // exit its loop (g_shutdown_type is set by handle_signals before returning). fn poll_once(table: supervisor.ServiceTable, bundle_fd: int, socket_fd: int, timeout_ms: int): bool poll.poll_clear() let sig_idx = poll.poll_add(g_signal_pipe_read, poll.POLLIN()) mut bundle_idx = 0 - 1 if bundle_fd >= 0 bundle_idx = poll.poll_add(bundle_fd, poll.POLLIN()) end if mut sock_idx = 0 - 1 if socket_fd >= 0 sock_idx = poll.poll_add(socket_fd, poll.POLLIN()) end if let ready = poll.poll_wait(timeout_ms) ui.ui_tick() if ready > 0 and poll.poll_readable(sig_idx) signal_pipe_drain() end if let ok = handle_signals(table) if ready > 0 and bundle_idx >= 0 and poll.poll_readable(bundle_idx) handle_contract_events(table, bundle_fd) end if if ready > 0 and sock_idx >= 0 and poll.poll_readable(sock_idx) socket.handle_client(socket_fd, table) end if supervisor.check_stop_timeouts(table) reap_children(table) return ok end poll_once // Convert an internal shutdown type to a human-readable reason string for the UI. fn shutdown_type_to_reason(shut_type: int): string if shut_type == shutdown.SHUT_REBOOT() return "reboot" elif shut_type == shutdown.SHUT_POWEROFF() return "poweroff" end if return "halt" end shutdown_type_to_reason // Build a BootStats struct from the service table. // boot_elapsed_ms: wall time from boot start to now (computed by caller). fn build_boot_stats(table: supervisor.ServiceTable, boot_elapsed_ms: int): ui.BootStats let count = supervisor.service_count(table) mut online = 0 mut failed = 0 mut i = 0 while i < count let rt = supervisor.get_runtime(table, i) let st = supervisor.rt_state(rt) if st == supervisor.STATE_RUNNING() online = online + 1 end if if st == supervisor.STATE_STOPPED() if supervisor.rt_last_exit_code(rt) == 0 online = online + 1 end if end if if st == supervisor.STATE_FAILED() or st == supervisor.STATE_MAINTENANCE() failed = failed + 1 end if i = i + 1 end while let slow = new [string](3) return ui.BootStats{ elapsed_ms: boot_elapsed_ms, online: online, failed: failed, slowest: slow, slowest_count: 0 } end build_boot_stats fn int_to_str(n: int): string if n == 0 return "0" end if mut value = n if n < 0 value = 0 - n end if mut result = "" while value > 0 let digit = value % 10 result = str.concat(str.substring("0123456789", digit, 1), result) value = value / 10 end while if n < 0 result = str.concat("-", result) end if return result end int_to_str end module