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
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
1001
1002
1003
1004
1005
1006
1007
1008
1009
1010
1011
1012
1013
1014
1015
1016
1017
1018
1019
1020
1021
1022
1023
1024
1025
1026
1027
1028
1029
1030
1031
1032
1033
1034
1035
1036
1037
1038
1039
1040
1041
1042
1043
1044
1045
1046
1047
1048
1049
1050
1051
1052
1053
1054
1055
1056
1057
1058
1059
1060
1061
1062
1063
1064
1065
1066
1067
1068
1069
1070
1071
1072
1073
1074
1075
1076
1077
1078
1079
1080
1081
1082
1083
1084
1085
1086
1087
1088
1089
1090
1091
1092
1093
1094
1095
1096
1097
1098
1099
1100
1101
1102
1103
1104
1105
1106
1107
1108
1109
1110
1111
1112
1113
1114
1115
1116
1117
1118
1119
1120
1121
1122
1123
1124
1125
1126
1127
1128
1129
1130
1131
1132
1133
1134
1135
1136
1137
1138
1139
1140
1141
1142
1143
1144
1145
1146
1147
1148
1149
1150
1151
1152
1153
1154
1155
1156
1157
1158
1159
1160
1161
1162
1163
1164
1165
1166
1167
1168
1169
1170
1171
1172
1173
1174
1175
1176
1177
1178
1179
1180
1181
1182
1183
1184
1185
1186
1187
1188
1189
1190
1191
1192
1193
1194
1195
1196
1197
1198
1199
1200
1201
1202
1203
1204
1205
1206
1207
1208
1209
1210
1211
1212
1213
1214
1215
1216
1217
1218
1219
1220
1221
1222
1223
1224
1225
1226
1227
1228
1229
1230
1231
1232
1233
1234
1235
1236
1237
1238
1239
1240
1241
1242
1243
1244
1245
1246
1247
1248
1249
1250
1251
1252
1253
1254
1255
1256
1257
1258
1259
1260
1261
1262
1263
1264
1265
1266
1267
1268
1269
1270
1271
1272
1273
1274
1275
1276
1277
1278
1279
1280
1281
1282
1283
1284
1285
1286
1287
1288
1289
1290
1291
1292
1293
1294
1295
1296
1297
1298
1299
1300
1301
1302
1303
1304
1305
1306
1307
1308
1309
1310
1311
1312
1313
1314
1315
1316
1317
1318
1319
1320
1321
1322
1323
1324
1325
1326
1327
1328
1329
1330
1331
1332
1333
1334
1335
1336
1337
1338
1339
1340
1341
1342
1343
1344
1345
1346
1347
1348
1349
1350
1351
1352
1353
1354
1355
1356
1357
1358
1359
1360
1361
1362
1363
1364
1365
1366
1367
1368
1369
1370
1371
1372
1373
1374
1375
1376
1377
1378
1379
1380
1381
1382
1383
1384
1385
1386
1387
1388
1389
1390
1391
1392
1393
1394
1395
1396
1397
1398
1399
1400
1401
1402
1403
1404
1405
1406
1407
1408
1409
1410
1411
1412
1413
1414
1415
1416
1417
1418
1419
1420
1421
1422
1423
1424
1425
1426
1427
1428
1429
1430
1431
1432
1433
1434
1435
1436
1437
|
/******************************************************************************
__ ____ __
/ / ___ ____ _/ __/_____________ _/ /__
/ / / _ \/ __ `/ /_/ ___/ ___/ __ `/ / _ \
/ /___/ __/ /_/ / __(__ ) /__/ /_/ / / __/
/_____/\___/\__,_/_/ /____/\___/\__,_/_/\___/
(C)opyright 2025-2026, Leafscale, LLC - https://www.leafscale.com
Project: zyginit
Filename: main.reef
Authors: Chris Tusa <chris.tusa@leafscale.com>
License: <see LICENSE file included with this source code>
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 <defunct> 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 <scenario>: run a canned UI scenario for snapshot testing.
if args.has_flag("ui-demo")
let scenario = args.get_flag_value("ui-demo")
let demo_mode = ui.detect_rich_mode()
ui.ui_init(demo_mode)
// Detect window size so demo uses real terminal dimensions.
// ZYGINIT_FORCE_80x25=1 in ui_tests.sh overrides this for
// snapshot stability.
ui.ui_detect_winsize()
process.exit_now(ui.ui_demo(scenario))
end if
// --version / -V short-circuits before any PID-1 setup. Manual invocations
// (zyginit --version on a shell) have stdio fds; the kernel never passes
// --version when exec'ing init as PID 1 (it passes -s, -m, etc. instead).
if args.has_flag("version") or args.has_flag("V")
println("zyginit " + version.VERSION())
return
end if
// PID-1 fd setup MUST run before any println — when the kernel exec()s
// init, stdin/stdout/stderr are not preopened. See setup_pid1_console
// for the full explanation.
if is_pid_1()
let _ = setup_pid1_console()
end if
// Children inherit TERM from us. Set it to match Hammerhead's
// kernel tem (framebuffer console). Services that need a different
// TERM can override via their [exec].environment in TOML.
if is_pid_1()
let _ = env.set_env("TERM", "sun-color")
end if
// Initialize UI renderer. Must happen before any ui_event_* calls.
// detect_rich_mode_for_main returns MODE_PLAIN when not PID 1 or when
// stdout is not a terminal (integration tests set ZYGINIT_NO_UI=1).
let rich_mode = ui.detect_rich_mode_for_main()
ui.ui_init(rich_mode)
// Detect terminal size. Must run after setup_pid1_console() (so fd 1 is
// /dev/console) and after ui_init (so g_mode is set for future redraws).
// On Linux dev builds or when stdout is a pipe, TIOCGWINSZ returns -1
// and ui_detect_winsize falls back to 80x25 defaults.
ui.ui_detect_winsize()
if ui.ui_mode() == ui.MODE_PLAIN()
println("zyginit v" + version.VERSION() + " starting")
if is_pid_1()
println("zyginit: running as PID 1 (init mode)")
else
println("zyginit: running as PID " + int_to_str(process.getpid()) + " (non-init mode)")
end if
end if
// Determine boot runlevel from kernel-passed args. -s in boot-args
// selects single-user mode; otherwise multi-user. Stored in g_runlevel.
g_runlevel = parse_boot_runlevel()
if ui.ui_mode() == ui.MODE_PLAIN()
if g_runlevel == RUNLEVEL_SINGLE()
println("zyginit: booting into single-user mode (-s in boot args)")
else
println("zyginit: booting into multi-user mode")
end if
end if
// Write BOOT_TIME and RUN_LVL utmpx records so who(1) -b/-r and
// uptime(1) report this boot. Best-effort; non-fatal on failure
// (e.g., /var/adm/utmpx doesn't yet exist on a freshly-installed
// BE — filesystem service will create the dir, this gets caught
// by our utmpd service later or admin can touch the file).
if is_pid_1()
let _ = zyginit_write_boot_utmpx()
// 'S' = 83, '3' = 51 (ASCII). Pass as int to the FFI helper.
if g_runlevel == RUNLEVEL_SINGLE()
let _ = zyginit_write_runlvl_utmpx(83)
else
let _ = zyginit_write_runlvl_utmpx(51)
end if
end if
// Cache PID 1's process start time as the boot-id sentinel for
// live-replace. /proc/1/start survives execve (same proc_t) but
// changes on real reboot (new PID 1). utmpx BOOT_TIME was used
// previously but pututxline updates rather than appends, so the
// new zyginit overwrites the original timestamp at startup,
// defeating the staleness check.
if is_pid_1()
g_boot_time = replace.read_boot_time()
if g_boot_time < 0
// Fallback: use time_now(). This degrades the staleness
// check (a real reboot might collide if seconds-resolution
// happens to repeat), but it's better than refusing all
// replaces.
g_boot_time = time.time_now()
println("zyginit: warning: /proc/1/start read failed; using time_now() = " + int_to_str(g_boot_time))
end if
else
// Non-PID-1 (supervisor mode for testing): stamp a fresh value.
g_boot_time = time.time_now()
end if
// ---- Phase 1: Load service definitions ----
let services = new [config.ServiceDef](MAX_SERVICES())
let svc_count = config.load_enabled_services(CONFIG_DIR(), services, MAX_SERVICES())
if 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())
// ---- 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
|