/****************************************************************************** __ ____ __ / / ___ ____ _/ __/_____________ _/ /__ / / / _ \/ __ `/ /_/ ___/ ___/ __ `/ / _ \ / /___/ __/ /_/ / __(__ ) /__/ /_/ / / __/ /_____/\___/\__,_/_/ /____/\___/\__,_/_/\___/ (C)opyright 2025-2026, Leafscale, LLC - https://www.leafscale.com Project: zyginit Filename: replace.reef Authors: Chris Tusa License: Description: Live-replace state-file handoff (operator-driven PID-1 upgrade) ******************************************************************************/ // Live-replace operator flow: zygctl replace -> old zyginit serializes // runtime state to /var/run/zyginit/state.toml, execs /sbin/init in // place; new zyginit reads state.toml, validates boot_time matches // current utmpx BOOT_TIME (else stale across a real reboot), and // rebuilds the contract_map / runtime fields in supervisor.ServiceTable. // // PID-1 in-place execve preserves proc_t.p_ct_process at the kernel // level, so contract ownership survives automatically — no // ct_ctl_adopt call is needed. This module owns userspace bookkeeping. module replace import config import supervisor import io.file import sys.fd as fd import sys.env as sysenv import core.str import encoding.toml import time.time as time export type RecoveredService type RecoveredState // Accessors — RecoveredState fn rs_count(s: RecoveredState): int fn rs_service(s: RecoveredState, idx: int): RecoveredService fn rs_boot_time(s: RecoveredState): int fn rs_is_empty(s: RecoveredState): bool // Accessors — RecoveredService fn svc_name(rs: RecoveredService): string fn svc_contract_id(rs: RecoveredService): int fn svc_restart_count(rs: RecoveredService): int fn svc_last_start_time(rs: RecoveredService): int fn svc_last_exit_code(rs: RecoveredService): int // Outgoing flow — write state, return true on success fn serialize_state(table: supervisor.ServiceTable, boot_time: int): bool // Incoming flow — read state file. Returns an empty RecoveredState // if the file is missing, corrupt, or has a mismatched boot_time. fn recover_state(current_boot_time: int): RecoveredState // Pre-condition check. Returns "" if all services are stable, else // a comma-separated "name:state" list suitable for logging. fn check_preconditions(table: supervisor.ServiceTable): string // FFI for boot-time sentinel (helpers.c) fn read_boot_time(): int // Constants fn STATE_FILE_PATH(): string fn STATE_TMP_PATH(): string fn STATE_DIR(): string end export // FFI — implemented in helpers.c extern "C" fn zyginit_read_boot_time(): int extern "C" fn zyginit_read_pid1_start(): int extern "C" fn zyginit_fsync_path(path: string): int // FFI — POSIX wrappers from helpers.c // Direct extern "C" for rename/unlink/fsync would conflict with system // headers' const char* declarations. Use zyginit_* wrappers (same pattern // as zyginit_symlink). extern "C" fn zyginit_rename(oldpath: string, newpath: string): int extern "C" fn zyginit_unlink(path: string): int extern "C" fn zyginit_fsync(fd: int): int // Returns PID 1's process start time, used as the boot-id sentinel. // Survives execve (proc_t preserved) but changes on real reboot. // Returns -1 if /proc/1/{stat,psinfo} can't be read. // // NOTE: utmpx BOOT_TIME was used previously but pututxline updates rather // than appends the existing record, so the new zyginit overwrites the // original timestamp at startup, defeating the staleness check. fn read_boot_time(): int return zyginit_read_pid1_start() end read_boot_time // File-system layout. Keep grouped here so a future tmpfs migration // only edits one place. fn STATE_DIR(): string return sysenv.get_env_or("ZYGINIT_RUN_DIR", "/var/run/zyginit") end STATE_DIR fn STATE_FILE_PATH(): string return str.concat(STATE_DIR(), "/state.toml") end STATE_FILE_PATH fn STATE_TMP_PATH(): string return str.concat(STATE_DIR(), "/state.toml.tmp") end STATE_TMP_PATH // ============================================================================ // Types // ============================================================================ type RecoveredService = struct name: string contract_id: int restart_count: int last_start_time: int last_exit_code: int end RecoveredService type RecoveredState = struct boot_time: int services: [RecoveredService] count: int end RecoveredState fn new_recovered_state(): RecoveredState return RecoveredState{ boot_time: 0 - 1, services: new [RecoveredService](0), count: 0 } end new_recovered_state // ============================================================================ // Accessors // ============================================================================ fn rs_count(s: RecoveredState): int return s.count end rs_count fn rs_service(s: RecoveredState, idx: int): RecoveredService return s.services[idx] end rs_service fn rs_boot_time(s: RecoveredState): int return s.boot_time end rs_boot_time fn rs_is_empty(s: RecoveredState): bool return s.count == 0 end rs_is_empty fn svc_name(rs: RecoveredService): string return rs.name end svc_name fn svc_contract_id(rs: RecoveredService): int return rs.contract_id end svc_contract_id fn svc_restart_count(rs: RecoveredService): int return rs.restart_count end svc_restart_count fn svc_last_start_time(rs: RecoveredService): int return rs.last_start_time end svc_last_start_time fn svc_last_exit_code(rs: RecoveredService): int return rs.last_exit_code end svc_last_exit_code // ============================================================================ // Outgoing — serialize running service state to state.toml // ============================================================================ fn serialize_state(table: supervisor.ServiceTable, boot_time: int): bool let tmp_path = STATE_TMP_PATH() let final_path = STATE_FILE_PATH() let dir_path = STATE_DIR() // O_WRONLY|O_CREAT|O_TRUNC, mode 0600 let flags = fd.O_WRONLY() + fd.O_CREAT() + fd.O_TRUNC() let out = fd.fd_open(tmp_path, flags, 384) if out < 0 println("replace: serialize: cannot open " + tmp_path) return false end if // Write boot_time header let header = str.concat("boot_time = ", int_to_str(boot_time)) let _ = fd.fd_write(out, str.concat(header, "\n")) // Iterate services — only RUNNING ones go in. Skip the rest. let count = supervisor.service_count(table) mut i = 0 while i < count let rt = supervisor.get_runtime(table, i) if supervisor.rt_state(rt) == supervisor.STATE_RUNNING() let def = supervisor.rt_def(rt) let _ = fd.fd_write(out, "\n[[service]]\n") let _ = fd.fd_write(out, str.concat("name = \"", str.concat(config.svc_name(def), "\"\n"))) let _ = fd.fd_write(out, str.concat("contract_id = ", str.concat(int_to_str(supervisor.rt_contract_id(rt)), "\n"))) let _ = fd.fd_write(out, str.concat("restart_count = ", str.concat(int_to_str(supervisor.rt_restart_count(rt)), "\n"))) let _ = fd.fd_write(out, str.concat("last_start_time = ", str.concat(int_to_str(supervisor.rt_last_start_time(rt)), "\n"))) let _ = fd.fd_write(out, str.concat("last_exit_code = ", str.concat(int_to_str(supervisor.rt_last_exit_code(rt)), "\n"))) end if i = i + 1 end while // fsync + close if zyginit_fsync(out) < 0 println("replace: serialize: fsync failed") let _ = fd.fd_close(out) let _ = zyginit_unlink(tmp_path) return false end if let _ = fd.fd_close(out) // Atomic rename if zyginit_rename(tmp_path, final_path) != 0 println("replace: serialize: rename to " + final_path + " failed") let _ = zyginit_unlink(tmp_path) return false end if // fsync the parent dir so the rename is durable let _ = zyginit_fsync_path(dir_path) return true end serialize_state // Local int_to_str — replicate the supervisor.reef pattern. Keeps the // module standalone (no cross-module helper exposure). fn int_to_str(n: int): string if n == 0 return "0" end if mut value = n mut neg = false if n < 0 value = 0 - n neg = true 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 neg result = str.concat("-", result) end if return result end int_to_str // ============================================================================ // Incoming — recover serialized state on restart // ============================================================================ // Local int parser with default. Handles negative numbers. // Empty string or non-numeric input yields default_val. fn str_to_int_or(s: string, default_val: int): int let n = str.length(s) if n == 0 return default_val end if mut start = 0 mut neg = false if str.char_at(s, 0) == '-' neg = true start = 1 end if if start >= n return default_val end if mut result = 0 mut i = start while i < n let c = str.char_at(s, i) if c < '0' or c > '9' return default_val end if result = result * 10 + (c - '0') i = i + 1 end while if neg return 0 - result end if return result end str_to_int_or fn recover_state(current_boot_time: int): RecoveredState let path = STATE_FILE_PATH() if not file.fileExists(path) return new_recovered_state() end if let content = file.readFile(path) if str.length(content) == 0 let _ = zyginit_unlink(path) return new_recovered_state() end if let keys = toml.toml_alloc_keys() let vals = toml.toml_alloc_values() let count = toml.toml_parse(content, keys, vals) if count == 0 println("replace: recover: parse error or empty in " + path + " — ignoring") let _ = zyginit_unlink(path) return new_recovered_state() end if if not toml.toml_has_key(keys, vals, count, "boot_time") println("replace: recover: missing boot_time in " + path + " — ignoring") let _ = zyginit_unlink(path) return new_recovered_state() end if let saved_boot = toml.toml_get_int(keys, vals, count, "boot_time") if saved_boot != current_boot_time or current_boot_time < 0 println("replace: recover: stale state file (boot_time " + int_to_str(saved_boot) + " != current " + int_to_str(current_boot_time) + "); ignoring") let _ = zyginit_unlink(path) return new_recovered_state() end if let n = toml.toml_array_count(keys, count, "service") let arr = new [RecoveredService](n) mut i = 0 while i < n let name = toml.toml_array_get(keys, vals, count, "service", i, "name") let ctid_str = toml.toml_array_get(keys, vals, count, "service", i, "contract_id") let rc_str = toml.toml_array_get(keys, vals, count, "service", i, "restart_count") let lst_str = toml.toml_array_get(keys, vals, count, "service", i, "last_start_time") let lec_str = toml.toml_array_get(keys, vals, count, "service", i, "last_exit_code") arr[i] = RecoveredService{ name: name, contract_id: str_to_int_or(ctid_str, 0 - 1), restart_count: str_to_int_or(rc_str, 0), last_start_time: str_to_int_or(lst_str, 0), last_exit_code: str_to_int_or(lec_str, 0 - 1) } i = i + 1 end while // State file consumed — delete to avoid panic-restart loops. let _ = zyginit_unlink(path) return RecoveredState{ boot_time: saved_boot, services: arr, count: n } end recover_state // ============================================================================ // Pre-condition check — transient state detection // ============================================================================ fn check_preconditions(table: supervisor.ServiceTable): string let count = supervisor.service_count(table) mut report = "" mut found = 0 mut i = 0 while i < count let rt = supervisor.get_runtime(table, i) let st = supervisor.rt_state(rt) // STATE_WAITING here means "restart delay pending" — services // filtered by runlevel are transitioned to STATE_STOPPED at // boot time (see supervisor.mark_runlevel_filtered), and the // table is fully started before the event loop entry that // processes the replace flag, so "not-yet-started" doesn't // occur here either. if st == supervisor.STATE_STARTING() or st == supervisor.STATE_STOPPING() or st == supervisor.STATE_WAITING() let entry = str.concat(config.svc_name(supervisor.rt_def(rt)), str.concat(":", supervisor.state_name(st))) if found > 0 report = str.concat(report, ", ") end if report = str.concat(report, entry) found = found + 1 end if i = i + 1 end while return report end check_preconditions end module