/****************************************************************************** __ ____ __ / / ___ ____ _/ __/_____________ _/ /__ / / / _ \/ __ `/ /_/ ___/ ___/ __ `/ / _ \ / /___/ __/ /_/ / __(__ ) /__/ /_/ / / __/ /_____/\___/\__,_/_/ /____/\___/\__,_/_/\___/ (C)opyright 2025-2026, Leafscale, LLC - https://www.leafscale.com Project: zyginit Filename: condition.reef Authors: Chris Tusa License: Description: Evaluate [condition] blocks: exists_file, exists_file_any, command. Returns pass/fail + reason. ******************************************************************************/ module condition import core.str as str import io.file import config export type ConditionResult fn evaluate_conditions(def: config.ServiceDef): ConditionResult fn cond_passed(r: ConditionResult): bool fn cond_reason(r: ConditionResult): string end export type ConditionResult = struct passed: bool reason: string end ConditionResult extern "C" fn run_command_with_timeout(cmd: string, timeout_sec: int): int // Sentinel return values from run_command_with_timeout. fn TIMEOUT_RC(): int return 0 - 2 end TIMEOUT_RC fn NOT_FOUND_RC(): int return 0 - 3 end NOT_FOUND_RC fn EXEC_FAILED_RC(): int return 0 - 4 end EXEC_FAILED_RC fn cond_passed(r: ConditionResult): bool return r.passed end cond_passed fn cond_reason(r: ConditionResult): string return r.reason end cond_reason fn evaluate_conditions(def: config.ServiceDef): ConditionResult // exists_file: single path must exist let f = config.svc_cond_exists_file(def) if str.length(f) > 0 if not file.fileExists(f) return ConditionResult{ passed: false, reason: str.concat("missing ", f) } end if end if // exists_file_any: at least one must exist let n = config.svc_cond_exists_file_any_count(def) if n > 0 let paths = config.svc_cond_exists_file_any(def) mut found = false mut i = 0 while i < n if file.fileExists(paths[i]) found = true end if i = i + 1 end while if not found return ConditionResult{ passed: false, reason: "none of exists_file_any paths exist" } end if end if // command: exit 0 = pass; non-zero = fail; bounded by 5-second timeout let cmd = config.svc_cond_command(def) if str.length(cmd) > 0 let rc = run_command_with_timeout(cmd, 5) if rc != 0 if rc == TIMEOUT_RC() return ConditionResult{ passed: false, reason: "command timeout" } elif rc == NOT_FOUND_RC() return ConditionResult{ passed: false, reason: str.concat("command not found: ", cmd) } elif rc == EXEC_FAILED_RC() return ConditionResult{ passed: false, reason: "command exec failed" } else return ConditionResult{ passed: false, reason: str.concat("command exit=", int_to_str(rc)) } end if end if end if return ConditionResult{ passed: true, reason: "" } end evaluate_conditions // Local int_to_str — Reef has no stdlib version (RFE-004). // Copied verbatim from contract.reef / socket.reef. 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 /****************************************************************************** __ ____ __ / / ___ ____ _/ __/_____________ _/ /__ / / / _ \/ __ `/ /_/ ___/ ___/ __ `/ / _ \ / /___/ __/ /_/ / __(__ ) /__/ /_/ / / __/ /_____/\___/\__,_/_/ /____/\___/\__,_/_/\___/ (C)opyright 2025-2026, Leafscale, LLC - https://www.leafscale.com Project: zyginit Filename: config.reef Authors: Chris Tusa License: Description: TOML service definition parser and ServiceDef types ******************************************************************************/ module config import encoding.toml import io.file import io.dir import core.str import core.result as res export type ServiceDef // Constants for service type fn SERVICE_TYPE_DAEMON(): int fn SERVICE_TYPE_ONESHOT(): int fn SERVICE_TYPE_TASK(): int // Constants for restart policy fn RESTART_ALWAYS(): int fn RESTART_FAILURE(): int fn RESTART_NEVER(): int // Constants for stop method fn STOP_METHOD_CONTRACT(): int fn STOP_METHOD_EXEC(): int // Constants for runlevel mode (which runlevel(s) include this service) fn RUNLEVEL_MULTI(): int fn RUNLEVEL_SINGLE(): int fn RUNLEVEL_ALWAYS(): int // Service definition constructor fn new_service_def(): ServiceDef // Accessors fn svc_name(svc: ServiceDef): string fn svc_description(svc: ServiceDef): string fn svc_type(svc: ServiceDef): int fn svc_start_cmd(svc: ServiceDef): string fn svc_stop_cmd(svc: ServiceDef): string fn svc_working_dir(svc: ServiceDef): string fn svc_environment(svc: ServiceDef): [string] fn svc_environment_count(svc: ServiceDef): int fn svc_user(svc: ServiceDef): string fn svc_group(svc: ServiceDef): string fn svc_stop_method(svc: ServiceDef): int fn svc_stop_signal(svc: ServiceDef): string fn svc_stop_timeout(svc: ServiceDef): int fn svc_requires(svc: ServiceDef): [string] fn svc_requires_count(svc: ServiceDef): int fn svc_after(svc: ServiceDef): [string] fn svc_after_count(svc: ServiceDef): int fn svc_conflicts(svc: ServiceDef): [string] fn svc_conflicts_count(svc: ServiceDef): int fn svc_contract_param(svc: ServiceDef): [string] fn svc_contract_param_count(svc: ServiceDef): int fn svc_contract_fatal(svc: ServiceDef): [string] fn svc_contract_fatal_count(svc: ServiceDef): int fn svc_restart_policy(svc: ServiceDef): int fn svc_restart_delay(svc: ServiceDef): int fn svc_max_retries(svc: ServiceDef): int fn svc_runlevel_mode(svc: ServiceDef): int fn svc_cond_exists_file(svc: ServiceDef): string fn svc_cond_exists_file_any(svc: ServiceDef): [string] fn svc_cond_exists_file_any_count(svc: ServiceDef): int fn svc_cond_command(svc: ServiceDef): string // Human-readable names for type/policy constants fn service_type_name(t: int): string fn restart_policy_name(p: int): string fn stop_method_name(m: int): string fn runlevel_mode_name(m: int): string // Parsing // parse_service: parse a service.toml file. `name` is taken from the // enclosing directory name (not from the TOML). `svc_dir` is the // directory containing the TOML, used to resolve "./" relative paths. fn parse_service(filepath: string, name: string, svc_dir: string, svc: ServiceDef): bool fn scan_enabled(config_dir: string, names: [string], max_names: int): int fn load_enabled_services(config_dir: string, services: [ServiceDef], max_services: int): int end export // FFI: helpers.c — resolve a symlink or path to its canonical absolute form. // Returns the resolved path, or "" on any error (dangling link, permission // denied, path does not exist, etc.). extern "C" fn zyginit_readlink_resolved(path: string): string // ============================================================================ // Constants // ============================================================================ fn SERVICE_TYPE_DAEMON(): int return 1 end SERVICE_TYPE_DAEMON fn SERVICE_TYPE_ONESHOT(): int return 2 end SERVICE_TYPE_ONESHOT fn SERVICE_TYPE_TASK(): int return 3 end SERVICE_TYPE_TASK fn RESTART_ALWAYS(): int return 1 end RESTART_ALWAYS fn RESTART_FAILURE(): int return 2 end RESTART_FAILURE fn RESTART_NEVER(): int return 3 end RESTART_NEVER fn STOP_METHOD_CONTRACT(): int return 1 end STOP_METHOD_CONTRACT fn STOP_METHOD_EXEC(): int return 2 end STOP_METHOD_EXEC // Runlevel modes — which runlevel(s) include this service in the active // set. Default is MULTI, matching legacy zyginit behavior (services run // in multi-user mode, are stopped on transitions to single-user). fn RUNLEVEL_MULTI(): int return 0 end RUNLEVEL_MULTI fn RUNLEVEL_SINGLE(): int return 1 end RUNLEVEL_SINGLE fn RUNLEVEL_ALWAYS(): int return 2 end RUNLEVEL_ALWAYS // ============================================================================ // ServiceDef type // ============================================================================ type ServiceDef = struct name: string description: string service_type: int start_cmd: string stop_cmd: string working_dir: string environment: [string] environment_count: int user: string group: string stop_method: int stop_signal: string stop_timeout: int requires: [string] requires_count: int after: [string] after_count: int conflicts: [string] conflicts_count: int contract_param: [string] contract_param_count: int contract_fatal: [string] contract_fatal_count: int restart_policy: int restart_delay: int max_retries: int // Runlevel mode: RUNLEVEL_MULTI (default), RUNLEVEL_SINGLE, or // RUNLEVEL_ALWAYS. Determines whether the service is in the active // set for single-user mode, multi-user mode, or both. runlevel_mode: int // [condition] block fields — all optional, default to empty/zero. cond_exists_file: string cond_exists_file_any: [string] cond_exists_file_any_count: int cond_command: string end ServiceDef fn new_service_def(): ServiceDef return ServiceDef{ name: "", description: "", service_type: SERVICE_TYPE_DAEMON(), start_cmd: "", stop_cmd: "", working_dir: "", environment: new [string](16), environment_count: 0, user: "", group: "", stop_method: STOP_METHOD_CONTRACT(), stop_signal: "TERM", stop_timeout: 60, requires: new [string](16), requires_count: 0, after: new [string](16), after_count: 0, conflicts: new [string](16), conflicts_count: 0, contract_param: new [string](8), contract_param_count: 0, contract_fatal: new [string](8), contract_fatal_count: 0, restart_policy: RESTART_FAILURE(), restart_delay: 5, max_retries: 3, runlevel_mode: RUNLEVEL_MULTI(), cond_exists_file: "", cond_exists_file_any: new [string](16), cond_exists_file_any_count: 0, cond_command: "" } end new_service_def // ============================================================================ // Accessors // ============================================================================ fn svc_name(svc: ServiceDef): string return svc.name end svc_name fn svc_description(svc: ServiceDef): string return svc.description end svc_description fn svc_type(svc: ServiceDef): int return svc.service_type end svc_type fn svc_start_cmd(svc: ServiceDef): string return svc.start_cmd end svc_start_cmd fn svc_stop_cmd(svc: ServiceDef): string return svc.stop_cmd end svc_stop_cmd fn svc_working_dir(svc: ServiceDef): string return svc.working_dir end svc_working_dir fn svc_environment(svc: ServiceDef): [string] return svc.environment end svc_environment fn svc_environment_count(svc: ServiceDef): int return svc.environment_count end svc_environment_count fn svc_user(svc: ServiceDef): string return svc.user end svc_user fn svc_group(svc: ServiceDef): string return svc.group end svc_group fn svc_stop_method(svc: ServiceDef): int return svc.stop_method end svc_stop_method fn svc_stop_signal(svc: ServiceDef): string return svc.stop_signal end svc_stop_signal fn svc_stop_timeout(svc: ServiceDef): int return svc.stop_timeout end svc_stop_timeout fn svc_requires(svc: ServiceDef): [string] return svc.requires end svc_requires fn svc_requires_count(svc: ServiceDef): int return svc.requires_count end svc_requires_count fn svc_after(svc: ServiceDef): [string] return svc.after end svc_after fn svc_after_count(svc: ServiceDef): int return svc.after_count end svc_after_count fn svc_conflicts(svc: ServiceDef): [string] return svc.conflicts end svc_conflicts fn svc_conflicts_count(svc: ServiceDef): int return svc.conflicts_count end svc_conflicts_count fn svc_contract_param(svc: ServiceDef): [string] return svc.contract_param end svc_contract_param fn svc_contract_param_count(svc: ServiceDef): int return svc.contract_param_count end svc_contract_param_count fn svc_contract_fatal(svc: ServiceDef): [string] return svc.contract_fatal end svc_contract_fatal fn svc_contract_fatal_count(svc: ServiceDef): int return svc.contract_fatal_count end svc_contract_fatal_count fn svc_restart_policy(svc: ServiceDef): int return svc.restart_policy end svc_restart_policy fn svc_restart_delay(svc: ServiceDef): int return svc.restart_delay end svc_restart_delay fn svc_max_retries(svc: ServiceDef): int return svc.max_retries end svc_max_retries fn svc_runlevel_mode(svc: ServiceDef): int return svc.runlevel_mode end svc_runlevel_mode fn svc_cond_exists_file(svc: ServiceDef): string return svc.cond_exists_file end svc_cond_exists_file fn svc_cond_exists_file_any(svc: ServiceDef): [string] return svc.cond_exists_file_any end svc_cond_exists_file_any fn svc_cond_exists_file_any_count(svc: ServiceDef): int return svc.cond_exists_file_any_count end svc_cond_exists_file_any_count fn svc_cond_command(svc: ServiceDef): string return svc.cond_command end svc_cond_command // ============================================================================ // Human-readable names // ============================================================================ fn service_type_name(t: int): string if t == SERVICE_TYPE_DAEMON() return "daemon" elif t == SERVICE_TYPE_ONESHOT() return "oneshot" elif t == SERVICE_TYPE_TASK() return "task" end if return "unknown" end service_type_name fn restart_policy_name(p: int): string if p == RESTART_ALWAYS() return "always" elif p == RESTART_FAILURE() return "failure" elif p == RESTART_NEVER() return "never" end if return "unknown" end restart_policy_name fn stop_method_name(m: int): string if m == STOP_METHOD_CONTRACT() return "contract" elif m == STOP_METHOD_EXEC() return "exec" end if return "unknown" end stop_method_name fn runlevel_mode_name(m: int): string if m == RUNLEVEL_MULTI() return "multi" elif m == RUNLEVEL_SINGLE() return "single" elif m == RUNLEVEL_ALWAYS() return "always" end if return "unknown" end runlevel_mode_name // Parse runlevel mode string to constant fn parse_runlevel_mode(mode_str: string): int if mode_str == "multi" return RUNLEVEL_MULTI() elif mode_str == "single" return RUNLEVEL_SINGLE() elif mode_str == "always" return RUNLEVEL_ALWAYS() end if // Unknown/empty — default to multi (legacy behavior) return RUNLEVEL_MULTI() end parse_runlevel_mode // ============================================================================ // Parsing helpers // ============================================================================ // Parse service type string to constant fn parse_service_type(type_str: string): int if type_str == "daemon" return SERVICE_TYPE_DAEMON() elif type_str == "oneshot" return SERVICE_TYPE_ONESHOT() elif type_str == "task" return SERVICE_TYPE_TASK() end if if type_str == "transient" println("zyginit: warning: type='transient' is deprecated in 0.2.0 — treating as 'task' (rename in your TOML)") return SERVICE_TYPE_TASK() end if return SERVICE_TYPE_DAEMON() end parse_service_type // Parse restart policy string to constant fn parse_restart_policy(policy_str: string): int if policy_str == "always" return RESTART_ALWAYS() elif policy_str == "failure" return RESTART_FAILURE() elif policy_str == "never" return RESTART_NEVER() end if return RESTART_FAILURE() end parse_restart_policy // Parse stop method string to constant fn parse_stop_method(method_str: string): int if method_str == "contract" return STOP_METHOD_CONTRACT() elif method_str == "exec" return STOP_METHOD_EXEC() end if return STOP_METHOD_CONTRACT() end parse_stop_method // Read an inline-array TOML field from the flattened key/value model. // // Reef 0.7.5+ flattens `key = ["a", "b"]` into indexed keys `key.0`, `key.1`, // ... plus a synthetic `key.#len` count marker — there is no longer a single // `key` entry holding the raw "[...]" text. This reads up to `max_items` // elements of `prefix` into `result` and returns the number stored. A missing // field or empty array yields 0. fn read_toml_array(keys: [string], vals: [string], count: int, prefix: string, result: [string], max_items: int): int let len_key = str.concat(prefix, ".#len") if not toml.toml_has_key(keys, vals, count, len_key) return 0 end if let n = toml.toml_get_int(keys, vals, count, len_key) mut stored = 0 mut i = 0 while i < n and stored < max_items let item_key = str.concat(str.concat(prefix, "."), str.from_int(i)) if toml.toml_has_key(keys, vals, count, item_key) result[stored] = toml.toml_get(keys, vals, count, item_key) stored = stored + 1 end if i = i + 1 end while return stored end read_toml_array // ============================================================================ // Service parsing // ============================================================================ // Resolve a path that may start with "./" to an absolute path using // the service's directory as the base. Absolute paths (starting with "/") // and empty strings are returned unchanged. "./foo" becomes // "/foo". This allows service.toml to reference companion // scripts as "./start.sh" without hard-coding the install path. fn resolve_exec_path(raw: string, svc_dir: string): string let raw_len = str.length(raw) if raw_len == 0 return raw end if // Absolute paths pass through unchanged if raw[0] == '/' return raw end if // "./" prefix: strip the prefix and join with svc_dir if raw_len >= 2 if raw[0] == '.' and raw[1] == '/' let rel = str.substring(raw, 2, raw_len - 2) return str.concat(str.concat(svc_dir, "/"), rel) end if end if // No prefix — return as-is (let exec find it via PATH) return raw end resolve_exec_path // Parse a single service.toml file into a ServiceDef. // `name` — service name derived from the enclosing directory name. // The TOML need not (and should not) contain service.name. // `svc_dir` — absolute path to the directory containing this service.toml; // used to resolve "./"-prefixed exec paths. // Returns true on success. fn parse_service(filepath: string, name: string, svc_dir: string, svc: ServiceDef): bool if not file.fileExists(filepath) return false end if let content_r = file.readFile(filepath) if res.is_err(content_r) return false end if let content = res.unwrap_ok(content_r) if str.length(content) == 0 return false end if let keys = toml.toml_alloc_keys() let vals = toml.toml_alloc_values() let count_r = toml.toml_parse(content, keys, vals) if res.is_err(count_r) return false end if let count = res.unwrap_ok(count_r) if count == 0 return false end if // Service name comes from the directory name, not from a TOML field. svc.name = name // [service] section — all optional (type defaults to daemon) if toml.toml_has_key(keys, vals, count, "service.description") svc.description = toml.toml_get(keys, vals, count, "service.description") end if if toml.toml_has_key(keys, vals, count, "service.type") svc.service_type = parse_service_type(toml.toml_get(keys, vals, count, "service.type")) end if // [exec] section — start is required if not toml.toml_has_key(keys, vals, count, "exec.start") return false end if svc.start_cmd = resolve_exec_path( toml.toml_get(keys, vals, count, "exec.start"), svc_dir) if toml.toml_has_key(keys, vals, count, "exec.stop") svc.stop_cmd = resolve_exec_path( toml.toml_get(keys, vals, count, "exec.stop"), svc_dir) end if if toml.toml_has_key(keys, vals, count, "exec.working_directory") svc.working_dir = toml.toml_get(keys, vals, count, "exec.working_directory") end if svc.environment_count = read_toml_array(keys, vals, count, "exec.environment", svc.environment, 16) if toml.toml_has_key(keys, vals, count, "exec.user") svc.user = toml.toml_get(keys, vals, count, "exec.user") end if if toml.toml_has_key(keys, vals, count, "exec.group") svc.group = toml.toml_get(keys, vals, count, "exec.group") end if // [stop] section — all optional with defaults if toml.toml_has_key(keys, vals, count, "stop.method") svc.stop_method = parse_stop_method(toml.toml_get(keys, vals, count, "stop.method")) end if if toml.toml_has_key(keys, vals, count, "stop.signal") svc.stop_signal = toml.toml_get(keys, vals, count, "stop.signal") end if if toml.toml_has_key(keys, vals, count, "stop.timeout") svc.stop_timeout = toml.toml_get_int(keys, vals, count, "stop.timeout") end if // [dependencies] section — inline arrays svc.requires_count = read_toml_array(keys, vals, count, "dependencies.requires", svc.requires, 16) svc.after_count = read_toml_array(keys, vals, count, "dependencies.after", svc.after, 16) svc.conflicts_count = read_toml_array(keys, vals, count, "dependencies.conflicts", svc.conflicts, 16) // [contract] section — inline arrays svc.contract_param_count = read_toml_array(keys, vals, count, "contract.param", svc.contract_param, 8) svc.contract_fatal_count = read_toml_array(keys, vals, count, "contract.fatal", svc.contract_fatal, 8) // [restart] section — all optional with defaults if toml.toml_has_key(keys, vals, count, "restart.on") svc.restart_policy = parse_restart_policy(toml.toml_get(keys, vals, count, "restart.on")) end if if toml.toml_has_key(keys, vals, count, "restart.delay") svc.restart_delay = toml.toml_get_int(keys, vals, count, "restart.delay") end if if toml.toml_has_key(keys, vals, count, "restart.max_retries") svc.max_retries = toml.toml_get_int(keys, vals, count, "restart.max_retries") end if // [runlevel] section — optional, default mode="multi" if toml.toml_has_key(keys, vals, count, "runlevel.mode") svc.runlevel_mode = parse_runlevel_mode( toml.toml_get(keys, vals, count, "runlevel.mode")) end if // [condition] section — all optional if toml.toml_has_key(keys, vals, count, "condition.exists_file") svc.cond_exists_file = toml.toml_get(keys, vals, count, "condition.exists_file") end if svc.cond_exists_file_any_count = read_toml_array(keys, vals, count, "condition.exists_file_any", svc.cond_exists_file_any, 16) if toml.toml_has_key(keys, vals, count, "condition.command") svc.cond_command = toml.toml_get(keys, vals, count, "condition.command") end if return true end parse_service // ============================================================================ // Service scanning // ============================================================================ // Scan the enabled.d/ directory and return the names of enabled services. // // In the 0.2.x layout each entry in enabled.d/ is a symlink whose name is // the service name and whose target is the service's directory inside // services/. For example: // // enabled.d/sshd -> ../services/sshd/ // // We resolve every symlink via zyginit_readlink_resolved (realpath) and // reject entries that do not resolve to a path under // /services/. This prevents a symlink escaping the config // tree from loading an arbitrary TOML file. // // Returns the number of names written into `names`. fn scan_enabled(config_dir: string, names: [string], max_names: int): int let enabled_dir = str.concat(config_dir, "/enabled.d") let services_prefix = str.concat(config_dir, "/services/") let prefix_len = str.length(services_prefix) if not dir.dir_exists(enabled_dir) return 0 end if let entries_r = dir.list_dir(enabled_dir) if res.is_err(entries_r) return 0 end if let entries = res.unwrap_ok(entries_r) let entry_count = entries.length() mut name_count = 0 mut i = 0 while i < entry_count and name_count < max_names let entry = entries[i] let link_path = str.concat(str.concat(enabled_dir, "/"), entry) let target = zyginit_readlink_resolved(link_path) // Reject dangling symlinks (target == "") if str.length(target) == 0 println("zyginit: enabled.d/" + entry + ": dangling symlink — ignoring") else // Reject symlinks that escape the services/ subtree. // Inline starts-with check: compare the first prefix_len chars. let target_len = str.length(target) mut has_prefix = false if target_len >= prefix_len if str.substring(target, 0, prefix_len) == services_prefix has_prefix = true end if end if if not has_prefix println("zyginit: enabled.d/" + entry + ": target " + target + " is outside services/ — ignoring") else names[name_count] = entry name_count = name_count + 1 end if end if i = i + 1 end while return name_count end scan_enabled // Load all enabled services from config_dir. // // Walks /services/ to find enabled services (via scan_enabled), // then for each name locates /services//service.toml, // parses it, and loads it into the services array. // // If /services/ does not exist, logs a fatal-tier message and // returns 0. The caller (main.reef) treats a 0 count as "nothing to do" // and should handle this as an error condition. // // Returns the number of services loaded, or -1 if the services/ directory // does not exist (FATAL configuration error — operator must migrate first). fn load_enabled_services(config_dir: string, services: [ServiceDef], max_services: int): int let services_root = str.concat(config_dir, "/services") if not dir.dir_exists(services_root) println("zyginit: FATAL: services/ directory not found at " + services_root) println("zyginit: run scripts/migrate-layout.sh to convert a 0.1.x tree") return 0 - 1 end if let names = new [string](128) let name_count = scan_enabled(config_dir, names, 128) mut loaded = 0 mut i = 0 while i < name_count and loaded < max_services let name = names[i] let svc_dir = str.concat(str.concat(services_root, "/"), name) let toml_path = str.concat(svc_dir, "/service.toml") if not file.fileExists(toml_path) println("zyginit: services/" + name + "/ missing service.toml — skipping") else mut svc = new_service_def() if parse_service(toml_path, name, svc_dir, svc) services[loaded] = svc loaded = loaded + 1 else println("zyginit: services/" + name + "/service.toml parse error — skipping") end if end if i = i + 1 end while return loaded end load_enabled_services end module /****************************************************************************** __ ____ __ / / ___ ____ _/ __/_____________ _/ /__ / / / _ \/ __ `/ /_/ ___/ ___/ __ `/ / _ \ / /___/ __/ /_/ / __(__ ) /__/ /_/ / / __/ /_____/\___/\__,_/_/ /____/\___/\__,_/_/\___/ (C)opyright 2025-2026, Leafscale, LLC - https://www.leafscale.com Project: zyginit Filename: contract.reef Authors: Chris Tusa License: Description: Hammerhead process contract FFI bindings (libcontract) ******************************************************************************/ // Hammerhead process contracts are the kernel mechanism for tracking service // processes. This module provides Reef wrappers around libcontract. // // This module ONLY works on Hammerhead. On Linux, the extern "C" // functions will fail at link time unless stub implementations are provided. // // Reference: Hammerhead libcontract(3LIB), contract(5), process(5) // Source: hammerhead/usr/src/lib/libcontract/common/ module contract import sys.fd as fd import core.str export type Contract // Contract event flags (from sys/contract/process.h) fn CT_PR_EV_EMPTY(): int fn CT_PR_EV_FORK(): int fn CT_PR_EV_EXIT(): int fn CT_PR_EV_CORE(): int fn CT_PR_EV_SIGNAL(): int fn CT_PR_EV_HWERR(): int fn CT_PR_ALLEVENT(): int fn CT_PR_ALLFATAL(): int // Contract parameter flags fn CT_PR_INHERIT(): int fn CT_PR_NOORPHAN(): int fn CT_PR_PGRPONLY(): int fn CT_PR_REGENT(): int // Status detail levels (from sys/contract.h) fn CTD_COMMON(): int fn CTD_FIXED(): int fn CTD_ALL(): int // sigsend id type fn P_CTID(): int // CTFS paths fn CTFS_ROOT(): string fn CTFS_PROCESS_TEMPLATE(): string fn CTFS_PROCESS_LATEST(): string fn CTFS_PROCESS_BUNDLE(): string // Contract handle constructor fn new_contract(id: int, service_name: string): Contract // Contract accessors fn contract_id(ct: Contract): int fn contract_service(ct: Contract): string fn contract_bundle_fd(ct: Contract): int // Contract lifecycle fn setup_template(): int fn get_latest_contract(): int fn clear_template(tmpl_fd: int): int fn open_bundle(): int fn kill_contract(contract_id: int, sig: int): int fn abandon_contract(contract_id: int): int fn adopt_contract(contract_id: int): int fn is_contract_empty(contract_id: int): bool // Event reading fn read_event(bundle_fd: int, out_ctid: [int], out_type: [int]): bool fn ack_event(contract_id: int, event_id: int): int end export // ============================================================================ // FFI declarations — libcontract // ============================================================================ // Template management extern "C" fn ct_tmpl_activate(fd: int): int extern "C" fn ct_tmpl_clear(fd: int): int extern "C" fn ct_tmpl_set_informative(fd: int, events: int): int extern "C" fn ct_tmpl_set_critical(fd: int, events: int): int // Process contract template extern "C" fn ct_pr_tmpl_set_fatal(fd: int, events: int): int extern "C" fn ct_pr_tmpl_set_param(fd: int, params: int): int // Contract status — ct_stathdl_t is an opaque void* extern "C" fn ct_status_read(fd: int, detail: int, statp: pointer): int extern "C" proc ct_status_free(stathdl: pointer) extern "C" fn ct_status_get_id(stathdl: pointer): int // Process-contract member list — fills *members and *n. Memory is // owned by the stathdl, freed by ct_status_free. Requires the status // to have been read at CTD_FIXED detail or higher (CTD_COMMON does // not include the member list). extern "C" fn ct_pr_status_get_members(stathdl: pointer, members: [pointer], n: [int]): int // Contract control extern "C" fn ct_ctl_abandon(fd: int): int extern "C" fn ct_ctl_adopt(fd: int): int // Contract events — ct_evthdl_t is an opaque void* extern "C" fn ct_event_read(fd: int, evtp: pointer): int extern "C" proc ct_event_free(evthdl: pointer) extern "C" fn ct_event_get_ctid(evthdl: pointer): int extern "C" fn ct_event_get_type(evthdl: pointer): int extern "C" fn ct_event_get_flags(evthdl: pointer): int // Process contract event details extern "C" fn ct_pr_event_get_exitstatus(evthdl: pointer, status: pointer): int extern "C" fn ct_pr_event_get_pid(evthdl: pointer, pid: pointer): int // POSIX — sigsend for contract-wide signaling extern "C" fn sigsend(idtype: int, id: int, sig: int): int // POSIX — open/close for ctfs paths extern "C" fn open(path: string, flags: int): int extern "C" fn close(fd: int): int // ============================================================================ // Constants — values from Hammerhead kernel headers // ============================================================================ // Contract event flags (sys/contract/process.h) fn CT_PR_EV_EMPTY(): int return 1 end CT_PR_EV_EMPTY fn CT_PR_EV_FORK(): int return 2 end CT_PR_EV_FORK fn CT_PR_EV_EXIT(): int return 4 end CT_PR_EV_EXIT fn CT_PR_EV_CORE(): int return 8 end CT_PR_EV_CORE fn CT_PR_EV_SIGNAL(): int return 16 end CT_PR_EV_SIGNAL fn CT_PR_EV_HWERR(): int return 32 end CT_PR_EV_HWERR fn CT_PR_ALLEVENT(): int return 63 end CT_PR_ALLEVENT fn CT_PR_ALLFATAL(): int return 56 end CT_PR_ALLFATAL // Contract parameter flags (sys/contract/process.h) fn CT_PR_INHERIT(): int return 1 end CT_PR_INHERIT fn CT_PR_NOORPHAN(): int return 2 end CT_PR_NOORPHAN fn CT_PR_PGRPONLY(): int return 4 end CT_PR_PGRPONLY fn CT_PR_REGENT(): int return 8 end CT_PR_REGENT // Status detail levels (sys/contract.h) fn CTD_COMMON(): int return 0 end CTD_COMMON fn CTD_FIXED(): int return 1 end CTD_FIXED fn CTD_ALL(): int return 2 end CTD_ALL // sigsend id type for contract (sys/procset.h enum position 13, // between P_ZONEID=12 and P_CPUID=14). Verified on Hammerhead via // utils/contract_probe. fn P_CTID(): int return 13 end P_CTID // CTFS paths fn CTFS_ROOT(): string return "/system/contract" end CTFS_ROOT fn CTFS_PROCESS_TEMPLATE(): string return "/system/contract/process/template" end CTFS_PROCESS_TEMPLATE fn CTFS_PROCESS_LATEST(): string return "/system/contract/process/latest" end CTFS_PROCESS_LATEST fn CTFS_PROCESS_BUNDLE(): string return "/system/contract/process/bundle" end CTFS_PROCESS_BUNDLE // Open flags fn O_RDONLY(): int return 0 end O_RDONLY fn O_WRONLY(): int return 1 end O_WRONLY fn O_RDWR(): int return 2 end O_RDWR // ============================================================================ // Contract type // ============================================================================ type Contract = struct id: int service_name: string bundle_fd: int end Contract fn new_contract(id: int, service_name: string): Contract return Contract{ id: id, service_name: service_name, bundle_fd: 0 - 1 } end new_contract fn contract_id(ct: Contract): int return ct.id end contract_id fn contract_service(ct: Contract): string return ct.service_name end contract_service fn contract_bundle_fd(ct: Contract): int return ct.bundle_fd end contract_bundle_fd // ============================================================================ // Contract lifecycle functions // ============================================================================ // Set up a contract template for forking a service. // Configures: informative=EMPTY, critical=EMPTY|HWERR, fatal=HWERR, // params=INHERIT|NOORPHAN. Returns template fd on success, -1 on error. fn setup_template(): int let tmpl_fd = open(CTFS_PROCESS_TEMPLATE(), O_RDWR()) if tmpl_fd < 0 return 0 - 1 end if // Want informative notification when contract becomes empty let rc1 = ct_tmpl_set_informative(tmpl_fd, CT_PR_EV_EMPTY()) // Critical events: empty (all exited) and hardware error let rc2 = ct_tmpl_set_critical(tmpl_fd, CT_PR_EV_EMPTY() + CT_PR_EV_HWERR()) // Fatal events: hardware error causes contract death let rc3 = ct_pr_tmpl_set_fatal(tmpl_fd, CT_PR_EV_HWERR()) // Parameters: inherit contracts on fork, kill orphans on abandon let rc4 = ct_pr_tmpl_set_param(tmpl_fd, CT_PR_INHERIT() + CT_PR_NOORPHAN()) if rc1 != 0 or rc2 != 0 or rc3 != 0 or rc4 != 0 close(tmpl_fd) return 0 - 1 end if let rc5 = ct_tmpl_activate(tmpl_fd) if rc5 != 0 close(tmpl_fd) return 0 - 1 end if return tmpl_fd end setup_template // After fork(), retrieve the contract ID for the new child process. // The kernel writes the latest contract to /system/contract/process/latest. // Returns contract ID on success, -1 on error. fn get_latest_contract(): int let latest_fd = open(CTFS_PROCESS_LATEST(), O_RDONLY()) if latest_fd < 0 return 0 - 1 end if // ct_status_read writes a handle (void*) to an output parameter. // We pass a [pointer] array — in C, this is void**, exactly what ct_status_read expects. unsafe let stathdl_buf = new [pointer](1) let rc = ct_status_read(latest_fd, CTD_COMMON(), stathdl_buf) close(latest_fd) if rc != 0 return 0 - 1 end if let stathdl = stathdl_buf[0] let ctid = ct_status_get_id(stathdl) ct_status_free(stathdl) return ctid end unsafe end get_latest_contract // Clear (deactivate) a contract template after fork. // Returns 0 on success. fn clear_template(tmpl_fd: int): int let rc = ct_tmpl_clear(tmpl_fd) close(tmpl_fd) return rc end clear_template // Open the contract bundle fd for poll()-based event monitoring. // Returns bundle fd on success, -1 on error. fn open_bundle(): int // O_NONBLOCK (0x80 on Hammerhead): ct_event_read on a blocking fd // BLOCKS waiting for events. Our main loop only enters // handle_contract_events when poll says the fd is readable, but // after we drain the events present at that moment, the next // ct_event_read call sees no event and would block indefinitely // — wedging the entire main loop, including reap_children for // SIGCHLD. With O_NONBLOCK, ct_event_read returns EAGAIN once // we've drained, read_event returns false, the while loop in // handle_contract_events exits cleanly. let bundle_fd = open(CTFS_PROCESS_BUNDLE(), O_RDONLY() + 0x80) return bundle_fd end open_bundle // Kill all processes in a contract with the given signal. // Uses sigsend(P_CTID, contract_id, signal). // Returns 0 on success, -1 on error. fn kill_contract(contract_id: int, sig: int): int return sigsend(P_CTID(), contract_id, sig) end kill_contract // Abandon a contract (release ownership, orphan handling applies). // Opens the contract's ctl fd and issues abandon. // Returns 0 on success, -1 on error. fn abandon_contract(contract_id: int): int // Build path: /system/contract/process//ctl // ctl file mode is --w--w--w-, so open with O_WRONLY (O_RDWR fails). let ctl_path = str.concat(str.concat("/system/contract/process/", int_to_str(contract_id)), "/ctl") let ctl_fd = open(ctl_path, O_WRONLY()) if ctl_fd < 0 return 0 - 1 end if let rc = ct_ctl_abandon(ctl_fd) close(ctl_fd) return rc end abandon_contract // Adopt an existing contract (for zyginit restart recovery). // Opens the contract's ctl fd and issues adopt. // Returns 0 on success, -1 on error. fn adopt_contract(contract_id: int): int let ctl_path = str.concat(str.concat("/system/contract/process/", int_to_str(contract_id)), "/ctl") let ctl_fd = open(ctl_path, O_WRONLY()) if ctl_fd < 0 return 0 - 1 end if let rc = ct_ctl_adopt(ctl_fd) close(ctl_fd) return rc end adopt_contract // Check whether a contract has zero member processes. Used by the // post-recovery idempotency step in main.apply_recovered_state — if a // contract was reported in state.toml but the kernel says it's empty, // the service exited during the exec gap and we should apply restart // policy as if a contract-empty event arrived. // // Returns true if the contract has zero members OR if the status // read fails (treat unknown as empty so the recovery path applies // restart policy rather than leaving a phantom service in RUNNING). fn is_contract_empty(contract_id: int): bool let status_path = str.concat(str.concat("/system/contract/process/", int_to_str(contract_id)), "/status") let st_fd = open(status_path, O_RDONLY()) if st_fd < 0 return true end if unsafe let stathdl_buf = new [pointer](1) // CTD_FIXED includes process-contract-specific fields like the // member-PID list, which CTD_COMMON omits. let rc = ct_status_read(st_fd, CTD_FIXED(), stathdl_buf) close(st_fd) if rc != 0 return true end if let stathdl = stathdl_buf[0] let members_buf = new [pointer](1) let n_buf = new [int](1) let mrc = ct_pr_status_get_members(stathdl, members_buf, n_buf) let n = n_buf[0] ct_status_free(stathdl) if mrc != 0 return true end if return n == 0 end unsafe end is_contract_empty // ============================================================================ // Event reading // ============================================================================ // Read an event from the contract bundle fd. // Writes the contract ID to out_ctid[0] and event type to out_type[0]. // Returns true on success, false on error or no event. fn read_event(bundle_fd: int, out_ctid: [int], out_type: [int]): bool unsafe let evthdl_buf = new [pointer](1) let rc = ct_event_read(bundle_fd, evthdl_buf) if rc != 0 return false end if let evthdl = evthdl_buf[0] out_ctid[0] = ct_event_get_ctid(evthdl) out_type[0] = ct_event_get_type(evthdl) ct_event_free(evthdl) return true end unsafe end read_event // Acknowledge a contract event. // Opens the contract's event fd and issues ack. fn ack_event(contract_id: int, event_id: int): int let ev_path = str.concat(str.concat("/system/contract/process/", int_to_str(contract_id)), "/events") let ev_fd = open(ev_path, O_RDONLY()) if ev_fd < 0 return 0 - 1 end if // For now, we just close — full ack requires ct_ctl_ack on the ctl fd close(ev_fd) return 0 end ack_event // ============================================================================ // Helpers // ============================================================================ // Convert int to string (for building ctfs paths) 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 /* * contract_linux_stubs.c — Linux stub implementations for Hammerhead libcontract FFI * * These stubs allow zyginit to compile and link on Linux for development. * All functions return error codes indicating contracts are not available. * On Hammerhead, DO NOT compile this file — link with -lcontract instead. */ #include /* Template management */ int ct_tmpl_activate(int fd) { return -1; } int ct_tmpl_clear(int fd) { return -1; } int ct_tmpl_set_informative(int fd, int events) { return -1; } int ct_tmpl_set_critical(int fd, int events) { return -1; } /* Process contract template */ int ct_pr_tmpl_set_fatal(int fd, int events) { return -1; } int ct_pr_tmpl_set_param(int fd, int params) { return -1; } /* Contract status */ int ct_status_read(int fd, int detail, void **statp) { return -1; } void ct_status_free(void *stathdl) { } int ct_status_get_id(void *stathdl) { return -1; } int ct_status_get_holder(void *stathdl) { return -1; } int ct_status_get_state(void *stathdl) { return -1; } int ct_pr_status_get_members(void *stathdl, void **members, unsigned int *n) { (void)stathdl; if (members != NULL) *members = NULL; if (n != NULL) *n = 0; return 0; } /* Contract control */ int ct_ctl_abandon(int fd) { return -1; } int ct_ctl_adopt(int fd) { return -1; } /* Contract events */ int ct_event_read(int fd, void **evtp) { return -1; } void ct_event_free(void *evthdl) { } int ct_event_get_ctid(void *evthdl) { return -1; } int ct_event_get_type(void *evthdl) { return -1; } int ct_event_get_flags(void *evthdl) { return -1; } /* Process contract event details */ int ct_pr_event_get_exitstatus(void *evthdl, int *status) { return -1; } int ct_pr_event_get_pid(void *evthdl, int *pid) { return -1; } /* POSIX — sigsend (Hammerhead-specific) */ int sigsend(int idtype, int id, int sig) { return -1; } /****************************************************************************** __ ____ __ / / ___ ____ _/ __/_____________ _/ /__ / / / _ \/ __ `/ /_/ ___/ ___/ __ `/ / _ \ / /___/ __/ /_/ / __(__ ) /__/ /_/ / / __/ /_____/\___/\__,_/_/ /____/\___/\__,_/_/\___/ (C)opyright 2025-2026, Leafscale, LLC - https://www.leafscale.com Project: zyginit Filename: ctlsocket.reef Authors: Chris Tusa License: Description: Unix domain socket server for zygctl communication ******************************************************************************/ // Protocol: newline-terminated text commands and responses. // Request: "COMMAND [ARG]\n" // Response: plain text lines, connection closed after response. module ctlsocket import net.unix as unix import io.file import io.dir import sys.env as env import supervisor import config import shutdown import core.str import core.result as res import version import ui import ui_render export fn create_socket(path: string): int proc destroy_socket(path: string, server_fd: int) proc handle_client(server_fd: int, table: supervisor.ServiceTable) fn reload_requested(): bool proc clear_reload_flag() fn replace_requested(): bool proc clear_replace_flag() fn replace_wait_seconds(): int fn shutdown_requested(): int proc clear_shutdown_request() fn runlevel_requested(): int proc clear_runlevel_request() end export extern "C" fn zyginit_symlink(target: string, linkpath: string): int extern "C" fn zyginit_fsync_path(path: string): int // Reload flag — set by "reload" command, checked by main event loop mut g_reload_requested: bool = false fn reload_requested(): bool return g_reload_requested end reload_requested proc clear_reload_flag() g_reload_requested = false end clear_reload_flag // Replace flag — set by "replace" command, checked by main event loop. // Wait seconds is the optional --wait=N value; 0 means "refuse immediately // on transient state". mut g_replace_requested: bool = false mut g_replace_wait: int = 0 fn replace_requested(): bool return g_replace_requested end replace_requested proc clear_replace_flag() g_replace_requested = false g_replace_wait = 0 end clear_replace_flag fn replace_wait_seconds(): int return g_replace_wait end replace_wait_seconds // Shutdown request flag — set by halt/reboot/poweroff commands, // checked by main event loop each iteration. // 0 = no shutdown requested; non-zero = SHUT_HALT/REBOOT/POWEROFF value. mut g_shutdown_request: int = 0 fn shutdown_requested(): int return g_shutdown_request end shutdown_requested proc clear_shutdown_request() g_shutdown_request = 0 end clear_shutdown_request // Runlevel transition request — set by `single` or `multi` socket // commands, picked up by main loop and applied via runlevel_transition. // Sentinel values: -1 = no request; 0 = MULTI; 1 = SINGLE (matching // main.reef's RUNLEVEL_* constants). mut g_runlevel_request: int = 0 - 1 fn runlevel_requested(): int return g_runlevel_request end runlevel_requested proc clear_runlevel_request() g_runlevel_request = 0 - 1 end clear_runlevel_request // ============================================================================ // Socket lifecycle // ============================================================================ // 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) if unlink_rc < 0 println("socket: unlink failed (non-fatal): " + path) end if let listen_r = unix.unix_listen(path, 5) if res.is_err(listen_r) println("socket: failed to listen on " + path) return 0 - 1 end if let fd = res.unwrap_ok(listen_r) println("socket: listening on " + path) return fd end create_socket // Close the socket and remove the socket file. Called during shutdown. // We fsync the parent directory after unlink to force the ZFS txg // commit before uadmin reboots — otherwise the next boot sees a stale // socket file in /var/run and create_socket fails (unlink runs again // but bind hits residual state). Without this, zygctl can't connect // after a zygctl-triggered reboot. proc destroy_socket(path: string, server_fd: int) println("socket: closing server fd") unix.unix_close(server_fd) let rc = unix.unix_unlink(path) if rc < 0 println("socket: unlink failed: " + path) else println("socket: unlinked " + path) end if let _ = zyginit_fsync_path(path) end destroy_socket // ============================================================================ // Client handling // ============================================================================ // Accept a client connection, read command, dispatch, respond, close. proc handle_client(server_fd: int, table: supervisor.ServiceTable) let accept_r = unix.unix_accept(server_fd) if res.is_err(accept_r) return end if let client_fd = res.unwrap_ok(accept_r) // Read the command (max 1024 bytes) let recv_r = unix.unix_recv(client_fd, 1024) if res.is_err(recv_r) unix.unix_close(client_fd) return end if let input = res.unwrap_ok(recv_r) if str.length(input) == 0 unix.unix_close(client_fd) return end if // Strip trailing newline/whitespace let cmd_raw = str.trim_ws(input) // Parse command and argument let space_pos = str.index_of(cmd_raw, " ") mut command = cmd_raw mut arg = "" if space_pos >= 0 command = str.substring(cmd_raw, 0, space_pos) arg = str.substring(cmd_raw, space_pos + 1, str.length(cmd_raw) - space_pos - 1) end if // Dispatch let response = dispatch_command(command, arg, table) // Send response and close unix.unix_send(client_fd, response) unix.unix_close(client_fd) end handle_client // ============================================================================ // Command dispatch // ============================================================================ fn dispatch_command(command: string, arg: string, table: supervisor.ServiceTable): string if command == "status" // Parse optional PLAIN modifier: "PLAIN" or "PLAIN " mut plain = 0 mut svc_arg = arg if str.length(arg) >= 5 and str.substring(arg, 0, 5) == "plain" if str.length(arg) == 5 plain = 1 svc_arg = "" elif str.substring(arg, 5, 1) == " " plain = 1 svc_arg = str.substring(arg, 6, str.length(arg) - 6) end if end if return cmd_status(table, svc_arg, plain) elif command == "start" return cmd_start(table, arg) elif command == "stop" return cmd_stop(table, arg) elif command == "restart" return cmd_restart(table, arg) elif command == "log" return cmd_log(table, arg) elif command == "reload" g_reload_requested = true return "reload initiated\n" elif command == "replace" // Optional --wait=N argument mut wait_seconds = 0 if str.length(arg) > 0 // Parse --wait=N if str.substring(arg, 0, 7) == "--wait=" let val = str.substring(arg, 7, str.length(arg) - 7) wait_seconds = parse_replace_int_or(val, 0) else return "error: replace: unknown argument: " + arg + "\n" end if end if g_replace_requested = true g_replace_wait = wait_seconds return str.concat("replace queued (wait=", str.concat(int_to_str(wait_seconds), ")\n")) elif command == "halt" g_shutdown_request = shutdown.SHUT_HALT() return "halting\n" elif command == "reboot" g_shutdown_request = shutdown.SHUT_REBOOT() return "rebooting\n" elif command == "poweroff" g_shutdown_request = shutdown.SHUT_POWEROFF() return "powering off\n" elif command == "single" g_runlevel_request = 1 // RUNLEVEL_SINGLE return "transitioning to single-user\n" elif command == "multi" g_runlevel_request = 0 // RUNLEVEL_MULTI return "transitioning to multi-user\n" elif command == "multiuser" // Legacy alias for backward compatibility g_runlevel_request = 0 return "transitioning to multi-user\n" elif command == "enable" return cmd_enable(arg) elif command == "disable" return cmd_disable(arg) elif command == "list" // Parse optional PLAIN modifier mut list_plain = 0 if arg == "plain" list_plain = 1 end if return cmd_list(table, list_plain) elif command == "version" return "zyginit " + version.VERSION() + "\n" elif command == "help" return cmd_help() end if return "error: unknown command: " + command + "\n" end dispatch_command // ============================================================================ // Command implementations // ============================================================================ fn cmd_status(table: supervisor.ServiceTable, arg: string, plain: int): string let count = supervisor.service_count(table) if str.length(arg) > 0 // Status of a single service — honor the plain flag from the caller let idx = supervisor.find_service(table, arg) if idx < 0 return "error: unknown service: " + arg + "\n" end if return ui_render.ui_render_status_one(table, idx, plain) end if // Status of all services if count == 0 return "no services loaded\n" end if // plain=1: no banner, no escapes; plain=0: banner + colors via daemon's g_mode return ui_render.ui_render_status(table, 1, plain) end cmd_status fn cmd_start(table: supervisor.ServiceTable, name: string): string if str.length(name) == 0 return "error: usage: start \n" end if let idx = supervisor.find_service(table, name) if idx < 0 return "error: unknown service: " + name + "\n" end if let rt = supervisor.get_runtime(table, idx) let state = supervisor.rt_state(rt) if state == supervisor.STATE_RUNNING() return name + " is already running\n" end if let conflict_peer = supervisor.conflicting_running_peer(table, idx) if conflict_peer >= 0 let other = supervisor.service_name_at(table, conflict_peer) return "error: cannot start " + name + ": conflicts with running service " + other + "\n" end if if supervisor.start_service(table, idx) return name + " started\n" end if return "error: failed to start " + name + "\n" end cmd_start fn cmd_stop(table: supervisor.ServiceTable, name: string): string if str.length(name) == 0 return "error: usage: stop \n" end if let idx = supervisor.find_service(table, name) if idx < 0 return "error: unknown service: " + name + "\n" end if if supervisor.stop_service(table, idx) return name + " stopping\n" end if return name + " is not running\n" end cmd_stop fn cmd_restart(table: supervisor.ServiceTable, name: string): string if str.length(name) == 0 return "error: usage: restart \n" end if let idx = supervisor.find_service(table, name) if idx < 0 return "error: unknown service: " + name + "\n" end if let rt = supervisor.get_runtime(table, idx) let state = supervisor.rt_state(rt) if state == supervisor.STATE_RUNNING() supervisor.stop_service(table, idx) end if // Note: actual restart happens via the event loop when the stop completes // and restart policy triggers. For immediate restart of a stopped service: if state != supervisor.STATE_RUNNING() if supervisor.start_service(table, idx) return name + " restarted\n" end if return "error: failed to restart " + name + "\n" end if return name + " stop initiated (will restart via policy)\n" end cmd_restart fn cmd_list(table: supervisor.ServiceTable, plain: int): string return ui_render.ui_render_list(table, plain) end cmd_list fn cmd_log(table: supervisor.ServiceTable, name: string): string if str.length(name) == 0 return "error: usage: log \n" end if let idx = supervisor.find_service(table, name) if idx < 0 return "error: unknown service: " + name + "\n" end if return supervisor.get_service_log(name) end cmd_log fn cmd_enable(name: string): string if str.length(name) == 0 return "error: usage: enable \n" end if let config_dir = env.get_env_or("ZYGINIT_CONFIG_DIR", "/etc/zyginit") let toml_path = str.concat(str.concat(config_dir, "/"), str.concat(name, ".toml")) if not file.fileExists(toml_path) return "error: no service definition found: " + toml_path + "\n" end if let enabled_dir = str.concat(config_dir, "/enabled.d") if not dir.dir_exists(enabled_dir) if res.is_err(dir.create_dir(enabled_dir)) return "error: cannot create " + enabled_dir + "\n" end if end if let link_path = str.concat(str.concat(enabled_dir, "/"), name) if file.fileExists(link_path) return name + " is already enabled\n" end if let target = str.concat("../", str.concat(name, ".toml")) let rc = zyginit_symlink(target, link_path) if rc < 0 return "error: failed to create symlink for " + name + "\n" end if return name + " enabled\n" end cmd_enable fn cmd_disable(name: string): string if str.length(name) == 0 return "error: usage: disable \n" end if let config_dir = env.get_env_or("ZYGINIT_CONFIG_DIR", "/etc/zyginit") let enabled_dir = str.concat(config_dir, "/enabled.d") let link_path = str.concat(str.concat(enabled_dir, "/"), name) if not file.fileExists(link_path) return name + " is not enabled\n" end if if res.is_ok(file.deleteFile(link_path)) return name + " disabled\n" end if return "error: failed to remove " + link_path + "\n" end cmd_disable fn cmd_help(): string return "commands: status [name], start , stop , restart , enable , disable , reload, replace [--wait=N], halt, reboot, poweroff, single, multiuser, log , list, version, help\n" end cmd_help // ============================================================================ // Local helper functions // ============================================================================ // Convert an integer to a string 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 // Local int parser for replace --wait=N. Negative numbers and non-numeric // input both yield default_val. Empty string also yields default. fn parse_replace_int_or(s: string, default_val: int): int let n = str.length(s) if n == 0 return default_val end if mut result = 0 mut i = 0 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 return result end parse_replace_int_or end module /****************************************************************************** __ ____ __ / / ___ ____ _/ __/_____________ _/ /__ / / / _ \/ __ `/ /_/ ___/ ___/ __ `/ / _ \ / /___/ __/ /_/ / __(__ ) /__/ /_/ / / __/ /_____/\___/\__,_/_/ /____/\___/\__,_/_/\___/ (C)opyright 2025-2026, Leafscale, LLC - https://www.leafscale.com Project: zyginit Filename: depgraph.reef Authors: Chris Tusa License: Description: Dependency graph and topological sort for service boot order ******************************************************************************/ module depgraph import config import collections.hashmap as hashmap import core.str import core.option as opt export type DepGraph fn MAX_SERVICES(): int fn MAX_DEPS(): int fn new_depgraph(): DepGraph fn add_service(graph: DepGraph, name: string): int fn add_dependency(graph: DepGraph, name: string, dep_name: string): bool fn build_graph(graph: DepGraph, services: [config.ServiceDef], count: int): bool fn topo_sort(graph: DepGraph, tiers: [string], tier_counts: [int], max_tiers: int): int fn has_cycle(graph: DepGraph): bool fn service_count(graph: DepGraph): int fn service_name_at(graph: DepGraph, idx: int): string end export // ============================================================================ // Constants // ============================================================================ fn MAX_SERVICES(): int return 128 end MAX_SERVICES fn MAX_DEPS(): int return 16 end MAX_DEPS // ============================================================================ // DepGraph type // ============================================================================ type DepGraph = struct // Service names indexed 0..count-1 names: [string] count: int // Name to index lookup name_map: hashmap.HashMap[int] // Adjacency: flattened array, deps[i * MAX_DEPS + j] = index of dep j for service i deps: [int] dep_count: [int] // In-degree for topological sort in_degree: [int] end DepGraph fn new_depgraph(): DepGraph let max = MAX_SERVICES() let max_d = MAX_DEPS() let g = DepGraph{ names: new [string](max), count: 0, name_map: hashmap.create[:int](0 - 1), deps: new [int](max * max_d), dep_count: new [int](max), in_degree: new [int](max) } // Initialize dep_count and in_degree to 0 mut i = 0 while i < max g.dep_count[i] = 0 g.in_degree[i] = 0 i = i + 1 end while return g end new_depgraph // ============================================================================ // Graph construction // ============================================================================ // Add a service node to the graph. Returns its index, or -1 if full. fn add_service(graph: DepGraph, name: string): int // Check if already exists let existing: opt.Option[int] = graph.name_map.get(name) if opt.is_some(existing) return opt.unwrap(existing) end if if graph.count >= MAX_SERVICES() return 0 - 1 end if let idx = graph.count graph.names[idx] = name graph.name_map.set(name, idx) graph.dep_count[idx] = 0 graph.in_degree[idx] = 0 graph.count = graph.count + 1 return idx end add_service // Add a dependency edge: "name" depends on "dep_name". // Both must already be in the graph. Returns true on success. fn add_dependency(graph: DepGraph, name: string, dep_name: string): bool if not graph.name_map.has(name) return false end if if not graph.name_map.has(dep_name) return false end if let from_opt: opt.Option[int] = graph.name_map.get(name) let to_opt: opt.Option[int] = graph.name_map.get(dep_name) let from_idx = opt.unwrap(from_opt) let to_idx = opt.unwrap(to_opt) let dc = graph.dep_count[from_idx] if dc >= MAX_DEPS() return false end if // Check for duplicate edge let base = from_idx * MAX_DEPS() mut i = 0 while i < dc if graph.deps[base + i] == to_idx return true end if i = i + 1 end while // Add edge: from_idx depends on to_idx graph.deps[base + dc] = to_idx graph.dep_count[from_idx] = dc + 1 // Increment in-degree of from_idx (it has one more dependency) graph.in_degree[from_idx] = graph.in_degree[from_idx] + 1 return true end add_dependency // Build the dependency graph from an array of ServiceDefs. // Returns true on success, false if a cycle is detected. fn build_graph(graph: DepGraph, services: [config.ServiceDef], count: int): bool // First pass: add all service nodes mut i = 0 while i < count let name = config.svc_name(services[i]) if str.length(name) > 0 add_service(graph, name) end if i = i + 1 end while // Second pass: add dependency edges i = 0 while i < count let name = config.svc_name(services[i]) // Add "requires" edges let req = config.svc_requires(services[i]) let req_count = config.svc_requires_count(services[i]) mut j = 0 while j < req_count let dep_name = req[j] if graph.name_map.has(dep_name) add_dependency(graph, name, dep_name) else println("depgraph: warning: " + name + " requires unknown service: " + dep_name) end if j = j + 1 end while // Add "after" edges (ordering only, same graph treatment) let aft = config.svc_after(services[i]) let aft_count = config.svc_after_count(services[i]) j = 0 while j < aft_count let dep_name = aft[j] if graph.name_map.has(dep_name) add_dependency(graph, name, dep_name) end if // Silently skip unknown "after" deps — they are soft ordering hints j = j + 1 end while i = i + 1 end while // Check for cycles if has_cycle(graph) println("depgraph: error: dependency cycle detected") return false end if return true end build_graph // ============================================================================ // Topological sort (Kahn's algorithm) // ============================================================================ // Perform topological sort and group services into parallel tiers. // Returns the number of tiers. Tiers are written into tiers[] as a flat // array, with tier_counts[t] indicating how many services are in tier t. // Services within a tier can start in parallel. fn topo_sort(graph: DepGraph, tiers: [string], tier_counts: [int], max_tiers: int): int let n = graph.count if n == 0 return 0 end if // Copy in_degree to working array (preserve original) let work_degree = new [int](MAX_SERVICES()) mut i = 0 while i < n work_degree[i] = graph.in_degree[i] i = i + 1 end while // Track which nodes have been placed let placed = new [bool](MAX_SERVICES()) i = 0 while i < n placed[i] = false i = i + 1 end while mut total_placed = 0 mut tier_idx = 0 mut tier_offset = 0 while total_placed < n and tier_idx < max_tiers // Find all nodes with in_degree == 0 that haven't been placed mut tier_size = 0 i = 0 while i < n if not placed[i] and work_degree[i] == 0 tiers[tier_offset + tier_size] = graph.names[i] tier_size = tier_size + 1 placed[i] = true end if i = i + 1 end while if tier_size == 0 // No progress — remaining nodes form a cycle break end if tier_counts[tier_idx] = tier_size // Decrement in_degree for all nodes that depend on placed nodes // For each placed node in this tier, find who depends on it mut t = 0 while t < tier_size let placed_name = tiers[tier_offset + t] let placed_opt: opt.Option[int] = graph.name_map.get(placed_name) let placed_idx = opt.unwrap(placed_opt) // Scan all nodes to find those that depend on placed_idx i = 0 while i < n if not placed[i] let base = i * MAX_DEPS() let dc = graph.dep_count[i] mut d = 0 while d < dc if graph.deps[base + d] == placed_idx work_degree[i] = work_degree[i] - 1 end if d = d + 1 end while end if i = i + 1 end while t = t + 1 end while total_placed = total_placed + tier_size tier_offset = tier_offset + tier_size tier_idx = tier_idx + 1 end while if total_placed < n println("depgraph: error: cycle detected, could not place all services") end if return tier_idx end topo_sort // ============================================================================ // Cycle detection // ============================================================================ // Check for cycles using iterative Kahn's — if we can't place all nodes, // there's a cycle. fn has_cycle(graph: DepGraph): bool let n = graph.count if n == 0 return false end if let work_degree = new [int](MAX_SERVICES()) mut i = 0 while i < n work_degree[i] = graph.in_degree[i] i = i + 1 end while // Queue-based Kahn's — just count how many we can place let queue = new [int](MAX_SERVICES()) mut head = 0 mut tail = 0 // Enqueue all nodes with in_degree 0 i = 0 while i < n if work_degree[i] == 0 queue[tail] = i tail = tail + 1 end if i = i + 1 end while mut placed = 0 while head < tail let node = queue[head] head = head + 1 placed = placed + 1 // For each other node, if it depends on this node, decrement i = 0 while i < n let base = i * MAX_DEPS() let dc = graph.dep_count[i] mut d = 0 while d < dc if graph.deps[base + d] == node work_degree[i] = work_degree[i] - 1 if work_degree[i] == 0 queue[tail] = i tail = tail + 1 end if end if d = d + 1 end while i = i + 1 end while end while return placed < n end has_cycle // ============================================================================ // Accessors // ============================================================================ fn service_count(graph: DepGraph): int return graph.count end service_count fn service_name_at(graph: DepGraph, idx: int): string if idx >= 0 and idx < graph.count return graph.names[idx] end if return "" end service_name_at end module /* * helpers.c — Portable C helpers for zyginit * * Thin wrappers around POSIX functions that need: * - const char* bridging for Reef FFI (which emits char*) * - multi-step sequences grouped for atomicity (e.g. drop_privileges) * - passwd/group lookups returning simple int values * * Compiled on both Linux and Hammerhead. On Hammerhead it is passed alongside * -lcontract; on Linux it is passed alongside contract_linux_stubs.o. */ #include #include #include #include #include #include #include #include #include #include #ifdef __sun #include #include #endif /* Wrapper for symlink() — avoids const char* vs char* conflict with Reef FFI */ int zyginit_symlink(char *target, char *linkpath) { return symlink(target, linkpath); } /* Wrapper for rename() — avoids const char* vs char* conflict with Reef FFI */ int zyginit_rename(char *oldpath, char *newpath) { return rename(oldpath, newpath); } /* Wrapper for unlink() — avoids const char* vs char* conflict with Reef FFI */ int zyginit_unlink(char *path) { return unlink(path); } /* Wrapper for fsync() — sys.fd does not expose fsync; the wrapper keeps * the FFI surface consistent with zyginit_rename / zyginit_unlink. */ int zyginit_fsync(int fd) { return fsync(fd); } /* Look up a user by name. Returns uid, or -1 if not found. */ int zyginit_getuid_by_name(char *name) { struct passwd *pw = getpwnam(name); if (pw == NULL) return -1; return (int)pw->pw_uid; } /* Look up a user's primary gid by name. Returns gid, or -1 if not found. */ int zyginit_getgid_by_username(char *name) { struct passwd *pw = getpwnam(name); if (pw == NULL) return -1; return (int)pw->pw_gid; } /* Look up a group by name. Returns gid, or -1 if not found. */ int zyginit_getgid_by_name(char *name) { struct group *gr = getgrnam(name); if (gr == NULL) return -1; return (int)gr->gr_gid; } /* Drop privileges: setgid, initgroups, setuid. Returns 0 on success, -1 on error. */ int zyginit_drop_privileges(char *username, int uid, int gid) { if (setgid(gid) != 0) return -1; if (initgroups(username, gid) != 0) return -1; if (setuid(uid) != 0) return -1; return 0; } /* Flush all dirty page-cache buffers to disk before shutdown. sync(2) is * POSIX and exists on both Linux and Hammerhead — no #ifdef needed. * * NOTE: sync(2) on illumos *schedules* writes; it does not wait for * completion. For ZFS with pending metadata changes (e.g. our * destroy_socket's unlink), use zyginit_fsync_path() to force a * synchronous commit. */ void zyginit_sync(void) { sync(); } /* Run a shell command via system(3), waiting for completion. Used in * the shutdown path to force `zpool sync` and `umountall -l` before * uadmin so all pending ZFS metadata (especially destroy_socket's * unlink) is durably on disk. fsync alone isn't enough — illumos's * sync(2) is async and the kernel reboot races with the txg commit. * * Returns the command's exit status, or -1 if system() itself failed. */ int zyginit_run_cmd(char *cmd) { int rc = system(cmd); if (rc < 0) return -1; return rc; } /* fsync the file at the given path (or its parent dir if the path * itself doesn't exist). Forces ZFS txg commit for any pending changes * touching that path. Returns 0 on success, -1 on error. * * Used after destroy_socket's unlink(/var/run/zyginit.sock) to make * sure the unlink is on disk before the child process triggers * uadmin. Without it, the kernel reboot can race with the ZFS txg * commit — boot 2 then sees a stale socket file in /var/run, boot 2's * create_socket can't recover cleanly, and zygctl loses its socket. */ #include #include int zyginit_fsync_path(char *path) { int fd; fd = open(path, O_RDONLY); if (fd < 0) { /* Path doesn't exist (probably just unlinked). fsync the parent * dir so the directory entry's removal is committed. */ char buf[4096]; char *parent; strncpy(buf, path, sizeof(buf) - 1); buf[sizeof(buf) - 1] = '\0'; parent = dirname(buf); fd = open(parent, O_RDONLY); if (fd < 0) return -1; } int rc = fsync(fd); int saved = errno; close(fd); errno = saved; return rc; } /* Shutdown wrapper — uadmin(A_SHUTDOWN, fcn, 0). On Hammerhead this is the * primitive init uses to halt, reboot, or poweroff the system. fcn is one of: * AD_HALT (0) — stop scheduler, wait for operator * AD_BOOT (1) — reboot * AD_POWEROFF (6) — ACPI/BMC chassis off * Returns 0 on success, -1 on error (errno set). On success, does not return * for AD_BOOT / AD_POWEROFF; for AD_HALT the kernel enters a halted state. * On Linux (dev build only) returns -1 unconditionally. */ #ifdef __sun int zyginit_shutdown(int fcn) { return uadmin(A_SHUTDOWN, fcn, 0); } #else int zyginit_shutdown(int fcn) { (void)fcn; return -1; } #endif /* Push the ldterm + ttcompat STREAMS modules onto an open fd so that * \n is translated to \r\n on output. Without this, zyginit's println * output to /dev/console (which has no autopush'd line discipline at * the time PID 1 first opens it) appears column-shifted relative to * kernel cmn_err writes. Returns 0 on success, -1 on any push error. * Linux build is a no-op (STREAMS doesn't exist there). */ #ifdef __sun int zyginit_push_ldterm(int fd) { if (ioctl(fd, I_PUSH, "ldterm") < 0) return -1; if (ioctl(fd, I_PUSH, "ttcompat") < 0) return -1; return 0; } #else int zyginit_push_ldterm(int fd) { (void)fd; return 0; } #endif /* Write a BOOT_TIME utmpx record. Standard illumos init does this so * who(1) -b and uptime(1) report the actual boot time. Returns 0 on * success, -1 on error. */ int zyginit_write_boot_utmpx(void) { struct utmpx ux; memset(&ux, 0, sizeof (ux)); strncpy(ux.ut_user, "reboot", sizeof (ux.ut_user)); strncpy(ux.ut_line, "system boot", sizeof (ux.ut_line)); ux.ut_pid = 0; ux.ut_type = BOOT_TIME; (void) time(&ux.ut_tv.tv_sec); setutxent(); if (pututxline(&ux) == NULL) { endutxent(); return -1; } endutxent(); return 0; } /* Write a RUN_LVL utmpx record with the given level character (e.g., * 'S' for single-user, '3' for multi-user). who(1) -r and runlevel(8) * read this. Returns 0 on success, -1 on error. * * Format matches what svc.startd's utmpx_set_runlevel() writes * (cmd/svc/startd/utmpx.c): * ut_line = "run-level X" (where X is the level char) * ut_exit.e_termination = new runlevel char * ut_exit.e_exit = previous level (we use 0 — first transition) * ut_type = RUN_LVL * ut_id, ut_user, ut_pid = unused for run-level records * * Linux build is a no-op (RUN_LVL constant and the e_termination / * e_exit struct members don't exist in glibc's utmpx; this is a * Hammerhead-specific feature for who(1)/uptime(1) integration). */ #ifdef __sun #include int zyginit_write_runlvl_utmpx(int level_char) { struct utmpx ux; struct utmpx *oup; char prev_level = '0'; memset(&ux, 0, sizeof (ux)); ux.ut_type = RUN_LVL; setutxent(); /* For RUN_LVL/BOOT_TIME, getutxid matches on type only — use it * to find the existing record so pututxline updates rather than * appends. Without this, who(1) -r returns the FIRST record * (often stale) instead of the most-recent transition. */ oup = getutxid(&ux); if (oup != NULL) { prev_level = oup->ut_exit.e_termination; } snprintf(ux.ut_line, sizeof (ux.ut_line), "run-level %c", (char)level_char); ux.ut_exit.e_termination = (char)level_char; ux.ut_exit.e_exit = prev_level; (void) time(&ux.ut_tv.tv_sec); if (pututxline(&ux) == NULL) { endutxent(); return -1; } endutxent(); return 0; } #else int zyginit_write_runlvl_utmpx(int level_char) { (void)level_char; return -1; } #endif /* Read the BOOT_TIME utmpx record's tv_sec value, used as the boot-id * sentinel for the live-replace state file. PID-1 in-place execve * preserves the same proc_t but the kernel does not preserve a "boot * id" anywhere obvious — utmpx BOOT_TIME survives because it lives in * /var/adm/utmpx, written by zyginit_write_boot_utmpx() at boot. A * real reboot writes a new BOOT_TIME with a different tv_sec, so * comparing against it lets the new zyginit detect a stale state file. * * Returns the BOOT_TIME tv_sec on success, or -1 if the file is not * readable, has no BOOT_TIME entry, or any utmpx call fails. * * NOTE: This function is no longer used by read_boot_time() in replace.reef * (replaced by zyginit_read_pid1_start below), but is retained here for * any future use (e.g., updating utmpx BOOT_TIME on detected reboot). */ int zyginit_read_boot_time(void) { struct utmpx *up; int result = -1; setutxent(); while ((up = getutxent()) != NULL) { if (up->ut_type == BOOT_TIME) { result = (int) up->ut_tv.tv_sec; break; } } endutxent(); return result; } /* Return CLOCK_MONOTONIC time in milliseconds as an int. * Used by ui.reef for elapsed-time stamps on tape rows. * Returns 0 on error (no caller needs error detail). */ int zyginit_monotonic_ms(void) { struct timespec ts; if (clock_gettime(CLOCK_MONOTONIC, &ts) != 0) return 0; return (int)((ts.tv_sec * 1000) + (ts.tv_nsec / 1000000)); } /* Rolling tape backing store for ui.reef. * * Reef's module-level `new []` initializers compile to GNU statement * expressions, which are invalid in C static initializer context. * Using pre-allocated C static arrays avoids the limitation entirely. * * String slots (glyph/name/note) are owned copies (strdup'd). Reef GC * may collect the original strings before a deferred redraw (Task 6 * ui_tick) reads them back, so we take ownership here. */ /* ZYGINIT_TAPE_SLOTS is the maximum physical tape buffer size. * The *active* tape height is computed at runtime by ui_detect_winsize() * (stored in g_tape_height, clamped to [4, 32]) so it adapts to the * terminal size. The C buffer is sized to the maximum so the ring-buffer * logic can use any slot index 0..31 without bounds trouble; rows beyond * g_tape_height are simply never used. */ #define ZYGINIT_TAPE_SLOTS 32 static int zyginit_tape_elapsed_buf[ZYGINIT_TAPE_SLOTS]; static char *zyginit_tape_glyph_buf[ZYGINIT_TAPE_SLOTS]; static char *zyginit_tape_name_buf[ZYGINIT_TAPE_SLOTS]; static char *zyginit_tape_note_buf[ZYGINIT_TAPE_SLOTS]; static int zyginit_tape_active_buf[ZYGINIT_TAPE_SLOTS]; /* Return the number of tape slots. Single source of truth for ui.reef. */ int zyginit_tape_slots(void) { return ZYGINIT_TAPE_SLOTS; } void zyginit_tape_clear(void) { int i; for (i = 0; i < ZYGINIT_TAPE_SLOTS; i++) { zyginit_tape_elapsed_buf[i] = 0; free(zyginit_tape_glyph_buf[i]); zyginit_tape_glyph_buf[i] = NULL; free(zyginit_tape_name_buf[i]); zyginit_tape_name_buf[i] = NULL; free(zyginit_tape_note_buf[i]); zyginit_tape_note_buf[i] = NULL; zyginit_tape_active_buf[i] = 0; } } /* zyginit_tape_set: set all fields of a slot, including the active flag. * active: 0 = static, 1 = forward spinner (starting), 2 = reverse spinner (stopping). */ void zyginit_tape_set(int slot, int elapsed, char *glyph, char *name, char *note, int active) { if (slot < 0 || slot >= ZYGINIT_TAPE_SLOTS) return; zyginit_tape_elapsed_buf[slot] = elapsed; free(zyginit_tape_glyph_buf[slot]); zyginit_tape_glyph_buf[slot] = (glyph && glyph[0]) ? strdup(glyph) : NULL; free(zyginit_tape_name_buf[slot]); zyginit_tape_name_buf[slot] = (name && name[0]) ? strdup(name) : NULL; free(zyginit_tape_note_buf[slot]); zyginit_tape_note_buf[slot] = (note && note[0]) ? strdup(note) : NULL; zyginit_tape_active_buf[slot] = active; } int zyginit_tape_get_active(int slot) { if (slot < 0 || slot >= ZYGINIT_TAPE_SLOTS) return 0; return zyginit_tape_active_buf[slot]; } void zyginit_tape_set_active(int slot, int active) { if (slot < 0 || slot >= ZYGINIT_TAPE_SLOTS) return; zyginit_tape_active_buf[slot] = active; } /* Update the glyph in place — used when a row transitions from active to terminal. */ void zyginit_tape_set_glyph(int slot, char *glyph) { if (slot < 0 || slot >= ZYGINIT_TAPE_SLOTS) return; free(zyginit_tape_glyph_buf[slot]); zyginit_tape_glyph_buf[slot] = (glyph && glyph[0]) ? strdup(glyph) : NULL; } /* Update the note in place. */ void zyginit_tape_set_note(int slot, char *note) { if (slot < 0 || slot >= ZYGINIT_TAPE_SLOTS) return; free(zyginit_tape_note_buf[slot]); zyginit_tape_note_buf[slot] = (note && note[0]) ? strdup(note) : NULL; } int zyginit_tape_get_elapsed(int slot) { if (slot < 0 || slot >= ZYGINIT_TAPE_SLOTS) return 0; return zyginit_tape_elapsed_buf[slot]; } char *zyginit_tape_get_glyph(int slot) { if (slot < 0 || slot >= ZYGINIT_TAPE_SLOTS) return (char *)""; return zyginit_tape_glyph_buf[slot] ? zyginit_tape_glyph_buf[slot] : (char *)""; } char *zyginit_tape_get_name(int slot) { if (slot < 0 || slot >= ZYGINIT_TAPE_SLOTS) return (char *)""; return zyginit_tape_name_buf[slot] ? zyginit_tape_name_buf[slot] : (char *)""; } char *zyginit_tape_get_note(int slot) { if (slot < 0 || slot >= ZYGINIT_TAPE_SLOTS) return (char *)""; return zyginit_tape_note_buf[slot] ? zyginit_tape_note_buf[slot] : (char *)""; } /* zyginit_isatty: thin wrapper around isatty(3) so Reef FFI sees an int * return. Returns 1 if fd is a terminal, 0 otherwise. */ int zyginit_isatty(int fd) { return isatty(fd) ? 1 : 0; } /* zyginit_esc_str: returns a static string containing only the ESC * character (0x1b). Used by ui.reef to build ANSI/SGR escape sequences * at runtime, since the Reef lexer does not support \x or octal string * escapes — only \n, \t, \r, \\, \", \$. */ const char *zyginit_esc_str(void) { static const char buf[2] = { '\x1b', '\0' }; return buf; } /* Read terminal size from fd (usually 1 = stdout = /dev/console). * Stores rows in out_rows[0] and cols in out_cols[0]. * Returns 0 on success, -1 on failure. * * On Hammerhead /dev/console is a STREAMS tty that supports TIOCGWINSZ * via the tem driver's GWINSZ ioctl. On Linux (dev build) this works * against any real tty but falls back to -1 when not a tty (e.g. in * integration tests), which is fine — ui_detect_winsize uses the * ZYGINIT_FORCE_80x25 path for test stability. */ #include #include /* struct winsize, TIOCGWINSZ — illumos puts these here, not in like Linux does */ int zyginit_get_winsize(int fd, int *out_rows, int *out_cols) { struct winsize ws; if (ioctl(fd, TIOCGWINSZ, &ws) != 0) return -1; *out_rows = ws.ws_row; *out_cols = ws.ws_col; return 0; } /* Resolve a symlink (or any path) to its canonical absolute form via * realpath(3). The resolved path is stored in a module-level static buffer * to avoid heap allocation (Reef FFI owns the returned string only for the * lifetime of the current call — callers must copy before calling again). * Returns the resolved path on success, or "" on any error (dangling link, * ENOENT, EACCES, etc.). Used by config.reef's scan_enabled_dir to validate * that enabled.d/ symlinks actually target a path inside services/. */ static char zyginit_readlink_buf[4096]; char *zyginit_readlink_resolved(char *path) { char *resolved = realpath(path, zyginit_readlink_buf); return resolved ? resolved : (char *)""; } /* Read PID 1's process start time, used as the boot-id sentinel for * live-replace. Survives execve (proc_t preserved) but changes on real * reboot (new PID 1). Returns the start time as an int, or -1 on error. * * Linux: /proc/1/stat field 22 (start_time in jiffies-since-boot) * illumos: /proc/1/psinfo pr_start.tv_sec (Unix timestamp) * * This correctly handles the case where zyginit_write_boot_utmpx() is * called at startup by the new zyginit after execve — pututxline updates * rather than appends the BOOT_TIME record, so utmpx BOOT_TIME is * unreliable as a boot-id sentinel after live replace. /proc/1/start * is stable across execve but changes on real reboot (new PID 1 starts * with a fresh proc_t), making it the correct sentinel. */ #ifdef __sun #include int zyginit_read_pid1_start(void) { int fd; psinfo_t psinfo; fd = open("/proc/1/psinfo", O_RDONLY); if (fd < 0) return -1; if (read(fd, &psinfo, sizeof(psinfo)) != (ssize_t)sizeof(psinfo)) { close(fd); return -1; } close(fd); return (int) psinfo.pr_start.tv_sec; } #else int zyginit_read_pid1_start(void) { int fd; char buf[1024]; int n; fd = open("/proc/1/stat", O_RDONLY); if (fd < 0) return -1; n = read(fd, buf, sizeof(buf) - 1); close(fd); if (n <= 0) return -1; buf[n] = '\0'; /* /proc//stat format: "pid (comm) state ppid pgrp ..." * Field 22 (1-indexed) is starttime. Parse by skipping past the * comm field's trailing ')', then count whitespace-separated fields. * Skip to the LAST ')' to handle commas/parens in comm. */ char *p = strrchr(buf, ')'); if (p == NULL) return -1; p++; /* skip past ')' */ /* Now we're at " state ppid pgrp ... starttime ..." * Field numbering: pid=1, comm=2, state=3, ppid=4, ..., starttime=22. * We have already consumed fields 1 and 2 (pid + comm). We need to * skip fields 3..21 (19 fields) to land on field 22 (starttime). * Loop while field < 21 skips 19 tokens (field=2..20, 19 iterations). */ int field = 2; /* we just consumed pid + comm = fields 1,2 */ while (*p && field < 21) { while (*p == ' ') p++; while (*p && *p != ' ') p++; field++; } while (*p == ' ') p++; if (*p == '\0') return -1; long starttime = 0; while (*p >= '0' && *p <= '9') { starttime = starttime * 10 + (*p - '0'); p++; } return (int) starttime; } #endif /* Run a shell command with a timeout. Returns: * command's exit code on normal completion (0 = success), * -2 if timeout exceeded (child killed), * -3 if command binary not found (exit 127 from sh), * -4 on other exec/fork failure. * Runs via /bin/sh -c so $PATH lookup works. */ #include #include int run_command_with_timeout(char *cmd, int timeout_sec) { pid_t pid = fork(); if (pid < 0) return -4; if (pid == 0) { /* child */ execl("/bin/sh", "sh", "-c", cmd, (char *)0); _exit(127); /* exec failed */ } /* parent: poll waitpid until exit or timeout */ time_t deadline = time(0) + timeout_sec; int status; for (;;) { pid_t r = waitpid(pid, &status, WNOHANG); if (r == pid) { if (WIFEXITED(status)) { int ec = WEXITSTATUS(status); if (ec == 127) return -3; /* exec failed in child */ return ec; } return -4; /* signaled */ } if (r < 0) return -4; if (time(0) >= deadline) { kill(pid, SIGKILL); waitpid(pid, &status, 0); return -2; } struct timespec ts = { 0, 50 * 1000 * 1000 }; /* 50ms poll */ nanosleep(&ts, 0); } } /****************************************************************************** __ ____ __ / / ___ ____ _/ __/_____________ _/ /__ / / / _ \/ __ `/ /_/ ___/ ___/ __ `/ / _ \ / /___/ __/ /_/ / __(__ ) /__/ /_/ / / __/ /_____/\___/\__,_/_/ /____/\___/\__,_/_/\___/ (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 ctlsocket 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_WAITING() 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 // Warn (once, at boot) about conflict targets that name a non-existent service. proc warn_unknown_conflicts(table: supervisor.ServiceTable, def: config.ServiceDef) let conf = config.svc_conflicts(def) let n = config.svc_conflicts_count(def) mut k = 0 while k < n if supervisor.find_service(table, conf[k]) < 0 println("zyginit: warning: " + config.svc_name(def) + " conflicts with unknown service: " + conf[k]) end if k = k + 1 end while end warn_unknown_conflicts // Boot-only pre-flight: for every pair of services that are both active in the // current runlevel and declared mutually exclusive, mark BOTH failed before any // fork. zyginit never auto-picks a winner. Runtime conflicts (zygctl start, // runlevel transitions) are handled by the guard in supervisor.start_service. proc resolve_conflicts(table: supervisor.ServiceTable) let n = supervisor.service_count(table) mut i = 0 while i < n let rt_i = supervisor.get_runtime(table, i) let def_i = supervisor.rt_def(rt_i) if supervisor.rt_state(rt_i) == supervisor.STATE_WAITING() and service_in_runlevel(def_i, g_runlevel) warn_unknown_conflicts(table, def_i) mut j = i + 1 while j < n let rt_j = supervisor.get_runtime(table, j) let def_j = supervisor.rt_def(rt_j) if supervisor.rt_state(rt_j) == supervisor.STATE_WAITING() and service_in_runlevel(def_j, g_runlevel) and supervisor.conflicts_with(table, i, j) let name_i = config.svc_name(def_i) let name_j = config.svc_name(def_j) supervisor.mark_conflict_failed(table, i, name_j) supervisor.mark_conflict_failed(table, j, name_i) if ui.ui_mode() == ui.MODE_PLAIN() println("zyginit: conflict: " + name_i + " and " + name_j + " are mutually exclusive; failing both") end if end if j = j + 1 end while end if i = i + 1 end while end resolve_conflicts // ============================================================================ // 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 raw_count = config.load_enabled_services(config_dir, new_services, max) // If services/ disappeared during a live reload, bail out — do not // disable all running services as a side-effect. if raw_count < 0 println("zyginit: reload: services/ directory missing — reload aborted") return end if let new_count = raw_count // 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 ctlsocket.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 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. 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 svc_count < 0 println("zyginit: FATAL: cannot proceed without services/ directory") println("zyginit: entering idle loop (boot from recovery medium to run migrate-layout.sh)") // svc_count is treated as 0 from here — the event loop will run // with no services, keeping the system accessible via the socket // (e.g., single-user shell via boot args or recovery medium). elif 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 // Clamp negative sentinel to 0 — all downstream code uses svc_count // as a loop bound or comparison; -1 would cause incorrect behavior. mut svc_count_clamped = svc_count if svc_count_clamped < 0 svc_count_clamped = 0 end if // ---- Phase 2: Build dependency graph ---- let graph = depgraph.new_depgraph() let graph_ok = depgraph.build_graph(graph, services, svc_count_clamped) 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_clamped 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 = 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 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_clamped, num_tiers, runlevel_name) resolve_conflicts(table) 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 ctlsocket.reload_requested() ctlsocket.clear_reload_flag() println("zyginit: reload requested via socket") reload_services(table) end if // Check for replace requested via socket if ctlsocket.replace_requested() let wait = ctlsocket.replace_wait_seconds() ctlsocket.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 = ctlsocket.shutdown_requested() if req != 0 ctlsocket.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 = ctlsocket.runlevel_requested() if rl_req >= 0 ctlsocket.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 ctlsocket.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) ctlsocket.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 skipped = 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 if st == supervisor.STATE_SKIPPED() skipped = skipped + 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, skipped: skipped, 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 /****************************************************************************** __ ____ __ / / ___ ____ _/ __/_____________ _/ /__ / / / _ \/ __ `/ /_/ ___/ ___/ __ `/ / _ \ / /___/ __/ /_/ / __(__ ) /__/ /_/ / / __/ /_____/\___/\__,_/_/ /____/\___/\__,_/_/\___/ (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 core.result as res 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_r = file.readFile(path) if res.is_err(content_r) let _ = zyginit_unlink(path) return new_recovered_state() end if let content = res.unwrap_ok(content_r) 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_r = toml.toml_parse(content, keys, vals) if res.is_err(count_r) println("replace: recover: parse error in " + path + " — ignoring") let _ = zyginit_unlink(path) return new_recovered_state() end if let count = res.unwrap_ok(count_r) 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 /****************************************************************************** __ ____ __ / / ___ ____ _/ __/_____________ _/ /__ / / / _ \/ __ `/ /_/ ___/ ___/ __ `/ / _ \ / /___/ __/ /_/ / __(__ ) /__/ /_/ / / __/ /_____/\___/\__,_/_/ /____/\___/\__,_/_/\___/ (C)opyright 2025-2026, Leafscale, LLC - https://www.leafscale.com Project: zyginit Filename: shutdown.reef Authors: Chris Tusa License: Description: PID-1 shutdown primitives — uadmin(2) FFI and shutdown-type constants. ******************************************************************************/ module shutdown export // Shutdown function codes (sys/uadmin.h A_SHUTDOWN fcn values) fn AD_HALT(): int fn AD_BOOT(): int fn AD_POWEROFF(): int // Symbolic shutdown-type codes used internally by zyginit. // These are NOT the uadmin AD_* codes — they're zyginit's own // enum for distinguishing what a shutdown was triggered as, // before it's mapped to AD_* at the actual uadmin call site. fn SHUT_NONE(): int fn SHUT_HALT(): int fn SHUT_REBOOT(): int fn SHUT_POWEROFF(): int // Map our internal type to the uadmin fcn code. fn to_ad_code(shut_type: int): int // Call uadmin(A_SHUTDOWN, fcn, 0). Returns 0 on success, -1 on error. // Only succeeds when running as PID 1. fn do_shutdown(fcn: int): int // Flush dirty page-cache buffers to disk (POSIX sync(2)). proc do_sync() end export extern "C" fn zyginit_shutdown(fcn: int): int extern "C" proc zyginit_sync() fn AD_HALT(): int return 0 end AD_HALT fn AD_BOOT(): int return 1 end AD_BOOT fn AD_POWEROFF(): int return 6 end AD_POWEROFF fn SHUT_NONE(): int return 0 end SHUT_NONE fn SHUT_HALT(): int return 1 end SHUT_HALT fn SHUT_REBOOT(): int return 2 end SHUT_REBOOT fn SHUT_POWEROFF(): int return 3 end SHUT_POWEROFF fn to_ad_code(shut_type: int): int if shut_type == SHUT_HALT() return AD_HALT() elif shut_type == SHUT_REBOOT() return AD_BOOT() elif shut_type == SHUT_POWEROFF() return AD_POWEROFF() end if // Default to halt for unknown / NONE return AD_HALT() end to_ad_code fn do_shutdown(fcn: int): int return zyginit_shutdown(fcn) end do_shutdown proc do_sync() zyginit_sync() end do_sync end module /****************************************************************************** __ ____ __ / / ___ ____ _/ __/_____________ _/ /__ / / / _ \/ __ `/ /_/ ___/ ___/ __ `/ / _ \ / /___/ __/ /_/ / __(__ ) /__/ /_/ / / __/ /_____/\___/\__,_/_/ /____/\___/\__,_/_/\___/ (C)opyright 2025-2026, Leafscale, LLC - https://www.leafscale.com Project: zyginit Filename: supervisor.reef Authors: Chris Tusa License: Description: Service lifecycle management — fork/exec, state, restart ******************************************************************************/ module supervisor import condition import config import contract import ui import io.dir import io.file import sys.env as sysenv import sys.fd as fd import sys.process as process import sys.signal as signal import time.time as time import time.clock as clock import collections.hashmap as hashmap import core.str import core.result as res import core.option as opt export type ServiceRuntime type ServiceTable // State constants fn STATE_DISABLED(): int fn STATE_WAITING(): int fn STATE_STARTING(): int fn STATE_RUNNING(): int fn STATE_STOPPING(): int fn STATE_STOPPED(): int fn STATE_FAILED(): int fn STATE_MAINTENANCE(): int fn STATE_SKIPPED(): int fn state_name(state: int): string // ServiceRuntime accessors fn rt_state(rt: ServiceRuntime): int fn rt_pid(rt: ServiceRuntime): int fn rt_contract_id(rt: ServiceRuntime): int fn rt_restart_count(rt: ServiceRuntime): int fn rt_last_exit_code(rt: ServiceRuntime): int fn rt_last_start_time(rt: ServiceRuntime): int fn rt_def(rt: ServiceRuntime): config.ServiceDef fn rt_skip_reason(rt: ServiceRuntime): string // ServiceTable operations fn new_service_table(max_services: int): ServiceTable fn add_service(table: ServiceTable, def: config.ServiceDef): int fn find_service(table: ServiceTable, name: string): int fn find_by_contract(table: ServiceTable, contract_id: int): int fn service_count(table: ServiceTable): int fn get_runtime(table: ServiceTable, idx: int): ServiceRuntime fn conflicts_with(table: ServiceTable, a_idx: int, b_idx: int): bool fn conflicting_running_peer(table: ServiceTable, idx: int): int fn service_name_at(table: ServiceTable, idx: int): string proc mark_conflict_failed(table: ServiceTable, idx: int, other_name: string) // Lifecycle fn start_service(table: ServiceTable, idx: int): bool fn stop_service(table: ServiceTable, idx: int): bool proc handle_contract_event(table: ServiceTable, contract_id: int, hint_exit_code: int) proc handle_child_exit(table: ServiceTable, pid: int, exit_code: int) proc check_stop_timeouts(table: ServiceTable) proc check_restart_delays(table: ServiceTable) // Oneshot teardown fn stop_oneshot(table: ServiceTable, idx: int): bool // Reload support proc mark_runlevel_filtered(table: ServiceTable, idx: int) proc disable_service(table: ServiceTable, idx: int) // Adopt runtime fields directly — used by the live-replace recovery path // (main.apply_recovered_state) and by utils/replace_probe. Production // startup and supervision go through start_service / handle_contract_event. proc adopt_runtime(table: ServiceTable, idx: int, state: int, pid: int, contract_id: int, restart_count: int, last_exit_code: int, last_start_time: int) fn make_def_with_name(name: string): config.ServiceDef // Status fn get_status_line(table: ServiceTable, idx: int): string fn format_duration(seconds: int): string // Log directory fn log_dir(): string fn get_service_log(name: string): string end export fn LOG_DIR(): string // Persistent on-disk default. /var/run is tmpfs and gets wiped on every // reboot, which makes diagnosing PID-1 boot failures nearly impossible — // the log of what zyginit did is gone before the next boot can read it. // /var/log lives on the BE's ZFS dataset so it survives across reboots // and is readable by mounting the BE from another BE if boot fails. return sysenv.get_env_or("ZYGINIT_LOG_DIR", "/var/log/zyginit") end LOG_DIR // Central operational log for the supervisor itself (as opposed to the // per-service .log files). Records events that would otherwise vanish // — most importantly spawn failures, where the service child never runs and // so writes nothing to its own log. See bug 003. fn ZYGINIT_LOG_PATH(): string return str.concat(LOG_DIR(), "/zyginit.log") end ZYGINIT_LOG_PATH // Append a single timestamped line to the central zyginit log. Best-effort: // any failure (dir missing, no space, priv-dropped child) is swallowed so // logging never blocks or aborts the supervisor. Uses only fd syscalls, so // it is safe to call from a forked child before exec. proc sup_log(msg: string) let flags = fd.O_WRONLY() + fd.O_CREAT() + fd.O_APPEND() let f = fd.fd_open(ZYGINIT_LOG_PATH(), flags, 420) if f >= 0 let line = str.concat(str.concat("[", int_to_str(time.time_now())), str.concat("] ", str.concat(msg, "\n"))) let _ = fd.fd_write(f, line) let _2 = fd.fd_close(f) end if end sup_log // Exec retry policy for service start commands. On a fresh first boot, // devfsadm + ZFS-pool-finalize I/O contention can make a stat()/exec of the // start command (or its #! interpreter) transiently fail even for a valid, // executable, absolute path. A bounded retry rides out the transient miss; a // genuinely-missing command still fails every attempt. See bug 003. fn EXEC_MAX_ATTEMPTS(): int return 5 end EXEC_MAX_ATTEMPTS fn EXEC_RETRY_DELAY_MS(): int return 100 end EXEC_RETRY_DELAY_MS // Privilege separation FFI wrappers extern "C" fn zyginit_getuid_by_name(name: string): int extern "C" fn zyginit_getgid_by_username(name: string): int extern "C" fn zyginit_getgid_by_name(name: string): int extern "C" fn zyginit_drop_privileges(username: string, uid: int, gid: int): int // ============================================================================ // State constants // ============================================================================ fn STATE_DISABLED(): int return 0 end STATE_DISABLED fn STATE_WAITING(): int return 1 end STATE_WAITING fn STATE_STARTING(): int return 2 end STATE_STARTING fn STATE_RUNNING(): int return 3 end STATE_RUNNING fn STATE_STOPPING(): int return 4 end STATE_STOPPING fn STATE_STOPPED(): int return 5 end STATE_STOPPED fn STATE_FAILED(): int return 6 end STATE_FAILED fn STATE_MAINTENANCE(): int return 7 end STATE_MAINTENANCE fn STATE_SKIPPED(): int return 8 end STATE_SKIPPED fn state_name(state: int): string if state == 0 return "disabled" elif state == 1 return "waiting" elif state == 2 return "starting" elif state == 3 return "running" elif state == 4 return "stopping" elif state == 5 return "stopped" elif state == 6 return "failed" elif state == 7 return "maintenance" elif state == 8 return "skipped" end if return "unknown" end state_name // ============================================================================ // ServiceRuntime type // ============================================================================ type ServiceRuntime = struct def: config.ServiceDef state: int contract_id: int pid: int restart_count: int last_exit_code: int last_start_time: int stop_requested_time: int restart_scheduled_time: int skip_reason: string end ServiceRuntime fn new_runtime(def: config.ServiceDef): ServiceRuntime return ServiceRuntime{ def: def, state: STATE_WAITING(), contract_id: 0 - 1, pid: 0 - 1, restart_count: 0, last_exit_code: 0 - 1, last_start_time: 0, stop_requested_time: 0, restart_scheduled_time: 0, skip_reason: "" } end new_runtime fn rt_state(rt: ServiceRuntime): int return rt.state end rt_state fn rt_pid(rt: ServiceRuntime): int return rt.pid end rt_pid fn rt_contract_id(rt: ServiceRuntime): int return rt.contract_id end rt_contract_id fn rt_restart_count(rt: ServiceRuntime): int return rt.restart_count end rt_restart_count fn rt_last_exit_code(rt: ServiceRuntime): int return rt.last_exit_code end rt_last_exit_code fn rt_last_start_time(rt: ServiceRuntime): int return rt.last_start_time end rt_last_start_time fn rt_def(rt: ServiceRuntime): config.ServiceDef return rt.def end rt_def fn rt_skip_reason(rt: ServiceRuntime): string return rt.skip_reason end rt_skip_reason // ============================================================================ // ServiceTable type // ============================================================================ type ServiceTable = struct runtimes: [ServiceRuntime] count: int max: int name_map: hashmap.HashMap[int] contract_map: hashmap.HashMap[int] end ServiceTable fn new_service_table(max_services: int): ServiceTable return ServiceTable{ runtimes: new [ServiceRuntime](max_services), count: 0, max: max_services, name_map: hashmap.create[:int](0 - 1), contract_map: hashmap.create[:int](0 - 1) } end new_service_table fn add_service(table: ServiceTable, def: config.ServiceDef): int if table.count >= table.max return 0 - 1 end if let idx = table.count table.runtimes[idx] = new_runtime(def) table.name_map.set(config.svc_name(def), idx) table.count = table.count + 1 return idx end add_service fn find_service(table: ServiceTable, name: string): int let idx: opt.Option[int] = table.name_map.get(name) if opt.is_some(idx) return opt.unwrap(idx) end if return 0 - 1 end find_service fn find_by_contract(table: ServiceTable, contract_id: int): int let key = int_to_str(contract_id) let idx: opt.Option[int] = table.contract_map.get(key) if opt.is_some(idx) return opt.unwrap(idx) end if return 0 - 1 end find_by_contract fn service_count(table: ServiceTable): int return table.count end service_count fn get_runtime(table: ServiceTable, idx: int): ServiceRuntime return table.runtimes[idx] end get_runtime // True if services a and b are declared mutually exclusive. Symmetric: a // conflict declared on either side counts for both. Self-comparison and // unknown names never match. fn conflicts_with(table: ServiceTable, a_idx: int, b_idx: int): bool if a_idx == b_idx return false end if let a_def = table.runtimes[a_idx].def let b_def = table.runtimes[b_idx].def let a_name = config.svc_name(a_def) let b_name = config.svc_name(b_def) let a_conf = config.svc_conflicts(a_def) let a_n = config.svc_conflicts_count(a_def) mut i = 0 while i < a_n if a_conf[i] == b_name return true end if i = i + 1 end while let b_conf = config.svc_conflicts(b_def) let b_n = config.svc_conflicts_count(b_def) i = 0 while i < b_n if b_conf[i] == a_name return true end if i = i + 1 end while return false end conflicts_with // Index of a RUNNING/STARTING service that conflicts with idx, or -1 if none. // Shared by the start guard and by zygctl start for messaging. fn conflicting_running_peer(table: ServiceTable, idx: int): int mut i = 0 while i < table.count if i != idx and conflicts_with(table, idx, i) let st = table.runtimes[i].state if st == STATE_RUNNING() or st == STATE_STARTING() return i end if end if i = i + 1 end while return 0 - 1 end conflicting_running_peer // Name of the service at idx (convenience for callers outside this module). fn service_name_at(table: ServiceTable, idx: int): string return config.svc_name(table.runtimes[idx].def) end service_name_at // Mark a service FAILED because of a conflict, recording the reason for // `zygctl status`. The skip_reason field doubles as a generic status reason. proc mark_conflict_failed(table: ServiceTable, idx: int, other_name: string) let rt = table.runtimes[idx] rt.state = STATE_FAILED() rt.skip_reason = str.concat("conflicts with ", other_name) table.runtimes[idx] = rt end mark_conflict_failed // ============================================================================ // Signal name to number mapping // ============================================================================ fn signal_from_name(name: string): int if name == "TERM" or name == "SIGTERM" return signal.SIGTERM() elif name == "HUP" or name == "SIGHUP" return signal.SIGHUP() elif name == "INT" or name == "SIGINT" return signal.SIGINT() elif name == "KILL" or name == "SIGKILL" return signal.SIGKILL() elif name == "USR1" or name == "SIGUSR1" return signal.SIGUSR1() elif name == "USR2" or name == "SIGUSR2" return signal.SIGUSR2() end if return signal.SIGTERM() end signal_from_name // ============================================================================ // Service lifecycle // ============================================================================ // Start a service: set up contract template, fork, exec. // Returns true on success. fn start_service(table: ServiceTable, idx: int): bool let rt = table.runtimes[idx] let def = rt.def let name = config.svc_name(def) // Evaluate [condition] block before forking. If conditions fail, the // service is transitioned to STATE_SKIPPED — no process is ever forked. let cond_result = condition.evaluate_conditions(def) if condition.cond_passed(cond_result) == false rt.state = STATE_SKIPPED() rt.skip_reason = condition.cond_reason(cond_result) table.runtimes[idx] = rt ui.ui_event_skipped(name, rt.skip_reason) return true end if // Conflict guard: refuse to start when a mutually-exclusive peer is already // running or starting. Leaves this service's state unchanged. Covers // zygctl start, runlevel transitions, and restarts (all funnel here). let conflict_peer = conflicting_running_peer(table, idx) if conflict_peer >= 0 let other = config.svc_name(table.runtimes[conflict_peer].def) if ui.ui_mode() == ui.MODE_PLAIN() println(str.concat(str.concat("supervisor: cannot start ", name), str.concat(": conflicts with running service ", other))) end if return false end if // Emit "starting" event so the UI spinner activates before the fork. ui.ui_event_starting(name) // Set up contract template (will fail on Linux — that's expected) let tmpl_fd = contract.setup_template() // Fork let pid = process.process_fork() if pid < 0 rt.state = STATE_FAILED() table.runtimes[idx] = rt ui.ui_event_failed(name, 0 - 1, 0) sup_log(str.concat(name, ": fork failed")) if ui.ui_mode() == ui.MODE_PLAIN() println("supervisor: fork failed for " + name) end if if tmpl_fd >= 0 contract.clear_template(tmpl_fd) end if return false end if if pid == 0 // CHILD PROCESS // Redirect fd 0 to /dev/null BEFORE setsid. PID 1 has /dev/console on // fd 0/1/2 (from setup_pid1_console), and if the new session is born // holding that inherited tty fd, illumos may auto-claim /dev/console // as the session's ctty even without an explicit O_NOCTTY-less open. // That blocks ttymon (in console-login) from claiming the console // later. See bugs/001-supervisor-leaves-console-as-fd0-in-children.md. let null_fd = fd.fd_open("/dev/null", fd.O_RDWR(), 0) if null_fd >= 0 fd.fd_dup2(null_fd, 0) fd.fd_close(null_fd) end if process.process_setsid() // Redirect stdout/stderr to per-service log file let log_path = str.concat(str.concat(LOG_DIR(), "/"), str.concat(name, ".log")) let log_flags = fd.O_WRONLY() + fd.O_CREAT() + fd.O_TRUNC() let log_fd = fd.fd_open(log_path, log_flags, 420) if log_fd >= 0 fd.fd_dup2(log_fd, 1) fd.fd_dup2(log_fd, 2) fd.fd_close(log_fd) end if // Change working directory if specified let wdir = config.svc_working_dir(def) if str.length(wdir) > 0 dir.change_dir(wdir) end if // Default PATH for service children. zyginit as PID 1 inherits // a sparse env from the kernel, so without this, services like // shell-script wrappers can't find bare command names. The // service's own [exec] environment list (below) can override. sysenv.set_env("PATH", "/usr/sbin:/usr/bin:/sbin") // Set environment variables let env_vars = config.svc_environment(def) let env_count = config.svc_environment_count(def) mut ei = 0 while ei < env_count let entry = env_vars[ei] let eq_pos = str.index_of(entry, "=") if eq_pos > 0 let key = str.substring(entry, 0, eq_pos) let val = str.substring(entry, eq_pos + 1, str.length(entry) - eq_pos - 1) sysenv.set_env(key, val) end if ei = ei + 1 end while // Drop privileges if user/group specified let svc_user = config.svc_user(def) let svc_group = config.svc_group(def) if str.length(svc_user) > 0 let uid = zyginit_getuid_by_name(svc_user) mut gid = zyginit_getgid_by_username(svc_user) // Override gid if explicit group specified if str.length(svc_group) > 0 gid = zyginit_getgid_by_name(svc_group) end if if uid < 0 or gid < 0 // User/group lookup failed — abort child process.exit_now(126) end if if zyginit_drop_privileges(svc_user, uid, gid) < 0 process.exit_now(126) end if elif str.length(svc_group) > 0 // Group only (no user change) — not typical but supported let gid = zyginit_getgid_by_name(svc_group) if gid < 0 process.exit_now(126) end if end if // Parse start_cmd into program + args let cmd = config.svc_start_cmd(def) let parts = new [string](32) let argc = str.split(cmd, ' ', parts, 32) if argc > 0 let program = parts[0] // argv[0] must be the program name (POSIX convention). // argv[1..] are the actual arguments. let args = new [string](argc) mut a = 0 while a < argc args[a] = parts[a] a = a + 1 end while // Retry the exec a bounded number of times. process_exec only // returns when the exec itself failed (the process image was // never replaced), so each loop iteration is a fresh attempt. // This absorbs the transient first-boot stat()/exec miss under // I/O pressure without masking a genuinely-missing command, // which fails every attempt and falls through to exit 127. mut attempt = 0 while attempt < EXEC_MAX_ATTEMPTS() process.process_exec(program, args) attempt = attempt + 1 if attempt < EXEC_MAX_ATTEMPTS() clock.sleep_millis(EXEC_RETRY_DELAY_MS()) end if end while end if // Every exec attempt failed — the start command never ran. Record it // loudly instead of vanishing with an empty log (the bug-003 symptom): // - fd 2 → per-service .log; the fd was opened as root before // any privilege drop, so this write survives a dropped-priv child. // - central zyginit.log via sup_log (best-effort; works while root). // Then exit 127 ("command not found" convention) so the parent's // exit-code plumbing reports a real code rather than the -1 sentinel. let fail_msg = str.concat(str.concat(name, ": exec failed for '"), str.concat(cmd, str.concat("' after ", str.concat(int_to_str(EXEC_MAX_ATTEMPTS()), " attempts")))) let _ = fd.fd_write(2, str.concat(fail_msg, "\n")) sup_log(fail_msg) process.exit_now(127) end if // PARENT PROCESS // Get contract ID (will be -1 on Linux) mut ctid = 0 - 1 if tmpl_fd >= 0 ctid = contract.get_latest_contract() contract.clear_template(tmpl_fd) end if // Update runtime rt.state = STATE_RUNNING() rt.pid = pid rt.contract_id = ctid rt.last_start_time = time.time_now() rt.skip_reason = "" // clear any stale conflict/skip reason from a prior failure table.runtimes[idx] = rt // Register contract mapping if ctid >= 0 table.contract_map.set(int_to_str(ctid), idx) end if // Fire ui_event_started only for daemons entering RUNNING state. // Oneshots must NOT fire here — they only call ui_event_started when // the contract-exit event confirms successful completion (in // handle_contract_event). Firing here AND on exit causes g_done to // double-increment, producing a count like "32/23 done". if config.svc_type(def) != config.SERVICE_TYPE_ONESHOT() ui.ui_event_started(name, 0) end if return true end start_service // Stop a service: signal the contract or exec the stop command. // Returns true if stop was initiated. fn stop_service(table: ServiceTable, idx: int): bool let rt = table.runtimes[idx] let def = rt.def let name = config.svc_name(def) if rt.state != STATE_RUNNING() return false end if rt.state = STATE_STOPPING() rt.stop_requested_time = time.time_now() table.runtimes[idx] = rt let stop_method = config.svc_stop_method(def) if stop_method == config.STOP_METHOD_EXEC() and str.length(config.svc_stop_cmd(def)) > 0 // Exec the stop command process.process_spawn_shell(config.svc_stop_cmd(def)) ui.ui_event_stopping(name) else // Signal the contract (default) let sig = signal_from_name(config.svc_stop_signal(def)) if rt.contract_id >= 0 contract.kill_contract(rt.contract_id, sig) elif rt.pid > 0 // Fallback: signal the process directly (Linux or no contract) process.process_kill(rt.pid, sig) end if ui.ui_event_stopping(name) end if return true end stop_service // Run a oneshot's declared exec.stop command synchronously. // Used during shutdown to reverse setup that the matching exec.start did. // Does nothing if no stop command is declared. // Returns true if stop was attempted (whether or not it succeeded). fn stop_oneshot(table: ServiceTable, idx: int): bool let rt = table.runtimes[idx] let def = rt.def let name = config.svc_name(def) // Guard (C-1): only oneshots get exec.stop dispatched at shutdown. // Daemons that land in STATE_STOPPED already had their stop_method run // (via stop_service). Re-running it would be incorrect. if config.svc_type(def) != config.SERVICE_TYPE_ONESHOT() return false end if let stop_cmd = config.svc_stop_cmd(def) if str.length(stop_cmd) == 0 // Nothing to reverse — normal for oneshots with no teardown. return false end if if ui.ui_mode() == ui.MODE_PLAIN() println("supervisor: running stop for " + name + " (" + stop_cmd + ")") end if let pid = process.process_spawn_shell(stop_cmd) if pid < 0 if ui.ui_mode() == ui.MODE_PLAIN() println("supervisor: " + name + ": stop spawn failed") end if return true end if // Bounded wait (C-2): stop scripts that hang shouldn't lock up shutdown. // Try-wait loop with a 10s deadline (20 x 500ms). mut waits = 0 mut exited = false while not exited and waits < 20 if process.process_try_wait(pid) exited = true else clock.sleep_millis(500) waits = waits + 1 end if end while if not exited if ui.ui_mode() == ui.MODE_PLAIN() println("supervisor: " + name + ": stop timed out, killing") end if let _ = process.process_kill(pid, signal.SIGKILL()) // Reap the now-dead child let _ = process.process_wait(pid) elif process.process_exit_code() != 0 if ui.ui_mode() == ui.MODE_PLAIN() println("supervisor: " + name + ": stop exited non-zero (" + int_to_str(process.process_exit_code()) + ")") end if end if return true end stop_oneshot // Handle a contract event (typically "empty" — all processes exited). // Looks up the service by contract ID and applies restart policy. proc handle_contract_event(table: ServiceTable, contract_id: int, hint_exit_code: int) let idx = find_by_contract(table, contract_id) if idx < 0 return end if let rt = table.runtimes[idx] let def = rt.def let name = config.svc_name(def) // Resolve exit code: prefer the hint (caller already reaped via SIGCHLD // path); else attempt to reap ourselves (contract-fd path arrived first). // hint_exit_code is non-negative when the caller has a real exit code; // -1 is the "no hint" sentinel matching the post-reap default below. mut exit_code = 0 - 1 if hint_exit_code >= 0 exit_code = hint_exit_code elif rt.pid > 0 if process.process_try_wait(rt.pid) exit_code = process.process_exit_code() end if end if // Reap-race reconciliation (Hammerhead contract path, bug 003). // The contract-empty event on the bundle fd can arrive before the // child's exit status has been posted for reaping. When that happens // we have no hint AND try_wait above could not resolve a code, yet a // child is still tracked (rt.pid > 0). Committing the -1 sentinel here // would (a) misreport a real exit code — a 127 spawn failure surfaces // as -1 — and (b) flip an exit-0 oneshot into a spurious FAILED. Worse, // this handler would then clear rt.pid and the contract map, so the // catch-all reaper later swallows the true code with nowhere to route // it. Defer instead: leave rt.pid and the contract mapping intact and // return. reap_children runs unconditionally every poll iteration and // re-dispatches this contract with the real exit code (hint >= 0) // within ~1s. A daemon that already completed its daemonize handshake // has rt.pid == -1 here, so its genuine crash (code truly unknown) is // NOT deferred and flows through the logic below as before. if hint_exit_code < 0 and exit_code < 0 and rt.pid > 0 return end if let svc_type = config.svc_type(def) // Task: may run for any duration. Exit 0 = STOPPED (counts as online). // Exit != 0 = FAILED. Never restarts. Skips the daemonize handshake. if svc_type == config.SERVICE_TYPE_TASK() let dur_ms = (time.time_now() - rt.last_start_time) * 1000 let ec: int = if exit_code >= 0 then exit_code else 0 end if rt.pid = 0 - 1 rt.contract_id = 0 - 1 rt.last_exit_code = ec table.contract_map.remove(int_to_str(contract_id)) if ec == 0 rt.state = STATE_STOPPED() table.runtimes[idx] = rt ui.ui_event_stopped(name, dur_ms) else rt.state = STATE_FAILED() table.runtimes[idx] = rt ui.ui_event_failed(name, ec, dur_ms) end if return end if // Daemonize handshake: a double-forking daemon (dlmgmtd, syslogd, // utmpd, etc.) signals readiness by having its parent exit 0 once // the daemonized child is set up. The child stays alive in the same // contract (CT_PR_INHERIT). If this call came from the PID-reap // path (hint_exit_code >= 0) and the parent exited cleanly while // we're still RUNNING, this is the daemonize handshake — NOT a // service stop. Clear the original PID so reap_children stops // chasing it, but keep contract_id and contract_map intact: the // eventual contract-empty event from the bundle fd will route back // here with hint_exit_code = -1 and we'll process the real stop // through the normal logic below. if svc_type == config.SERVICE_TYPE_DAEMON() and hint_exit_code >= 0 and exit_code == 0 and rt.state == STATE_RUNNING() rt.pid = 0 - 1 rt.last_exit_code = exit_code table.runtimes[idx] = rt return end if rt.last_exit_code = exit_code rt.pid = 0 - 1 rt.contract_id = 0 - 1 // Remove contract mapping table.contract_map.remove(int_to_str(contract_id)) // Apply restart policy let policy = config.svc_restart_policy(def) // Oneshot services are "done" after running — don't restart if svc_type == config.SERVICE_TYPE_ONESHOT() let dur_ms = (time.time_now() - rt.last_start_time) * 1000 if exit_code == 0 rt.state = STATE_STOPPED() table.runtimes[idx] = rt ui.ui_event_started(name, dur_ms) else rt.state = STATE_FAILED() table.runtimes[idx] = rt ui.ui_event_failed(name, exit_code, dur_ms) end if return end if // Check if we were in the process of stopping if rt.state == STATE_STOPPING() let dur_ms = (time.time_now() - rt.last_start_time) * 1000 rt.state = STATE_STOPPED() table.runtimes[idx] = rt ui.ui_event_stopped(name, dur_ms) return end if // Daemon restart logic mut should_restart = false if policy == config.RESTART_ALWAYS() should_restart = true elif policy == config.RESTART_FAILURE() and exit_code != 0 should_restart = true end if if should_restart // Check max_retries let max = config.svc_max_retries(def) if max >= 0 and rt.restart_count >= max rt.state = STATE_MAINTENANCE() table.runtimes[idx] = rt if ui.ui_mode() == ui.MODE_PLAIN() println("supervisor: " + name + " entered maintenance (max retries)") end if return end if // Schedule restart after delay rt.state = STATE_WAITING() rt.restart_count = rt.restart_count + 1 rt.restart_scheduled_time = time.time_now() + config.svc_restart_delay(def) if ui.ui_mode() == ui.MODE_PLAIN() println("supervisor: " + name + " scheduled for restart") end if else let dur_ms = (time.time_now() - rt.last_start_time) * 1000 rt.state = STATE_STOPPED() table.runtimes[idx] = rt if exit_code == 0 ui.ui_event_stopped(name, dur_ms) else ui.ui_event_failed(name, exit_code, dur_ms) end if end if table.runtimes[idx] = rt end handle_contract_event // Handle a child exit by PID (for Linux/non-contract mode). // Scans the table for a matching PID. proc handle_child_exit(table: ServiceTable, pid: int, exit_code: int) mut i = 0 while i < table.count let rt = table.runtimes[i] if rt.pid == pid // Simulate a contract event with a fake contract id // Reuse handle_contract_event logic by temporarily setting fields rt.last_exit_code = exit_code rt.pid = 0 - 1 table.runtimes[i] = rt // Use contract_id if available, otherwise handle directly if rt.contract_id >= 0 handle_contract_event(table, rt.contract_id, exit_code) else // Inline the restart logic for non-contract mode let def = rt.def let name = config.svc_name(def) let policy = config.svc_restart_policy(def) let svc_type = config.svc_type(def) if svc_type == config.SERVICE_TYPE_TASK() let dur_ms = (time.time_now() - rt.last_start_time) * 1000 if exit_code == 0 rt.state = STATE_STOPPED() table.runtimes[i] = rt ui.ui_event_stopped(name, dur_ms) else rt.state = STATE_FAILED() rt.last_exit_code = exit_code table.runtimes[i] = rt ui.ui_event_failed(name, exit_code, dur_ms) end if elif svc_type == config.SERVICE_TYPE_ONESHOT() if exit_code == 0 rt.state = STATE_STOPPED() else rt.state = STATE_FAILED() end if elif rt.state == STATE_STOPPING() rt.state = STATE_STOPPED() else mut should_restart = false if policy == config.RESTART_ALWAYS() should_restart = true elif policy == config.RESTART_FAILURE() and exit_code != 0 should_restart = true end if if should_restart let max = config.svc_max_retries(def) if max >= 0 and rt.restart_count >= max rt.state = STATE_MAINTENANCE() else rt.state = STATE_WAITING() rt.restart_count = rt.restart_count + 1 rt.restart_scheduled_time = time.time_now() + config.svc_restart_delay(def) end if else rt.state = STATE_STOPPED() end if end if table.runtimes[i] = rt end if return end if i = i + 1 end while end handle_child_exit // Check for services whose stop timeout has expired and escalate to SIGKILL. proc check_stop_timeouts(table: ServiceTable) let now = time.time_now() mut i = 0 while i < table.count let rt = table.runtimes[i] if rt.state == STATE_STOPPING() and rt.stop_requested_time > 0 let timeout = config.svc_stop_timeout(rt.def) if now - rt.stop_requested_time >= timeout let name = config.svc_name(rt.def) if ui.ui_mode() == ui.MODE_PLAIN() println("supervisor: " + name + " stop timeout, sending SIGKILL") end if if rt.contract_id >= 0 contract.kill_contract(rt.contract_id, signal.SIGKILL()) elif rt.pid > 0 process.process_kill(rt.pid, signal.SIGKILL()) end if end if end if i = i + 1 end while end check_stop_timeouts // Check for services whose restart delay has elapsed and start them. proc check_restart_delays(table: ServiceTable) let now = time.time_now() mut i = 0 while i < table.count let rt = table.runtimes[i] if rt.state == STATE_WAITING() and rt.restart_scheduled_time > 0 if now >= rt.restart_scheduled_time rt.restart_scheduled_time = 0 table.runtimes[i] = rt let name = config.svc_name(rt.def) if ui.ui_mode() == ui.MODE_PLAIN() println("supervisor: restarting " + name) end if start_service(table, i) end if end if i = i + 1 end while end check_restart_delays // ============================================================================ // Reload support // ============================================================================ // Mark a service as disabled and stop it if running. // Does not remove from the table (to avoid index invalidation). proc disable_service(table: ServiceTable, idx: int) let rt = table.runtimes[idx] if rt.state == STATE_RUNNING() stop_service(table, idx) end if rt.state = STATE_DISABLED() rt.restart_scheduled_time = 0 table.runtimes[idx] = rt end disable_service // Mark a service as filtered-out-by-runlevel at boot time. Sets state // to STOPPED, the same terminal state that runtime runlevel transitions // produce for services that exit because they're no longer in the // active set. Without this, runlevel-filtered services sit in // STATE_WAITING forever — misleading in status output and tripping // pre-condition checks (e.g., replace's check_preconditions). The // state machine is more consistent if all "service didn't run / no // longer running" cases collapse to STOPPED regardless of how they // got there. proc mark_runlevel_filtered(table: ServiceTable, idx: int) let rt = table.runtimes[idx] rt.state = STATE_STOPPED() table.runtimes[idx] = rt end mark_runlevel_filtered // Adopt runtime fields directly on a ServiceRuntime, bypassing the // normal start/stop lifecycle. Used by main.apply_recovered_state for // live-replace recovery (the new zyginit doesn't fork the service — // it inherits a contract held by the same PID-1 proc_t and rebuilds // in-memory bookkeeping) and by utils/replace_probe for state-file // round-trip tests. Production startup and supervision still go // through start_service / handle_contract_event. proc adopt_runtime(table: ServiceTable, idx: int, state: int, pid: int, contract_id: int, restart_count: int, last_exit_code: int, last_start_time: int) let rt = table.runtimes[idx] rt.state = state rt.pid = pid rt.contract_id = contract_id rt.restart_count = restart_count rt.last_exit_code = last_exit_code rt.last_start_time = last_start_time table.runtimes[idx] = rt // Register contract mapping so handle_contract_event can route // events for this contract back to the right service. if contract_id >= 0 table.contract_map.set(int_to_str(contract_id), idx) end if end adopt_runtime // Test/probe helper — build a ServiceDef with a given name and all other // fields set to defaults. Wraps config.new_service_def() so external // callers (utils/replace_probe) don't need to reconstruct the struct // literal (cross-module struct literal syntax is not supported in Reef). fn make_def_with_name(name: string): config.ServiceDef mut def = config.new_service_def() def.name = name return def end make_def_with_name // ============================================================================ // Status // ============================================================================ fn get_status_line(table: ServiceTable, idx: int): string let rt = table.runtimes[idx] let name = config.svc_name(rt.def) let st = state_name(rt.state) mut line = name + " " + st if rt.pid > 0 line = str.concat(line, " pid=") line = str.concat(line, int_to_str(rt.pid)) end if if rt.contract_id >= 0 line = str.concat(line, " ctid=") line = str.concat(line, int_to_str(rt.contract_id)) end if // Uptime (only for running services with a valid start time) if rt.state == STATE_RUNNING() and rt.last_start_time > 0 let uptime = time.time_now() - rt.last_start_time line = str.concat(line, " uptime=") line = str.concat(line, format_duration(uptime)) end if // Exit code (show for stopped/failed/maintenance states) if rt.last_exit_code >= 0 and rt.state != STATE_RUNNING() and rt.state != STATE_WAITING() line = str.concat(line, " exit=") line = str.concat(line, int_to_str(rt.last_exit_code)) end if // Restart count (show if non-zero) if rt.restart_count > 0 line = str.concat(line, " restarts=") line = str.concat(line, int_to_str(rt.restart_count)) end if return line end get_status_line // Format a duration in seconds to a human-readable string. // Examples: "45s", "5m30s", "2h15m", "1d3h" fn format_duration(seconds: int): string if seconds < 0 return "0s" end if let days = seconds / 86400 let remaining_after_days = seconds % 86400 let hours = remaining_after_days / 3600 let remaining_after_hours = remaining_after_days % 3600 let minutes = remaining_after_hours / 60 let secs = remaining_after_hours % 60 if days > 0 mut result = int_to_str(days) + "d" if hours > 0 result = str.concat(result, int_to_str(hours)) result = str.concat(result, "h") end if return result end if if hours > 0 mut result = int_to_str(hours) + "h" if minutes > 0 result = str.concat(result, int_to_str(minutes)) result = str.concat(result, "m") end if return result end if if minutes > 0 mut result = int_to_str(minutes) + "m" if secs > 0 result = str.concat(result, int_to_str(secs)) result = str.concat(result, "s") end if return result end if return int_to_str(secs) + "s" end format_duration // ============================================================================ // Log access // ============================================================================ fn log_dir(): string return LOG_DIR() end log_dir fn get_service_log(name: string): string let log_path = str.concat(str.concat(LOG_DIR(), "/"), str.concat(name, ".log")) if not file.fileExists(log_path) return "no log available for " + name + "\n" end if let content_r = file.readFile(log_path) if res.is_err(content_r) return "no log available for " + name + "\n" end if let content = res.unwrap_ok(content_r) if str.length(content) == 0 return "(empty log)\n" end if return content end get_service_log // ============================================================================ // Helpers // ============================================================================ 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 /****************************************************************************** __ ____ __ / / ___ ____ _/ __/_____________ _/ /__ / / / _ \/ __ `/ /_/ ___/ ___/ __ `/ / _ \ / /___/ __/ /_/ / __(__ ) /__/ /_/ / / __/ /_____/\___/\__,_/_/ /____/\___/\__,_/_/\___/ (C)opyright 2025-2026, Leafscale, LLC - https://www.leafscale.com Project: zyginit Filename: ui.reef Authors: Chris Tusa License: Description: Unified visual rendering: palette, glyphs, sigil, rolling tape, divider gauge, spinner, final cards. ******************************************************************************/ module ui import core.str as str import sys.env as env import sys.process as process import time.time as time import version export fn MODE_PLAIN(): int fn MODE_RICH_ASCII(): int fn MODE_RICH_16(): int fn MODE_RICH_TRUE(): int proc ui_init(rich_mode: int) proc ui_detect_winsize() fn ui_mode(): int fn ui_runlevel(): string fn ui_demo(scenario: string): int fn detect_rich_mode(): int fn detect_rich_mode_for_main(): int fn paint(token: string, text: string): string fn glyph_ok(): string fn glyph_fail(): string fn glyph_pending(): string fn glyph_starting(): string fn sigil(): string proc ui_boot_start(num_svc: int, num_tiers: int, runlevel: string) proc ui_tier_start(tier: int) proc ui_tier_done(tier: int) proc ui_event_started(name: string, dur_ms: int) proc ui_event_failed(name: string, exit_code: int, dur_ms: int) proc ui_event_starting(name: string) proc ui_event_stopping(name: string) proc ui_event_skipped(name: string, reason: string) proc ui_tick() fn ui_spinner_frame(reverse: bool): string fn fmt_divider_gauge(done: int, total: int): string type BootStats proc ui_boot_complete(stats: BootStats) proc ui_shutdown_start(reason: string) proc ui_event_stopped(name: string, dur_ms: int) proc ui_shutdown_complete(reason: string, elapsed_ms: int) fn pad_right(s: string, n: int): string fn make_divider(): string proc ui_set_mode(m: int) fn zyginit_monotonic_ms(): int end export type BootStats = struct elapsed_ms: int online: int failed: int skipped: int slowest: [string] slowest_count: int end BootStats // Render modes. Decided once at startup by ui_init(). fn MODE_PLAIN(): int return 0 end MODE_PLAIN fn MODE_RICH_ASCII(): int return 1 end MODE_RICH_ASCII fn MODE_RICH_16(): int return 2 end MODE_RICH_16 fn MODE_RICH_TRUE(): int return 3 end MODE_RICH_TRUE mut g_mode: int = 0 mut g_failed: bool = false // set if write() to /dev/console errors // Declared here (before TAPE_HEIGHT) so the C prototype is emitted before // its first use; also appears grouped with the other tape externs below. extern "C" fn zyginit_tape_slots(): int // Dynamic screen size — populated by ui_detect_winsize(), called from // main.reef after setup_pid1_console() so /dev/console is on fd 1. // Safe defaults (80x25) used when TIOCGWINSZ fails (Linux dev builds, // pipes, integration tests with ZYGINIT_FORCE_80x25=1). mut g_screen_rows: int = 25 mut g_screen_cols: int = 80 mut g_tape_height: int = 12 mut g_line_width: int = 78 fn TAPE_HEIGHT(): int return g_tape_height end TAPE_HEIGHT fn LINE_WIDTH(): int return g_line_width end LINE_WIDTH mut g_phase: string = "" mut g_runlevel: string = "" mut g_num_svc: int = 0 mut g_num_tiers: int = 0 mut g_cur_tier: int = 0 mut g_done: int = 0 mut g_failed_count: int = 0 mut g_skipped: int = 0 mut g_failed_first: string = "" mut g_failed_first_exit: int = 0 mut g_boot_start_ms: int = 0 mut g_shutdown_reason: string = "" // Spinner state mut g_tick: int = 0 // Tape state counters. The backing arrays live in helpers.c (C static buffers) // to avoid Reef's module-level new[] limitation (Reef emits GCC statement-expr // initializers which are invalid as C static initializers). mut g_tape_head: int = 0 mut g_tape_count: int = 0 extern "C" fn zyginit_isatty(fd: int): int extern "C" fn zyginit_esc_str(): string extern "C" fn zyginit_monotonic_ms(): int extern "C" fn zyginit_tape_slots(): int extern "C" proc zyginit_tape_clear() extern "C" proc zyginit_tape_set(slot: int, elapsed: int, glyph: string, svc_name: string, note: string, active_flag: int) extern "C" fn zyginit_tape_get_elapsed(slot: int): int extern "C" fn zyginit_tape_get_glyph(slot: int): string extern "C" fn zyginit_tape_get_name(slot: int): string extern "C" fn zyginit_tape_get_note(slot: int): string extern "C" fn zyginit_tape_get_active(slot: int): int extern "C" proc zyginit_tape_set_active(slot: int, active_flag: int) extern "C" proc zyginit_tape_set_glyph(slot: int, glyph: string) extern "C" proc zyginit_tape_set_note(slot: int, note: string) extern "C" fn zyginit_get_winsize(fd: int, out_rows: [int], out_cols: [int]): int fn N_FRAMES(): int return 4 end N_FRAMES // Return the current spinner frame character. // Forward (starting): cycles / - \ | with increasing g_tick // Reverse (stopping): cycles | \ - / with increasing g_tick (unwinds) fn ui_spinner_frame(reverse: bool): string let i = g_tick % N_FRAMES() if reverse let r = (N_FRAMES() - 1) - i if r == 0 return "/" end if if r == 1 return "-" end if if r == 2 return "\\" end if return "|" end if if i == 0 return "/" end if if i == 1 return "-" end if if i == 2 return "\\" end if return "|" end ui_spinner_frame // Pure terminal capability detector (no PID check, no isatty check). // Inspects only env vars and TERM. Use this from --ui-demo and snapshot tests. // Production callers (main.reef boot path) should use // detect_rich_mode_for_main() — it adds the PID-1 and isatty guards. fn detect_rich_mode(): int if env.has_env("ZYGINIT_NO_UI") return MODE_PLAIN() end if if env.has_env("NO_COLOR") return MODE_PLAIN() end if let term = env.get_env_or("TERM", "") if term == "dumb" return MODE_PLAIN() end if if env.has_env("ZYGINIT_ASCII") return MODE_RICH_ASCII() end if if term == "sun" return MODE_RICH_16() end if if term == "vt100" return MODE_RICH_16() end if if term == "vt220" return MODE_RICH_16() end if if term == "sun-color" return MODE_RICH_TRUE() end if if str.index_of(term, "256color") >= 0 return MODE_RICH_TRUE() end if // Hammerhead UEFI framebuffer: kernel doesn't set TERM but tem // supports full ANSI/CSI/truecolor. Default to truecolor when // we have no TERM hint. if str.length(term) == 0 return MODE_RICH_TRUE() end if // Anything else: safe 16-color baseline. return MODE_RICH_16() end detect_rich_mode // PID-1-aware, tty-aware wrapper around detect_rich_mode. // Adds: non-PID-1 instances always emit plain; non-tty stdout always emits plain. // Call this from main.reef boot path; call detect_rich_mode() for tests/demo. fn detect_rich_mode_for_main(): int // Hard overrides if env.has_env("ZYGINIT_NO_UI") return MODE_PLAIN() end if if env.has_env("NO_COLOR") return MODE_PLAIN() end if // Production gates if process.getpid() != 1 return MODE_PLAIN() end if if zyginit_isatty(1) == 0 return MODE_PLAIN() end if // TERM-driven mode let term = env.get_env_or("TERM", "") if term == "dumb" return MODE_PLAIN() end if if env.has_env("ZYGINIT_ASCII") return MODE_RICH_ASCII() end if if term == "sun" return MODE_RICH_16() end if if term == "vt100" return MODE_RICH_16() end if if term == "vt220" return MODE_RICH_16() end if if term == "sun-color" return MODE_RICH_TRUE() end if if str.index_of(term, "256color") >= 0 return MODE_RICH_TRUE() end if // Hammerhead UEFI framebuffer: kernel doesn't set TERM but tem // supports full ANSI/CSI/truecolor. Default to truecolor when we // have a tty but no TERM hint. if str.length(term) == 0 return MODE_RICH_TRUE() end if // Anything else: safe 16-color baseline. return MODE_RICH_16() end detect_rich_mode_for_main // SGR escape sequences. ESC char via C helper (Reef lexer has no \x escapes). // esc_reset returns the SGR reset sequence "\e[0m". fn esc_reset(): string return str.concat(zyginit_esc_str(), "[0m") end esc_reset // 16-color foreground codes. fn sgr_steel(): string return str.concat(zyginit_esc_str(), "[37m") end sgr_steel fn sgr_teal(): string return str.concat(zyginit_esc_str(), "[36m") end sgr_teal fn sgr_ok(): string return str.concat(zyginit_esc_str(), "[32m") end sgr_ok fn sgr_warn(): string return str.concat(zyginit_esc_str(), "[33m") end sgr_warn fn sgr_fail(): string return str.concat(zyginit_esc_str(), "[31m") end sgr_fail fn sgr_mute(): string return str.concat(zyginit_esc_str(), "[2m") end sgr_mute // Truecolor foreground codes. fn sgr_steel_true(): string return str.concat(zyginit_esc_str(), "[38;2;110;123;139m") end sgr_steel_true fn sgr_teal_true(): string return str.concat(zyginit_esc_str(), "[38;2;0;106;111m") end sgr_teal_true fn sgr_ok_true(): string return str.concat(zyginit_esc_str(), "[38;2;46;139;87m") end sgr_ok_true fn sgr_warn_true(): string return str.concat(zyginit_esc_str(), "[38;2;255;191;0m") end sgr_warn_true fn sgr_fail_true(): string return str.concat(zyginit_esc_str(), "[38;2;200;32;31m") end sgr_fail_true // Wrap text in the given palette token, honoring g_mode. fn paint(token: string, text: string): string if g_mode == MODE_PLAIN() return text end if mut on = "" if g_mode == MODE_RICH_TRUE() if token == "frame" on = sgr_steel_true() elif token == "accent" on = sgr_teal_true() elif token == "ok" on = sgr_ok_true() elif token == "warn" on = sgr_warn_true() elif token == "fail" on = sgr_fail_true() elif token == "mute" on = sgr_mute() end if else if token == "frame" on = sgr_steel() elif token == "accent" on = sgr_teal() elif token == "ok" on = sgr_ok() elif token == "warn" on = sgr_warn() elif token == "fail" on = sgr_fail() elif token == "mute" on = sgr_mute() end if end if return str.concat(str.concat(on, text), esc_reset()) end paint // Glyphs. Selected by g_mode. fn glyph_ok(): string if g_mode == MODE_RICH_ASCII() return "#" end if if g_mode == MODE_PLAIN() return "ok" end if return "●" end glyph_ok fn glyph_fail(): string if g_mode == MODE_RICH_ASCII() return "X" end if if g_mode == MODE_PLAIN() return "fail" end if return "✕" end glyph_fail fn glyph_pending(): string if g_mode == MODE_RICH_ASCII() return "." end if if g_mode == MODE_PLAIN() return "pending" end if return "·" end glyph_pending fn glyph_starting(): string if g_mode == MODE_RICH_ASCII() return "o" end if if g_mode == MODE_PLAIN() return "starting" end if return "◉" end glyph_starting // Sigil: "[Z]" colored c.accent in rich modes. fn sigil(): string return paint("accent", "[Z]") end sigil // Plain-mode line: " [k=v ...]" fn fmt_plain_event(elapsed_ms: int, level: string, event: string, name: string, kv: string): string let secs = int_to_str(elapsed_ms / 1000) // centiseconds: truncate the within-second remainder to 0-99 (2 digits) let frac = int_to_str((elapsed_ms % 1000) / 10) mut pad = "" if str.length(frac) < 2 pad = "0" end if mut line = str.concat(" ", secs) line = str.concat(line, ".") line = str.concat(line, pad) line = str.concat(line, frac) line = str.concat(line, " ") line = str.concat(line, level) line = str.concat(line, " ") line = str.concat(line, event) line = str.concat(line, " ") line = str.concat(line, name) if str.length(kv) > 0 line = str.concat(line, " ") line = str.concat(line, kv) end if return line end fmt_plain_event proc tape_push(elapsed_ms: int, glyph: string, name: string, note: string, active_flag: int) let slot = g_tape_head % TAPE_HEIGHT() zyginit_tape_set(slot, elapsed_ms, glyph, name, note, active_flag) g_tape_head = g_tape_head + 1 if g_tape_count < TAPE_HEIGHT() g_tape_count = g_tape_count + 1 end if end tape_push fn fmt_tape_row(row_elapsed_ms: int, row_glyph: string, row_name: string, row_note: string): string let secs = row_elapsed_ms / 1000 let frac = (row_elapsed_ms % 1000) / 10 mut sf = int_to_str(frac) if str.length(sf) < 2 sf = str.concat("0", sf) end if let elapsed = str.concat(str.concat(int_to_str(secs), "."), sf) mut pad = "" let pad_n = 6 - str.length(elapsed) mut i = 0 while i < pad_n pad = str.concat(pad, " ") i = i + 1 end while mut row = str.concat(pad, elapsed) row = str.concat(row, " ") row = str.concat(row, row_glyph) row = str.concat(row, " ") row = str.concat(row, row_name) if str.length(row_note) > 0 row = str.concat(row, " ") row = str.concat(row, paint("mute", str.concat(str.concat("(", row_note), ")"))) end if return row end fmt_tape_row fn make_divider(): string mut div = "" mut k = 0 let dlen = g_line_width - 16 while k < dlen div = str.concat(div, "─") k = k + 1 end while return div end make_divider fn fmt_header(elapsed_ms: int): string let secs = elapsed_ms / 1000 let frac = (elapsed_ms % 1000) / 100 let elapsed = str.concat(str.concat(int_to_str(secs), "."), str.concat(int_to_str(frac), "s")) let phase_label = g_runlevel mut out = str.concat(" ", sigil()) out = str.concat(out, str.concat(" zyginit ", str.concat(version.VERSION(), " · "))) out = str.concat(out, paint("frame", phase_label)) let boot_visually_done = (g_phase == "boot") and ((g_done + g_skipped + g_failed_count) >= g_num_svc) and (g_num_svc > 0) if not boot_visually_done out = str.concat(out, " ") out = str.concat(out, paint("accent", str.concat("elapsed ", elapsed))) end if out = str.concat(out, "\n ") out = str.concat(out, paint("frame", str.concat(str.concat(int_to_str(g_num_svc), " services · tier "), str.concat(str.concat(int_to_str(g_cur_tier), " · "), str.concat(str.concat(int_to_str(g_done), " done · "), str.concat(str.concat(int_to_str(g_failed_count), " failed · "), str.concat(int_to_str(g_skipped), " skipped"))))))) out = str.concat(out, "\n ") if g_failed_count == 0 out = str.concat(out, paint("mute", "failed: none")) elif g_failed_count == 1 out = str.concat(out, paint("fail", str.concat(str.concat("failed: ", g_failed_first), str.concat(" exit=", int_to_str(g_failed_first_exit))))) else out = str.concat(out, paint("fail", str.concat(str.concat("failed: ", int_to_str(g_failed_count)), " services"))) end if out = str.concat(out, "\n ") out = str.concat(out, fmt_divider_gauge(g_done + g_skipped, g_num_svc)) return out end fmt_header // Render the boot/shutdown header's bottom rule as a divider with an // embedded progress gauge. Replaces make_divider() in fmt_header's // trailing rule and superseded fmt_progress_bar() (now removed) in // render_boot_screen. Total visual width = // g_line_width - 16 (matches make_divider). Brackets + 26-cell inner // content + equal lpad/rpad of dashes. Narrow-terminal fallback drops // the brackets and shows just "── NN% ──". fn fmt_divider_gauge(done: int, total: int): string if g_mode == MODE_PLAIN() return "" end if let total_width = g_line_width - 16 let bar_width = 20 let inner_width = 26 // bar(20) + " "(2) + "NNN%"(4) let bracket_width = inner_width + 2 // 28: '[' + inner + ']' mut filled = 0 if total > 0 filled = (done * bar_width) / total end if if filled > bar_width filled = bar_width end if if filled < 0 filled = 0 end if mut pct = 0 if total > 0 pct = (done * 100) / total end if if pct > 100 pct = 100 end if if pct < 0 pct = 0 end if // pct_field: right-aligned 3 cells + '%' = 4 cells. E.g. " 3%", " 31%", "100%". let pct_str = int_to_str(pct) let pct_len = str.length(pct_str) mut pct_field = "" mut pp = 0 while pp < (3 - pct_len) pct_field = str.concat(pct_field, " ") pp = pp + 1 end while pct_field = str.concat(pct_field, pct_str) pct_field = str.concat(pct_field, "%") // Narrow-terminal fallback: not enough room for brackets + padding. if total_width < bracket_width + 2 // Emit "── NN% ──" centered in total_width. let label = str.concat(" ", str.concat(pct_field, " ")) let label_len = 1 + 4 + 1 // " " + "NNN%" + " " mut pad = 0 if total_width > label_len pad = total_width - label_len end if let lpad_n = pad / 2 let rpad_n = pad - lpad_n mut out = "" mut a = 0 while a < lpad_n out = str.concat(out, "─") a = a + 1 end while out = str.concat(out, label) a = 0 while a < rpad_n out = str.concat(out, "─") a = a + 1 end while return paint("accent", out) end if // Full layout: lpad + '[' + bar + " " + pct_field + ']' + rpad let pad_total = total_width - bracket_width let lpad_n = pad_total / 2 let rpad_n = pad_total - lpad_n // Build the bar. Painting per-cell to support per-mode chars. mut bar = "" mut i = 0 while i < bar_width if g_mode == MODE_RICH_ASCII() if i < filled bar = str.concat(bar, "#") else bar = str.concat(bar, " ") end if else if i < filled bar = str.concat(bar, paint("accent", "█")) else bar = str.concat(bar, paint("mute", "░")) end if end if i = i + 1 end while // Assemble. Brackets, pct, and pad chars all painted "accent". mut out = "" mut j = 0 while j < lpad_n out = str.concat(out, "─") j = j + 1 end while out = paint("accent", out) out = str.concat(out, paint("accent", "[")) out = str.concat(out, bar) out = str.concat(out, paint("accent", " ")) out = str.concat(out, paint("accent", pct_field)) out = str.concat(out, paint("accent", "]")) mut rpad = "" j = 0 while j < rpad_n rpad = str.concat(rpad, "─") j = j + 1 end while out = str.concat(out, paint("accent", rpad)) return out end fmt_divider_gauge // Render a single tape row, overriding the stored glyph with the current // spinner frame when the row's active flag indicates a starting or stopping // transition (active==1 = forward spinner, active==2 = reverse spinner). fn fmt_tape_row_at(slot: int): string let elapsed_ms = zyginit_tape_get_elapsed(slot) let name = zyginit_tape_get_name(slot) let note = zyginit_tape_get_note(slot) let stored_glyph = zyginit_tape_get_glyph(slot) let row_active = zyginit_tape_get_active(slot) mut glyph = stored_glyph if row_active == 1 glyph = paint("warn", ui_spinner_frame(false)) elif row_active == 2 glyph = paint("warn", ui_spinner_frame(true)) end if return fmt_tape_row(elapsed_ms, glyph, name, note) end fmt_tape_row_at // Scan tape for any slot with a non-zero active flag (i.e., a spinner is live). fn has_active_tape_row(): bool mut i = 0 while i < g_tape_count let logical = g_tape_head - 1 - i mut slot = logical % TAPE_HEIGHT() if slot < 0 slot = slot + TAPE_HEIGHT() end if if zyginit_tape_get_active(slot) != 0 return true end if i = i + 1 end while return false end has_active_tape_row // Find the most-recently-pushed tape slot with a non-zero active flag and // whose name matches `name`. Returns the slot index or -1 if not found. fn tape_find_active_by_name(name: string): int if g_tape_count == 0 return 0 - 1 end if mut i = 0 while i < g_tape_count let logical = g_tape_head - 1 - i mut slot = logical % TAPE_HEIGHT() if slot < 0 slot = slot + TAPE_HEIGHT() end if if zyginit_tape_get_active(slot) != 0 if zyginit_tape_get_name(slot) == name return slot end if end if i = i + 1 end while return 0 - 1 end tape_find_active_by_name fn render_boot_screen(elapsed_ms: int): string if g_mode == MODE_PLAIN() return "" end if mut out = str.concat(fmt_header(elapsed_ms), "\n") let start = g_tape_head - g_tape_count mut i = 0 while i < TAPE_HEIGHT() if i < g_tape_count let slot = (start + i) % TAPE_HEIGHT() out = str.concat(out, fmt_tape_row_at(slot)) end if if i < (TAPE_HEIGHT() - 1) out = str.concat(out, "\n") end if i = i + 1 end while // No trailing newline: total height is exactly g_screen_rows. A trailing // \n would advance the cursor past the bottom edge and scroll the [Z] // banner off the top of the screen. The progress gauge lives in the // header rule (see fmt_divider_gauge), not on the last row. return out end render_boot_screen // Advance the tick counter and, if in rich mode with an active spinner, redraw. proc ui_tick() g_tick = g_tick + 1 if g_mode == MODE_PLAIN() return end if if not has_active_tape_row() return end if let elapsed = zyginit_monotonic_ms() - g_boot_start_ms print(str.concat(str.concat(zyginit_esc_str(), "[2J"), str.concat(zyginit_esc_str(), "[H"))) print(render_boot_screen(elapsed)) end ui_tick // Rich-mode-only redraw. Plain mode emits its own line before calling this. proc emit_redraw(elapsed_ms: int) if g_mode == MODE_PLAIN() return end if print(str.concat(str.concat(zyginit_esc_str(), "[2J"), str.concat(zyginit_esc_str(), "[H"))) print(render_boot_screen(elapsed_ms)) end emit_redraw proc ui_boot_start(num_svc: int, num_tiers: int, runlevel: string) g_phase = "boot" g_runlevel = runlevel g_num_svc = num_svc g_num_tiers = num_tiers g_cur_tier = 0 g_done = 0 g_failed_count = 0 g_skipped = 0 g_failed_first = "" g_failed_first_exit = 0 g_tape_head = 0 g_tape_count = 0 zyginit_tape_clear() g_boot_start_ms = zyginit_monotonic_ms() end ui_boot_start proc ui_tier_start(tier: int) g_cur_tier = tier end ui_tier_start proc ui_tier_done(tier: int) // header repaints happen via emit_redraw on next event/tick end ui_tier_done // dur_ms: service's own runtime (from supervisor). Reserved for future // use (Task 6 spinner display); the tape currently shows wall-clock // elapsed since boot, not per-service duration. proc ui_event_started(name: string, dur_ms: int) let elapsed = zyginit_monotonic_ms() - g_boot_start_ms g_done = g_done + 1 let slot = tape_find_active_by_name(name) if slot >= 0 // Update the existing active row in place zyginit_tape_set_glyph(slot, paint("ok", glyph_ok())) zyginit_tape_set_note(slot, "") zyginit_tape_set_active(slot, 0) else // No prior starting row — push a fresh static row tape_push(elapsed, paint("ok", glyph_ok()), name, "", 0) end if if g_mode == MODE_PLAIN() println(fmt_plain_event(elapsed, "info", "started", name, "")) else emit_redraw(elapsed) end if end ui_event_started // dur_ms: service's own runtime (from supervisor). Reserved for future // use (Task 6 spinner display); the tape currently shows wall-clock // elapsed since boot, not per-service duration. proc ui_event_failed(name: string, exit_code: int, dur_ms: int) let elapsed = zyginit_monotonic_ms() - g_boot_start_ms g_done = g_done + 1 g_failed_count = g_failed_count + 1 if g_failed_count == 1 g_failed_first = name g_failed_first_exit = exit_code end if let exit_kv = str.concat("exit=", int_to_str(exit_code)) let slot = tape_find_active_by_name(name) if slot >= 0 zyginit_tape_set_glyph(slot, paint("fail", glyph_fail())) zyginit_tape_set_note(slot, exit_kv) zyginit_tape_set_active(slot, 0) else tape_push(elapsed, paint("fail", glyph_fail()), name, exit_kv, 0) end if if g_mode == MODE_PLAIN() println(fmt_plain_event(elapsed, "err", "failed", name, exit_kv)) else emit_redraw(elapsed) end if end ui_event_failed // Called when a service is transitioning to started (forward spinner visible). proc ui_event_starting(name: string) let elapsed = zyginit_monotonic_ms() - g_boot_start_ms // active=1: forward spinner (starting) tape_push(elapsed, paint("warn", ui_spinner_frame(false)), name, "", 1) if g_mode == MODE_PLAIN() println(fmt_plain_event(elapsed, "info", "starting", name, "")) else emit_redraw(elapsed) end if end ui_event_starting // Called when a service is transitioning to stopped (reverse spinner visible). proc ui_event_stopping(name: string) let elapsed = zyginit_monotonic_ms() - g_boot_start_ms // active=2: reverse spinner (stopping) tape_push(elapsed, paint("warn", ui_spinner_frame(true)), name, "", 2) if g_mode == MODE_PLAIN() println(fmt_plain_event(elapsed, "info", "stopping", name, "")) else emit_redraw(elapsed) end if end ui_event_stopping // Called when a service's [condition] block fails and the service is skipped // (STATE_SKIPPED). No process is ever forked; the service is counted as done // for progress purposes but not as online or failed. proc ui_event_skipped(name: string, reason: string) let elapsed = zyginit_monotonic_ms() - g_boot_start_ms g_skipped = g_skipped + 1 let note = str.concat("skipped: ", reason) tape_push(elapsed, paint("mute", glyph_pending()), name, note, 0) if g_mode == MODE_PLAIN() let kv = str.concat("reason=", reason) println(fmt_plain_event(elapsed, "info", "skipped", name, kv)) else emit_redraw(elapsed) end if end ui_event_skipped proc ui_init(rich_mode: int) g_mode = rich_mode g_failed = false end ui_init // Detect terminal size via TIOCGWINSZ and update g_tape_height / g_line_width. // Called from main.reef after setup_pid1_console() (so fd 1 = /dev/console) // and before ui_boot_start(). // When ZYGINIT_FORCE_80x25 is set (integration / snapshot tests), lock to // 80x25 so snapshot output is deterministic regardless of the host terminal. proc ui_detect_winsize() if env.has_env("ZYGINIT_FORCE_80x25") g_screen_rows = 25 g_screen_cols = 80 else let rows = new [int](1) let cols = new [int](1) rows[0] = 25 cols[0] = 80 if zyginit_get_winsize(1, rows, cols) == 0 g_screen_rows = rows[0] g_screen_cols = cols[0] end if end if // Tape height = screen rows - (header 4 rows including gauge divider). // Gauge lives inside the header, not on a separate trailing row. g_tape_height = g_screen_rows - 4 if g_tape_height < 4 g_tape_height = 4 end if if g_tape_height > 32 g_tape_height = 32 end if // Line width = cols - 2 (1-char margin each side) g_line_width = g_screen_cols - 2 if g_line_width < 40 g_line_width = 40 end if end ui_detect_winsize fn ui_mode(): int return g_mode end ui_mode fn ui_runlevel(): string return g_runlevel end ui_runlevel proc ui_set_mode(m: int) g_mode = m end ui_set_mode proc ui_boot_complete(stats: BootStats) if g_mode == MODE_PLAIN() println(fmt_plain_event(stats.elapsed_ms, "info", "boot_complete", "summary", str.concat(str.concat("online=", int_to_str(stats.online)), str.concat(str.concat(" failed=", int_to_str(stats.failed)), str.concat(" skipped=", int_to_str(stats.skipped)))))) return end if // Clear screen, paint final card. print(str.concat(str.concat(zyginit_esc_str(), "[2J"), str.concat(zyginit_esc_str(), "[H"))) let secs = stats.elapsed_ms / 1000 let frac = (stats.elapsed_ms % 1000) / 100 let elapsed = str.concat(str.concat(int_to_str(secs), "."), str.concat(int_to_str(frac), "s")) let summary_count = int_to_str(stats.online + stats.failed + stats.skipped) mut out = str.concat(" ", sigil()) out = str.concat(out, str.concat(" zyginit ", str.concat(version.VERSION(), " · "))) out = str.concat(out, paint("frame", g_runlevel)) out = str.concat(out, " ") out = str.concat(out, paint("accent", str.concat("boot ", elapsed))) out = str.concat(out, "\n") out = str.concat(out, " ") out = str.concat(out, paint("frame", str.concat(str.concat(summary_count, " services — "), str.concat(str.concat(int_to_str(stats.online), " online, "), str.concat(str.concat(int_to_str(stats.failed), " failed, "), str.concat(int_to_str(stats.skipped), " skipped")))))) out = str.concat(out, "\n\n") if stats.failed == 0 out = str.concat(out, " ") out = str.concat(out, paint("ok", "all services online")) out = str.concat(out, "\n") elif stats.failed == 1 out = str.concat(out, " ") out = str.concat(out, paint("fail", str.concat(str.concat("failed: ", g_failed_first), str.concat(" exit=", int_to_str(g_failed_first_exit))))) out = str.concat(out, "\n") out = str.concat(out, " ") out = str.concat(out, paint("accent", str.concat("→ zygctl log ", g_failed_first))) out = str.concat(out, paint("mute", " to see why")) out = str.concat(out, "\n") else out = str.concat(out, " ") out = str.concat(out, paint("fail", str.concat(str.concat("failed: ", int_to_str(stats.failed)), " services"))) out = str.concat(out, "\n") out = str.concat(out, " ") out = str.concat(out, paint("accent", "→ zygctl status")) out = str.concat(out, paint("mute", " to see them")) out = str.concat(out, "\n") end if out = str.concat(out, " ") out = str.concat(out, paint("accent", make_divider())) out = str.concat(out, "\n") // Slowest line let slow_n = stats.slowest_count if slow_n > 0 mut slow = "slowest:" mut i = 0 mut max = slow_n if max > 3 max = 3 end if while i < max slow = str.concat(slow, " ") slow = str.concat(slow, stats.slowest[i]) i = i + 1 end while out = str.concat(out, " ") out = str.concat(out, paint("frame", slow)) out = str.concat(out, "\n") end if print(out) end ui_boot_complete proc ui_shutdown_start(reason: string) g_phase = "shutdown" g_shutdown_reason = reason g_runlevel = str.concat("stopping for ", reason) g_done = 0 g_failed_count = 0 g_skipped = 0 g_failed_first = "" g_failed_first_exit = 0 g_tape_head = 0 g_tape_count = 0 g_boot_start_ms = zyginit_monotonic_ms() end ui_shutdown_start proc ui_event_stopped(name: string, dur_ms: int) let elapsed = zyginit_monotonic_ms() - g_boot_start_ms g_done = g_done + 1 let slot = tape_find_active_by_name(name) if slot >= 0 zyginit_tape_set_glyph(slot, paint("mute", glyph_pending())) zyginit_tape_set_note(slot, "") zyginit_tape_set_active(slot, 0) else tape_push(elapsed, paint("mute", glyph_pending()), name, "", 0) end if if g_mode == MODE_PLAIN() println(fmt_plain_event(elapsed, "info", "stopped", name, "")) else emit_redraw(elapsed) end if end ui_event_stopped proc ui_shutdown_complete(reason: string, elapsed_ms: int) if g_mode == MODE_PLAIN() println(fmt_plain_event(elapsed_ms, "info", "shutdown_complete", reason, str.concat("down=", int_to_str(g_done)))) return end if print(str.concat(str.concat(zyginit_esc_str(), "[2J"), str.concat(zyginit_esc_str(), "[H"))) let secs = elapsed_ms / 1000 let frac = (elapsed_ms % 1000) / 100 let elapsed = str.concat(str.concat(int_to_str(secs), "."), str.concat(int_to_str(frac), "s")) mut out = str.concat(" ", sigil()) out = str.concat(out, str.concat(" zyginit ", str.concat(version.VERSION(), " · "))) out = str.concat(out, paint("frame", str.concat(reason, " complete"))) out = str.concat(out, " ") out = str.concat(out, paint("accent", str.concat(str.concat(reason, " "), elapsed))) out = str.concat(out, "\n ") mut stat_str = "stopped cleanly" if g_failed_count > 0 stat_str = str.concat(str.concat("stopped (", int_to_str(g_failed_count)), " force-killed)") end if out = str.concat(out, paint("frame", str.concat(str.concat(int_to_str(g_num_svc), " services "), stat_str))) out = str.concat(out, "\n ") out = str.concat(out, paint("accent", make_divider())) out = str.concat(out, "\n ") out = str.concat(out, paint("mute", "invoking uadmin(A_SHUTDOWN, AD_BOOT)")) out = str.concat(out, "\n") print(out) end ui_shutdown_complete // Scenario dispatcher for --ui-demo. Called from main.reef. // Returns 0 on success, 1 if the scenario name is unknown. fn ui_demo(scenario: string): int if scenario == "skeleton" println("ui.reef skeleton ok, mode=" + int_to_str(g_mode)) return 0 end if if scenario == "plain_boot" println(fmt_plain_event(40, "info", "started", "root-fs", "")) println(fmt_plain_event(120, "info", "started", "crypto", "dur_ms=80")) println(fmt_plain_event(310, "info", "started", "devfs", "dur_ms=190")) println(fmt_plain_event(5020, "err", "failed", "network", "exit=1 dur_ms=4023")) return 0 end if if str.index_of(scenario, "detect_") == 0 g_mode = detect_rich_mode() println("mode=" + int_to_str(g_mode)) return 0 end if if scenario == "palette_true" or scenario == "palette_16" g_mode = detect_rich_mode() println(paint("frame", "frame text")) println(paint("accent", "accent text")) println(paint("ok", "ok text")) println(paint("warn", "warn text")) println(paint("fail", "fail text")) println(paint("mute", "mute text")) return 0 end if if scenario == "glyphs_unicode" or scenario == "glyphs_ascii" g_mode = detect_rich_mode() println(glyph_ok() + " " + glyph_starting() + " " + glyph_fail() + " " + glyph_pending()) return 0 end if if scenario == "sigil_rich" g_mode = detect_rich_mode() println(sigil() + " zyginit " + version.VERSION()) return 0 end if if scenario == "boot_tape_rich" or scenario == "boot_tape_ascii" or scenario == "boot_tape_plain" g_mode = detect_rich_mode() ui_boot_start(17, 8, "multi-user") g_boot_start_ms = 0 let names = new [string](9) names[0] = "root-fs" names[1] = "crypto" names[2] = "devfs" names[3] = "swap" names[4] = "filesystem" names[5] = "identity" names[6] = "sysconfig" names[7] = "dlmgmtd" names[8] = "network" let elapsed_seq = new [int](9) elapsed_seq[0] = 40 elapsed_seq[1] = 160 elapsed_seq[2] = 310 elapsed_seq[3] = 490 elapsed_seq[4] = 1040 elapsed_seq[5] = 1080 elapsed_seq[6] = 1360 elapsed_seq[7] = 1550 elapsed_seq[8] = 5020 mut i = 0 while i < 8 g_done = g_done + 1 tape_push(elapsed_seq[i], paint("ok", glyph_ok()), names[i], "", 0) i = i + 1 end while g_failed_count = 1 g_failed_first = "network" g_failed_first_exit = 1 g_cur_tier = 5 g_done = g_done + 1 tape_push(5020, paint("fail", glyph_fail()), "network", "exit=1", 0) if g_mode == MODE_PLAIN() let lvls = new [string](9) let evs = new [string](9) let kvs = new [string](9) mut j = 0 while j < 8 lvls[j] = "info" evs[j] = "started" kvs[j] = "" j = j + 1 end while lvls[8] = "err" evs[8] = "failed" kvs[8] = "exit=1" mut k = 0 while k < 9 println(fmt_plain_event(elapsed_seq[k], lvls[k], evs[k], names[k], kvs[k])) k = k + 1 end while else print(render_boot_screen(5020)) end if return 0 end if if scenario == "progress_rich" or scenario == "progress_ascii" if scenario == "progress_ascii" g_mode = MODE_RICH_ASCII() else g_mode = detect_rich_mode() if g_mode == MODE_PLAIN() g_mode = MODE_RICH_TRUE() end if end if ui_detect_winsize() ui_boot_start(17, 8, "multi-user") g_done = 12 g_failed_count = 1 println(fmt_divider_gauge(g_done + g_skipped, g_num_svc)) return 0 end if if scenario == "progress_full" g_mode = detect_rich_mode() if g_mode == MODE_PLAIN() g_mode = MODE_RICH_TRUE() end if ui_detect_winsize() ui_boot_start(17, 8, "multi-user") g_done = 17 println(fmt_divider_gauge(g_done + g_skipped, g_num_svc)) return 0 end if if scenario == "gauge_live_31" or scenario == "gauge_live_31_ascii" if scenario == "gauge_live_31_ascii" g_mode = MODE_RICH_ASCII() else g_mode = detect_rich_mode() if g_mode == MODE_PLAIN() g_mode = MODE_RICH_TRUE() end if end if ui_detect_winsize() println(fmt_divider_gauge(31, 100)) return 0 end if if scenario == "gauge_live_100" g_mode = detect_rich_mode() if g_mode == MODE_PLAIN() g_mode = MODE_RICH_TRUE() end if ui_detect_winsize() println(fmt_divider_gauge(100, 100)) return 0 end if if scenario == "gauge_live_0" g_mode = detect_rich_mode() if g_mode == MODE_PLAIN() g_mode = MODE_RICH_TRUE() end if ui_detect_winsize() println(fmt_divider_gauge(0, 100)) return 0 end if if scenario == "spinner_forward" g_mode = detect_rich_mode() mut i = 0 while i < 8 g_tick = i print(ui_spinner_frame(false)) i = i + 1 end while println("") return 0 end if if scenario == "spinner_reverse" g_mode = detect_rich_mode() mut i = 0 while i < 8 g_tick = i print(ui_spinner_frame(true)) i = i + 1 end while println("") return 0 end if if scenario == "spinner_in_tape" g_mode = detect_rich_mode() ui_boot_start(17, 8, "multi-user") g_boot_start_ms = 0 tape_push(40, paint("ok", glyph_ok()), "root-fs", "", 0) tape_push(160, paint("ok", glyph_ok()), "crypto", "", 0) tape_push(310, paint("ok", glyph_ok()), "devfs", "", 0) g_done = 3 // Push the network row with active=1 (forward spinner). // Bypass ui_event_starting to avoid a host-dependent monotonic_ms() // reading in the demo. g_tick = 0 tape_push(2100, paint("warn", ui_spinner_frame(false)), "network", "", 1) // Now bump tick so the live re-render shows frame index 2. g_tick = 2 print(str.concat(str.concat(zyginit_esc_str(), "[2J"), str.concat(zyginit_esc_str(), "[H"))) print(render_boot_screen(2100)) return 0 end if if scenario == "final_card_ok" g_mode = detect_rich_mode() ui_boot_start(17, 8, "multi-user") let slow = new [string](3) slow[0] = "dlmgmtd 1.2s" slow[1] = "filesystem 0.55s" slow[2] = "sshd 0.37s" let stats = BootStats{ elapsed_ms: 8300, online: 17, failed: 0, skipped: 0, slowest: slow, slowest_count: 3 } ui_boot_complete(stats) return 0 end if if scenario == "final_card_failed" g_mode = detect_rich_mode() ui_boot_start(17, 8, "multi-user") g_failed_first = "network" g_failed_first_exit = 1 let slow = new [string](3) slow[0] = "network 5.02s" slow[1] = "filesystem 0.55s" slow[2] = "sshd 0.37s" let stats = BootStats{ elapsed_ms: 8300, online: 16, failed: 1, skipped: 0, slowest: slow, slowest_count: 3 } ui_boot_complete(stats) return 0 end if if scenario == "shutdown_mirror" g_mode = detect_rich_mode() ui_boot_start(17, 8, "multi-user") ui_shutdown_start("reboot") g_boot_start_ms = 0 tape_push(40, paint("mute", glyph_pending()), "sshd", "", 0) tape_push(160, paint("mute", glyph_pending()), "console-login", "", 0) tape_push(310, paint("mute", glyph_pending()), "cron", "", 0) // active=2: reverse spinner (stopping) g_tick = 0 tape_push(480, paint("warn", ui_spinner_frame(true)), "syslogd", "", 2) g_tick = 3 g_done = 4 g_cur_tier = 6 print(str.concat(str.concat(zyginit_esc_str(), "[2J"), str.concat(zyginit_esc_str(), "[H"))) print(render_boot_screen(480)) return 0 end if if scenario == "shutdown_final" g_mode = detect_rich_mode() ui_boot_start(17, 8, "multi-user") ui_shutdown_start("reboot") g_num_svc = 17 g_done = 17 ui_shutdown_complete("reboot", 2100) return 0 end if if scenario == "task_ok" g_mode = detect_rich_mode() ui_boot_start(5, 2, "multi-user") g_boot_start_ms = 0 tape_push(40, paint("ok", glyph_ok()), "root-fs", "", 0) tape_push(160, paint("ok", glyph_ok()), "task-svc", "", 0) g_done = 2 let slow_tok = new [string](3) let stats_tok = BootStats{ elapsed_ms: 200, online: 5, failed: 0, skipped: 0, slowest: slow_tok, slowest_count: 0 } ui_boot_complete(stats_tok) return 0 end if if scenario == "task_failed" g_mode = detect_rich_mode() ui_boot_start(5, 2, "multi-user") g_boot_start_ms = 0 tape_push(40, paint("ok", glyph_ok()), "root-fs", "", 0) tape_push(160, paint("fail", glyph_fail()), "task-svc", "exit=1", 0) g_done = 2 g_failed_count = 1 g_failed_first = "task-svc" g_failed_first_exit = 1 let slow_tf = new [string](3) let stats_tf = BootStats{ elapsed_ms: 200, online: 4, failed: 1, skipped: 0, slowest: slow_tf, slowest_count: 0 } ui_boot_complete(stats_tf) return 0 end if if scenario == "boot_with_skipped" g_mode = detect_rich_mode() ui_boot_start(5, 2, "multi-user") g_boot_start_ms = 0 tape_push(40, paint("ok", glyph_ok()), "root-fs", "", 0) tape_push(160, paint("mute", glyph_pending()), "acpihpd", "skipped: missing /dev/acpihp", 0) tape_push(310, paint("ok", glyph_ok()), "cron", "", 0) g_done = 2 g_skipped = 1 print(str.concat(str.concat(zyginit_esc_str(), "[2J"), str.concat(zyginit_esc_str(), "[H"))) print(render_boot_screen(400)) return 0 end if if scenario == "final_card_with_skipped" g_mode = detect_rich_mode() ui_boot_start(23, 9, "multi-user") let slow_fcs = new [string](3) let stats_fcs = BootStats{ elapsed_ms: 8300, online: 19, failed: 0, skipped: 4, slowest: slow_fcs, slowest_count: 0 } ui_boot_complete(stats_fcs) return 0 end if if scenario == "status_skipped" // status_skipped requires zygctl status table rendering with a // SKIPPED row. The existing --ui-demo infrastructure doesn't yet // build a ServiceTable mock that ui_render.ui_render_status can // render. Deferred — emit a placeholder note so the test exists // and tracks the gap. g_mode = detect_rich_mode() println("status_skipped: deferred — zygctl status snapshot via --ui-demo not yet supported") return 0 end if println("ui.reef: unknown demo scenario: " + scenario) return 1 end ui_demo 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 // ============================================================================ // Status/list rendering (used by socket command handlers) // ============================================================================ fn pad_right(s: string, n: int): string let extra = n - str.length(s) if extra <= 0 return s end if mut out = s mut i = 0 while i < extra out = str.concat(out, " ") i = i + 1 end while return out end pad_right end module /****************************************************************************** __ ____ __ / / ___ ____ _/ __/_____________ _/ /__ / / / _ \/ __ `/ /_/ ___/ ___/ __ `/ / _ \ / /___/ __/ /_/ / __(__ ) /__/ /_/ / / __/ /_____/\___/\__,_/_/ /____/\___/\__,_/_/\___/ (C)opyright 2025-2026, Leafscale, LLC - https://www.leafscale.com Project: zyginit Filename: ui_render.reef Authors: Chris Tusa License: Description: Status/list table rendering for zygctl status and list commands. Separated from ui.reef to avoid a circular dependency with supervisor. ******************************************************************************/ module ui_render import ui import supervisor import config import version import core.str as str import time.time as time export fn ui_render_status(table: supervisor.ServiceTable, want_banner: int, plain: int): string fn ui_render_status_one(table: supervisor.ServiceTable, idx: int, plain: int): string fn ui_render_list(table: supervisor.ServiceTable, plain: int): string end export // ============================================================================ // Helpers // ============================================================================ 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 // Helper: return the palette token ("ok"/"warn"/"fail"/"mute") for a state. fn state_color(state: int): string if state == supervisor.STATE_RUNNING() return "ok" end if if state == supervisor.STATE_STARTING() return "warn" end if if state == supervisor.STATE_STOPPING() return "warn" end if if state == supervisor.STATE_STOPPED() return "mute" end if if state == supervisor.STATE_FAILED() return "fail" end if if state == supervisor.STATE_MAINTENANCE() return "fail" end if if state == supervisor.STATE_DISABLED() return "mute" end if if state == supervisor.STATE_WAITING() return "warn" end if if state == supervisor.STATE_SKIPPED() return "mute" end if return "mute" end state_color // Helper: return the raw glyph character for a state (no paint wrapping). fn state_glyph(state: int): string if state == supervisor.STATE_RUNNING() return ui.glyph_ok() end if if state == supervisor.STATE_STARTING() return ui.glyph_starting() end if if state == supervisor.STATE_STOPPING() return ui.glyph_starting() end if if state == supervisor.STATE_STOPPED() return ui.glyph_pending() end if if state == supervisor.STATE_FAILED() return ui.glyph_fail() end if if state == supervisor.STATE_MAINTENANCE() return ui.glyph_fail() end if if state == supervisor.STATE_DISABLED() return ui.glyph_pending() end if if state == supervisor.STATE_WAITING() return ui.glyph_starting() end if if state == supervisor.STATE_SKIPPED() return ui.glyph_pending() end if return "?" end state_glyph // Helper: return the label string for a state (leading space, no glyph). fn state_label(state: int): string if state == supervisor.STATE_RUNNING() return " running" end if if state == supervisor.STATE_STARTING() return " starting" end if if state == supervisor.STATE_STOPPING() return " stopping" end if if state == supervisor.STATE_STOPPED() return " stopped" end if if state == supervisor.STATE_FAILED() return " failed" end if if state == supervisor.STATE_MAINTENANCE() return " maint." end if if state == supervisor.STATE_DISABLED() return " disabled" end if if state == supervisor.STATE_WAITING() return " waiting" end if if state == supervisor.STATE_SKIPPED() return " skipped" end if return " ?" end state_label // Compute the SERVICE column width for status tables. // Scans all service names and returns the widest + 2 padding, clamped to [12, 24]. // The "SERVICE" header is 7 chars; we use that as the floor before clamping. fn compute_service_col_width(table: supervisor.ServiceTable): int let count = supervisor.service_count(table) mut max_len = 7 // "SERVICE" header length mut i = 0 while i < count let rt = supervisor.get_runtime(table, i) let def = supervisor.rt_def(rt) let name = config.svc_name(def) let n = str.length(name) if n > max_len max_len = n end if i = i + 1 end while // Clamp: 12 minimum (visual stability), 24 maximum (avoid eating other columns) if max_len < 12 max_len = 12 end if if max_len > 24 max_len = 24 end if return max_len + 2 // 2 cols of padding after the name end compute_service_col_width fn fmt_status_row(table: supervisor.ServiceTable, idx: int, name_col_width: int): string let rt = supervisor.get_runtime(table, idx) let def = supervisor.rt_def(rt) let name = config.svc_name(def) let state = supervisor.rt_state(rt) let pid = supervisor.rt_pid(rt) let ctid = supervisor.rt_contract_id(rt) mut row = str.concat(" ", ui.pad_right(name, name_col_width)) // STATE column: paint only the glyph, then pad the label so ANSI escapes // don't inflate the byte count and shift downstream columns. let glyph_color = state_color(state) let g = state_glyph(state) let label = state_label(state) row = str.concat(row, ui.paint(glyph_color, g)) row = str.concat(row, ui.pad_right(label, 10)) if pid > 0 row = str.concat(row, ui.pad_right(int_to_str(pid), 7)) else row = str.concat(row, ui.pad_right("", 7)) end if if ctid > 0 row = str.concat(row, ui.pad_right(int_to_str(ctid), 7)) else row = str.concat(row, ui.pad_right("", 7)) end if // Uptime: only for RUNNING with a valid start time let last_start = supervisor.rt_last_start_time(rt) if state == supervisor.STATE_RUNNING() and last_start > 0 let uptime_s = time.time_now() - last_start row = str.concat(row, ui.pad_right(supervisor.format_duration(uptime_s), 10)) else row = str.concat(row, ui.pad_right("", 10)) end if // Notes column: exit code and restart count mut notes = "" let last_exit = supervisor.rt_last_exit_code(rt) let restart_count = supervisor.rt_restart_count(rt) let reason = supervisor.rt_skip_reason(rt) if state == supervisor.STATE_SKIPPED() and str.length(reason) > 0 notes = reason elif state == supervisor.STATE_FAILED() and str.length(reason) > 0 notes = reason elif last_exit >= 0 and state != supervisor.STATE_RUNNING() and state != supervisor.STATE_WAITING() notes = str.concat("exit=", int_to_str(last_exit)) if restart_count > 0 notes = str.concat(notes, str.concat(" restarts=", int_to_str(restart_count))) end if elif restart_count > 0 notes = str.concat("restarts=", int_to_str(restart_count)) end if row = str.concat(row, notes) return row end fmt_status_row // ============================================================================ // Public render functions // ============================================================================ fn ui_render_status(table: supervisor.ServiceTable, want_banner: int, plain: int): string let prev_mode = ui.ui_mode() // Mode source: when plain=1 the client requested no escapes (always // honored). When plain=0, the daemon's own g_mode (set at startup // by TTY/term detection) determines rendering. Task 10 ensures the // client only sends a bare STATUS/LIST when its own stdout is rich. if plain != 0 ui.ui_set_mode(ui.MODE_PLAIN()) end if let count = supervisor.service_count(table) // Summary counts mut online = 0 mut failed = 0 mut skipped = 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 elif st == supervisor.STATE_STOPPED() // A oneshot that exited 0 is "done and online" from the system's perspective. let rt2 = supervisor.get_runtime(table, i) if supervisor.rt_last_exit_code(rt2) == 0 online = online + 1 end if elif st == supervisor.STATE_FAILED() or st == supervisor.STATE_MAINTENANCE() failed = failed + 1 elif st == supervisor.STATE_SKIPPED() skipped = skipped + 1 end if i = i + 1 end while let name_col = compute_service_col_width(table) mut out = "" if want_banner != 0 and ui.ui_mode() != ui.MODE_PLAIN() let rl = ui.ui_runlevel() let rl_str = if str.length(rl) > 0 then rl else "running" end if out = str.concat(out, " ") out = str.concat(out, ui.sigil()) out = str.concat(out, str.concat(" zyginit ", str.concat(version.VERSION(), " · "))) out = str.concat(out, ui.paint("frame", str.concat(str.concat(rl_str, " · "), str.concat(int_to_str(count), str.concat(" services · ", str.concat(int_to_str(online), str.concat(" online, ", str.concat(str.concat(int_to_str(failed), " failed, "), str.concat(int_to_str(skipped), " skipped"))))))))) out = str.concat(out, "\n ") out = str.concat(out, ui.paint("accent", ui.make_divider())) out = str.concat(out, "\n") end if // Column header out = str.concat(out, " ") let header_text = str.concat(ui.pad_right("SERVICE", name_col), str.concat(ui.pad_right("STATE", 11), str.concat(ui.pad_right("PID", 7), str.concat(ui.pad_right("CTID", 7), str.concat(ui.pad_right("UPTIME", 10), "NOTES"))))) out = str.concat(out, ui.paint("frame", header_text)) out = str.concat(out, "\n") // Rows mut j = 0 while j < count out = str.concat(out, fmt_status_row(table, j, name_col)) out = str.concat(out, "\n") j = j + 1 end while ui.ui_set_mode(prev_mode) return out end ui_render_status fn ui_render_status_one(table: supervisor.ServiceTable, idx: int, plain: int): string let prev_mode = ui.ui_mode() if plain != 0 ui.ui_set_mode(ui.MODE_PLAIN()) end if let name_col = compute_service_col_width(table) let row = str.concat(fmt_status_row(table, idx, name_col), "\n") ui.ui_set_mode(prev_mode) return row end ui_render_status_one fn ui_render_list(table: supervisor.ServiceTable, plain: int): string let prev_mode = ui.ui_mode() if plain != 0 ui.ui_set_mode(ui.MODE_PLAIN()) end if let count = supervisor.service_count(table) if count == 0 ui.ui_set_mode(prev_mode) return "no services loaded\n" end if mut out = "" mut i = 0 while i < count let rt = supervisor.get_runtime(table, i) let def = supervisor.rt_def(rt) let name = config.svc_name(def) let stype = config.service_type_name(config.svc_type(def)) let chip = ui.paint("accent", stype) out = str.concat(out, name) out = str.concat(out, " (") out = str.concat(out, chip) out = str.concat(out, ", enabled)\n") i = i + 1 end while ui.ui_set_mode(prev_mode) return out end ui_render_list end module /****************************************************************************** __ ____ __ / / ___ ____ _/ __/_____________ _/ /__ / / / _ \/ __ `/ /_/ ___/ ___/ __ `/ / _ \ / /___/ __/ /_/ / __(__ ) /__/ /_/ / / __/ /_____/\___/\__,_/_/ /____/\___/\__,_/_/\___/ (C)opyright 2025-2026, Leafscale, LLC - https://www.leafscale.com Project: zyginit Filename: version.reef Authors: Chris Tusa License: Description: Generated version constant. Do not edit by hand — regenerated by scripts/bump-version.sh. ******************************************************************************/ module version export fn VERSION(): string end export fn VERSION(): string return "0.2.7" end VERSION end module