1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
|
/******************************************************************************
__ ____ __
/ / ___ ____ _/ __/_____________ _/ /__
/ / / _ \/ __ `/ /_/ ___/ ___/ __ `/ / _ \
/ /___/ __/ /_/ / __(__ ) /__/ /_/ / / __/
/_____/\___/\__,_/_/ /____/\___/\__,_/_/\___/
(C)opyright 2025-2026, Leafscale, LLC - https://www.leafscale.com
Project: zyginit
Filename: replace.reef
Authors: Chris Tusa <chris.tusa@leafscale.com>
License: <see LICENSE file included with this source code>
Description: Live-replace state-file handoff (operator-driven PID-1 upgrade)
******************************************************************************/
// Live-replace operator flow: zygctl replace -> old zyginit serializes
// runtime state to /var/run/zyginit/state.toml, execs /sbin/init in
// place; new zyginit reads state.toml, validates boot_time matches
// current utmpx BOOT_TIME (else stale across a real reboot), and
// rebuilds the contract_map / runtime fields in supervisor.ServiceTable.
//
// PID-1 in-place execve preserves proc_t.p_ct_process at the kernel
// level, so contract ownership survives automatically — no
// ct_ctl_adopt call is needed. This module owns userspace bookkeeping.
module replace
import config
import supervisor
import io.file
import sys.fd as fd
import sys.env as sysenv
import core.str
import encoding.toml
import time.time as time
export
type RecoveredService
type RecoveredState
// Accessors — RecoveredState
fn rs_count(s: RecoveredState): int
fn rs_service(s: RecoveredState, idx: int): RecoveredService
fn rs_boot_time(s: RecoveredState): int
fn rs_is_empty(s: RecoveredState): bool
// Accessors — RecoveredService
fn svc_name(rs: RecoveredService): string
fn svc_contract_id(rs: RecoveredService): int
fn svc_restart_count(rs: RecoveredService): int
fn svc_last_start_time(rs: RecoveredService): int
fn svc_last_exit_code(rs: RecoveredService): int
// Outgoing flow — write state, return true on success
fn serialize_state(table: supervisor.ServiceTable, boot_time: int): bool
// Incoming flow — read state file. Returns an empty RecoveredState
// if the file is missing, corrupt, or has a mismatched boot_time.
fn recover_state(current_boot_time: int): RecoveredState
// Pre-condition check. Returns "" if all services are stable, else
// a comma-separated "name:state" list suitable for logging.
fn check_preconditions(table: supervisor.ServiceTable): string
// FFI for boot-time sentinel (helpers.c)
fn read_boot_time(): int
// Constants
fn STATE_FILE_PATH(): string
fn STATE_TMP_PATH(): string
fn STATE_DIR(): string
end export
// FFI — implemented in helpers.c
extern "C" fn zyginit_read_boot_time(): int
extern "C" fn zyginit_read_pid1_start(): int
extern "C" fn zyginit_fsync_path(path: string): int
// FFI — POSIX wrappers from helpers.c
// Direct extern "C" for rename/unlink/fsync would conflict with system
// headers' const char* declarations. Use zyginit_* wrappers (same pattern
// as zyginit_symlink).
extern "C" fn zyginit_rename(oldpath: string, newpath: string): int
extern "C" fn zyginit_unlink(path: string): int
extern "C" fn zyginit_fsync(fd: int): int
// Returns PID 1's process start time, used as the boot-id sentinel.
// Survives execve (proc_t preserved) but changes on real reboot.
// Returns -1 if /proc/1/{stat,psinfo} can't be read.
//
// NOTE: utmpx BOOT_TIME was used previously but pututxline updates rather
// than appends the existing record, so the new zyginit overwrites the
// original timestamp at startup, defeating the staleness check.
fn read_boot_time(): int
return zyginit_read_pid1_start()
end read_boot_time
// File-system layout. Keep grouped here so a future tmpfs migration
// only edits one place.
fn STATE_DIR(): string
return sysenv.get_env_or("ZYGINIT_RUN_DIR", "/var/run/zyginit")
end STATE_DIR
fn STATE_FILE_PATH(): string
return str.concat(STATE_DIR(), "/state.toml")
end STATE_FILE_PATH
fn STATE_TMP_PATH(): string
return str.concat(STATE_DIR(), "/state.toml.tmp")
end STATE_TMP_PATH
// ============================================================================
// Types
// ============================================================================
type RecoveredService = struct
name: string
contract_id: int
restart_count: int
last_start_time: int
last_exit_code: int
end RecoveredService
type RecoveredState = struct
boot_time: int
services: [RecoveredService]
count: int
end RecoveredState
fn new_recovered_state(): RecoveredState
return RecoveredState{
boot_time: 0 - 1,
services: new [RecoveredService](0),
count: 0
}
end new_recovered_state
// ============================================================================
// Accessors
// ============================================================================
fn rs_count(s: RecoveredState): int
return s.count
end rs_count
fn rs_service(s: RecoveredState, idx: int): RecoveredService
return s.services[idx]
end rs_service
fn rs_boot_time(s: RecoveredState): int
return s.boot_time
end rs_boot_time
fn rs_is_empty(s: RecoveredState): bool
return s.count == 0
end rs_is_empty
fn svc_name(rs: RecoveredService): string
return rs.name
end svc_name
fn svc_contract_id(rs: RecoveredService): int
return rs.contract_id
end svc_contract_id
fn svc_restart_count(rs: RecoveredService): int
return rs.restart_count
end svc_restart_count
fn svc_last_start_time(rs: RecoveredService): int
return rs.last_start_time
end svc_last_start_time
fn svc_last_exit_code(rs: RecoveredService): int
return rs.last_exit_code
end svc_last_exit_code
// ============================================================================
// Outgoing — serialize running service state to state.toml
// ============================================================================
fn serialize_state(table: supervisor.ServiceTable, boot_time: int): bool
let tmp_path = STATE_TMP_PATH()
let final_path = STATE_FILE_PATH()
let dir_path = STATE_DIR()
// O_WRONLY|O_CREAT|O_TRUNC, mode 0600
let flags = fd.O_WRONLY() + fd.O_CREAT() + fd.O_TRUNC()
let out = fd.fd_open(tmp_path, flags, 384)
if out < 0
println("replace: serialize: cannot open " + tmp_path)
return false
end if
// Write boot_time header
let header = str.concat("boot_time = ", int_to_str(boot_time))
let _ = fd.fd_write(out, str.concat(header, "\n"))
// Iterate services — only RUNNING ones go in. Skip the rest.
let count = supervisor.service_count(table)
mut i = 0
while i < count
let rt = supervisor.get_runtime(table, i)
if supervisor.rt_state(rt) == supervisor.STATE_RUNNING()
let def = supervisor.rt_def(rt)
let _ = fd.fd_write(out, "\n[[service]]\n")
let _ = fd.fd_write(out, str.concat("name = \"", str.concat(config.svc_name(def), "\"\n")))
let _ = fd.fd_write(out, str.concat("contract_id = ", str.concat(int_to_str(supervisor.rt_contract_id(rt)), "\n")))
let _ = fd.fd_write(out, str.concat("restart_count = ", str.concat(int_to_str(supervisor.rt_restart_count(rt)), "\n")))
let _ = fd.fd_write(out, str.concat("last_start_time = ", str.concat(int_to_str(supervisor.rt_last_start_time(rt)), "\n")))
let _ = fd.fd_write(out, str.concat("last_exit_code = ", str.concat(int_to_str(supervisor.rt_last_exit_code(rt)), "\n")))
end if
i = i + 1
end while
// fsync + close
if zyginit_fsync(out) < 0
println("replace: serialize: fsync failed")
let _ = fd.fd_close(out)
let _ = zyginit_unlink(tmp_path)
return false
end if
let _ = fd.fd_close(out)
// Atomic rename
if zyginit_rename(tmp_path, final_path) != 0
println("replace: serialize: rename to " + final_path + " failed")
let _ = zyginit_unlink(tmp_path)
return false
end if
// fsync the parent dir so the rename is durable
let _ = zyginit_fsync_path(dir_path)
return true
end serialize_state
// Local int_to_str — replicate the supervisor.reef pattern. Keeps the
// module standalone (no cross-module helper exposure).
fn int_to_str(n: int): string
if n == 0
return "0"
end if
mut value = n
mut neg = false
if n < 0
value = 0 - n
neg = true
end if
mut result = ""
while value > 0
let digit = value % 10
result = str.concat(str.substring("0123456789", digit, 1), result)
value = value / 10
end while
if neg
result = str.concat("-", result)
end if
return result
end int_to_str
// ============================================================================
// Incoming — recover serialized state on restart
// ============================================================================
// Local int parser with default. Handles negative numbers.
// Empty string or non-numeric input yields default_val.
fn str_to_int_or(s: string, default_val: int): int
let n = str.length(s)
if n == 0
return default_val
end if
mut start = 0
mut neg = false
if str.char_at(s, 0) == '-'
neg = true
start = 1
end if
if start >= n
return default_val
end if
mut result = 0
mut i = start
while i < n
let c = str.char_at(s, i)
if c < '0' or c > '9'
return default_val
end if
result = result * 10 + (c - '0')
i = i + 1
end while
if neg
return 0 - result
end if
return result
end str_to_int_or
fn recover_state(current_boot_time: int): RecoveredState
let path = STATE_FILE_PATH()
if not file.fileExists(path)
return new_recovered_state()
end if
let content = file.readFile(path)
if str.length(content) == 0
let _ = zyginit_unlink(path)
return new_recovered_state()
end if
let keys = toml.toml_alloc_keys()
let vals = toml.toml_alloc_values()
let count = toml.toml_parse(content, keys, vals)
if count == 0
println("replace: recover: parse error or empty in " + path + " — ignoring")
let _ = zyginit_unlink(path)
return new_recovered_state()
end if
if not toml.toml_has_key(keys, vals, count, "boot_time")
println("replace: recover: missing boot_time in " + path + " — ignoring")
let _ = zyginit_unlink(path)
return new_recovered_state()
end if
let saved_boot = toml.toml_get_int(keys, vals, count, "boot_time")
if saved_boot != current_boot_time or current_boot_time < 0
println("replace: recover: stale state file (boot_time " + int_to_str(saved_boot) +
" != current " + int_to_str(current_boot_time) + "); ignoring")
let _ = zyginit_unlink(path)
return new_recovered_state()
end if
let n = toml.toml_array_count(keys, count, "service")
let arr = new [RecoveredService](n)
mut i = 0
while i < n
let name = toml.toml_array_get(keys, vals, count, "service", i, "name")
let ctid_str = toml.toml_array_get(keys, vals, count, "service", i, "contract_id")
let rc_str = toml.toml_array_get(keys, vals, count, "service", i, "restart_count")
let lst_str = toml.toml_array_get(keys, vals, count, "service", i, "last_start_time")
let lec_str = toml.toml_array_get(keys, vals, count, "service", i, "last_exit_code")
arr[i] = RecoveredService{
name: name,
contract_id: str_to_int_or(ctid_str, 0 - 1),
restart_count: str_to_int_or(rc_str, 0),
last_start_time: str_to_int_or(lst_str, 0),
last_exit_code: str_to_int_or(lec_str, 0 - 1)
}
i = i + 1
end while
// State file consumed — delete to avoid panic-restart loops.
let _ = zyginit_unlink(path)
return RecoveredState{
boot_time: saved_boot,
services: arr,
count: n
}
end recover_state
// ============================================================================
// Pre-condition check — transient state detection
// ============================================================================
fn check_preconditions(table: supervisor.ServiceTable): string
let count = supervisor.service_count(table)
mut report = ""
mut found = 0
mut i = 0
while i < count
let rt = supervisor.get_runtime(table, i)
let st = supervisor.rt_state(rt)
// STATE_WAITING here means "restart delay pending" — services
// filtered by runlevel are transitioned to STATE_STOPPED at
// boot time (see supervisor.mark_runlevel_filtered), and the
// table is fully started before the event loop entry that
// processes the replace flag, so "not-yet-started" doesn't
// occur here either.
if st == supervisor.STATE_STARTING() or st == supervisor.STATE_STOPPING() or st == supervisor.STATE_WAITING()
let entry = str.concat(config.svc_name(supervisor.rt_def(rt)),
str.concat(":", supervisor.state_name(st)))
if found > 0
report = str.concat(report, ", ")
end if
report = str.concat(report, entry)
found = found + 1
end if
i = i + 1
end while
return report
end check_preconditions
end module
|