Repository navigation
Expand file tree
/
Copy pathfreebsd-launchd-plan.html
More file actions
1245 lines (1060 loc) · 97.7 KB
/
Copy pathfreebsd-launchd-plan.html
File metadata and controls
1245 lines (1060 loc) · 97.7 KB
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
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>FreeBSD launchd — porting plan</title>
<style>
:root {
--bg: #fbfbf8;
--fg: #1a1a1a;
--muted: #555;
--accent: #b03000;
--accent2: #0a4d68;
--ok: #1f7a1f;
--warn: #b06800;
--bad: #b00020;
--code-bg: #f0ece4;
--rule: #d6cfc0;
--card: #fff;
}
html { -webkit-text-size-adjust: 100%; }
body { margin: 0 auto; max-width: 980px; padding: 2.5rem 1.5rem 6rem;
font: 16px/1.55 -apple-system, BlinkMacSystemFont, "SF Pro Text", system-ui, sans-serif;
color: var(--fg); background: var(--bg); }
h1 { font-size: 2rem; line-height: 1.2; margin: 0 0 .25rem; }
h2 { font-size: 1.4rem; margin: 2.5rem 0 .75rem; padding-bottom: .25rem; border-bottom: 2px solid var(--rule); }
h3 { font-size: 1.15rem; margin: 1.75rem 0 .5rem; color: var(--accent2); }
h4 { margin: 1.25rem 0 .35rem; }
.subtitle { color: var(--muted); font-size: 1.05rem; margin: 0 0 2rem; }
code, pre, kbd { font-family: "SF Mono", Menlo, Consolas, monospace; }
code { background: var(--code-bg); padding: 1px 5px; border-radius: 3px; font-size: .9em; }
pre { background: var(--code-bg); padding: .85rem 1rem; border-radius: 6px;
overflow-x: auto; font-size: .82rem; line-height: 1.45;
border-left: 3px solid var(--accent2); }
pre code { background: none; padding: 0; }
pre.shell { border-left-color: var(--ok); }
pre.plist { border-left-color: var(--accent); }
pre.warn-pre { border-left-color: var(--warn); background: #fff5e6; }
a { color: var(--accent2); }
a:hover { color: var(--accent); }
.tldr { background: var(--card); border: 1px solid var(--rule); border-left: 4px solid var(--accent2);
padding: 1rem 1.25rem; border-radius: 6px; margin-bottom: 2rem; }
.tldr h3 { margin-top: 0; color: var(--accent2); }
.pill { display: inline-block; font-size: .72rem; padding: 1px 8px; border-radius: 999px;
background: #eee; color: #333; margin-left: .35rem; vertical-align: middle;
font-weight: 600; letter-spacing: .02em; }
.pill.ok { background: #d8efd8; color: var(--ok); }
.pill.warn { background: #f6e4cb; color: var(--warn); }
.pill.bad { background: #f5d0d6; color: var(--bad); }
.pill.info { background: #d6e6f3; color: var(--accent2); }
.grid { display: grid; gap: 1rem; grid-template-columns: 1fr 1fr; margin: 1rem 0; }
@media (max-width: 700px) { .grid { grid-template-columns: 1fr; } }
.card { background: var(--card); border: 1px solid var(--rule); border-radius: 6px;
padding: 1rem 1.1rem; }
.card h4 { margin-top: 0; }
.phase { background: var(--card); border: 1px solid var(--rule); border-radius: 6px;
padding: 1.2rem 1.4rem; margin: 1rem 0; }
.phase h3 { margin-top: 0; }
table { border-collapse: collapse; width: 100%; margin: 1rem 0; font-size: .92rem; }
th, td { text-align: left; padding: .5rem .65rem; border-bottom: 1px solid var(--rule); vertical-align: top; }
th { background: #eee5d6; }
tr:nth-child(even) td { background: #faf6ed; }
blockquote { margin: 1rem 0; padding: .5rem 1rem; border-left: 3px solid var(--accent);
background: #fff8f3; color: #333; }
.footnote { font-size: .85rem; color: var(--muted); }
ol li, ul li { margin: .25rem 0; }
.nav { position: sticky; top: 0; background: var(--bg); margin: -2.5rem -1.5rem 2rem;
padding: .75rem 1.5rem; border-bottom: 1px solid var(--rule);
font-size: .88rem; z-index: 10; }
.nav a { margin-right: .9rem; text-decoration: none; }
.filename { color: var(--accent); font-family: "SF Mono", Menlo, Consolas, monospace; font-size: .9em; }
.verdict { font-weight: 600; }
.verdict.go { color: var(--ok); }
.verdict.maybe { color: var(--warn); }
.verdict.no { color: var(--bad); }
hr { border: 0; border-top: 1px dashed var(--rule); margin: 2rem 0; }
.resolved { background: #ecf7ec; border-left: 4px solid var(--ok); padding: .8rem 1rem; margin: 1rem 0; border-radius: 0 6px 6px 0; }
.resolved strong { color: var(--ok); }
.risk { background: #fff5e6; border-left: 4px solid var(--warn); padding: .8rem 1rem; margin: 1rem 0; border-radius: 0 6px 6px 0; }
.risk strong { color: var(--warn); }
.ascii-diagram { font-family: "SF Mono", Menlo, Consolas, monospace; font-size: .82rem; line-height: 1.3; white-space: pre; background: var(--code-bg); padding: 1rem; border-radius: 6px; overflow-x: auto; }
.step-num { display: inline-block; width: 1.6em; height: 1.6em; line-height: 1.6em; text-align: center; background: var(--accent); color: white; border-radius: 50%; font-weight: 700; font-size: .85em; margin-right: .35em; }
.open-q { background: #fff8d6; border: 1px solid #e5d76b; padding: .8rem 1rem; margin: 1rem 0; border-radius: 6px; font-size: .92rem; }
.open-q strong { color: #7a5e00; }
</style>
</head>
<body>
<nav class="nav">
<a href="#tldr">Status</a>
<a href="#goal">Goal</a>
<a href="#repo">Repo</a>
<a href="#arch">Architecture</a>
<a href="#paths">Install paths</a>
<a href="#decisions">Decisions</a>
<a href="#files">File-by-file</a>
<a href="#bsd-wins">FreeBSD wins</a>
<a href="#deps">Deps</a>
<a href="#livecd">LiveCD pipeline</a>
<a href="#ci">CI & release</a>
<a href="#boot">Boot flow</a>
<a href="#rcd">rc.d port</a>
<a href="#license">License</a>
<a href="#phases">Phases</a>
<a href="#open">Open questions</a>
</nav>
<h1>FreeBSD launchd — porting plan</h1>
<p class="subtitle">A FreeBSD-only port of Apple's <code>launchd</code> that drops Mach IPC, replaces <code>rc.d</code> as PID 1, and lives in standard FreeBSD paths. Single repo, single <code>build.sh</code>: builds the GNUstep system-domain libraries + launchd <em>inside a livecd staging chroot</em>, boot-tests the resulting ISO in CI, publishes a continuous release. Pipeline lifted from <a href="https://github.com/pkgdemon/freebsd-livecd-unionfs">freebsd-livecd-unionfs</a>.</p>
<section id="tldr" class="tldr">
<h3>Status: planning <span class="pill info">v0</span></h3>
<ul>
<li><strong>Repo:</strong> <a href="https://github.com/pkgdemon/freebsd-launchd">github.com/pkgdemon/freebsd-launchd</a> — empty; this plan is the scope-of-work for the first commits.</li>
<li><strong>Mission:</strong> replace <code>/etc/rc</code> + <code>rc.d</code> on FreeBSD with Apple <code>launchd</code> as PID 1. Sshd is the proof-of-life target; other base service plists (syslog, cron, devd, NFS, etc.) ship in the repo for users to opt into. Desktop later.</li>
<li><strong>No Mach IPC, no XPC, no <code>libxpc</code>, no <code>darlingserver</code>.</strong> All Mach paths in the imported source are deleted; service-to-service IPC uses launchd's own AF_UNIX socket activation (<code>Sockets</code> plist key, predates XPC).</li>
<li><strong>Event loop:</strong> <code>libdispatch</code> dispatch sources only (kqueue under the hood). No raw <code>kqueue</code>/<code>kevent</code> in our code.</li>
<li><strong>Plist parsing:</strong> GNUstep <code>NSPropertyListSerialization</code> (handles XML and binary). Foundation in PID 1 — same shape as macOS.</li>
<li><strong>No separate Gershwin repo.</strong> The five system-domain libs (libdispatch, libobjc2, tools-make, libs-base, libs-corebase) get cloned and built directly inside the livecd staging chroot by <code>build.sh</code>. No submodule.</li>
<li><strong>One repo, one pipeline.</strong> Lifts <code>build.sh</code>, <code>tests/boot-test.sh</code>, and the GitHub Actions workflow from <code>freebsd-livecd-unionfs</code>. Build → boot-test in qemu → publish a continuous release ISO. Boot test watches the serial console for two markers: <code>launchd: PID 1 ready</code> and <code>login:</code>. Either marker missing fails the release gate.</li>
<li><strong>Install paths:</strong> binaries follow FreeBSD <code>hier(7)</code> — <code>/sbin/launchd</code>, <code>/sbin/launchctl</code>. Only the plist directories use the Apple/NeXT layout, because that's what the daemon already scans.</li>
<li><strong>Licensing:</strong> BSD-2-Clause top-level (matches FreeBSD ecosystem). Apple's launchd files keep their original Apache 2.0 headers per-file. <code>NOTICE</code> enumerates Apple, swift-corelibs-libdispatch, GNUstep, and us.</li>
</ul>
</section>
<h2 id="goal">1. Goal & non-goals</h2>
<h3>1.1 Goal</h3>
<p>Boot a FreeBSD 15.0 live ISO to a usable multi-user prompt with launchd as PID 1. Reach a console <code>login:</code>, then prove the supervisor with sshd. Other base service plists — syslogd, cron, devd, mountd, nfsd, nfsclient, etc. — ship in the repo so users can <code>launchctl load</code> what they want, but they aren't release-gating goals. No <code>/etc/rc</code>, no <code>/etc/rc.d</code>, no <code>service(8)</code>. Every push to <code>main</code> rebuilds the ISO, boot-tests it in qemu, and (on green) publishes it as the <code>continuous</code> GitHub release.</p>
<h3>1.2 Non-goals (this iteration)</h3>
<ul>
<li><strong>Not a port of every <code>rc.d</code> script.</strong> FreeBSD ships ~180; we'll port a server-essential ~12 and document the rest as "won't port" with reasons (§11).</li>
<li><strong>Not Linux, not NetBSD.</strong> FreeBSD-only — we use kqueue-flavored libdispatch features freely without portability shims (§7).</li>
<li><strong>No GUI stack.</strong> No libs-gui, libs-back, libs-opal, libs-quartzcore, no Workspace.app, no LoginWindow.app. Headless server.</li>
<li><strong>No Mach IPC compatibility shim.</strong> Third-party plists referencing <code>MachServices</code> get the key silently ignored (and logged at debug level).</li>
<li><strong>No <code>libxpc</code> emulation.</strong> XPC services are Mach-rooted; without Mach there's nothing to emulate.</li>
</ul>
<h2 id="repo">2. Repository — single repo, single pipeline</h2>
<p>One repo: <a href="https://github.com/pkgdemon/freebsd-launchd"><code>pkgdemon/freebsd-launchd</code></a>. It contains:</p>
<ol>
<li>The Apple <code>launchd-842.1.4</code> source (one-time fork, Mach paths gutted) under <code>src/</code>.</li>
<li>Our rc.d-replacement plists under <code>plists/</code>.</li>
<li>A <code>build.sh</code> that — modeled on <a href="https://github.com/pkgdemon/freebsd-livecd-unionfs"><code>freebsd-livecd-unionfs</code></a> — extracts FreeBSD pkgbase, mounts a chroot, clones & builds the GNUstep system-domain libraries into <code>/System/Library/</code>, builds launchd into <code>/sbin/</code>, slims the rootfs, mkuzips it, and wraps it in a hybrid BIOS+UEFI cd9660 ISO.</li>
<li>A <code>tests/boot-test.sh</code> that boots the ISO under qemu+OVMF and watches serial for launchd-emitted markers.</li>
<li>A GitHub Actions workflow that runs build → test → release on every push.</li>
</ol>
<p><strong>No git submodules.</strong> Earlier versions of this plan considered a "system-domain Gershwin fork" as a submodule. Cleaner approach: <code>build.sh</code> simply <code>git clone</code>s each upstream library at chroot-build time, builds, installs.</p>
<h3>2.1 Top-level layout</h3>
<pre><code>freebsd-launchd/
├── LICENSE BSD-2-Clause
├── NOTICE Apple, swift-corelibs-libdispatch, GNUstep, us
├── README.md elevator pitch + quickstart
├── PLAN.md link to this published plan
├── build.sh lifted from livecd-unionfs; clones 6 upstreams, builds them in-chroot, calls make-launchd.sh, then ISO-wraps
├── make-launchd.sh STANDALONE — builds + installs launchd from src/ to /sbin/; assumes /System/Library/ libs present. Reusable by gershwin-on-freebsd.
├── pkglist.txt runtime FreeBSD pkgs to install in chroot (start empty)
├── buildpkgs.txt build-only pkgs (cmake, ninja, gmake, autoconf, libtool) — purged before slim
├── repos/ gitignored — populated by build.sh's git-clone stage; rsync'd into chroot
├── boot/
│ └── loader.conf kernel modules + init kenv (init_path=/sbin/launchd)
├── ramdisk/
│ └── init.sh unionfs pivot: mounts /sysroot, kenv init_chroot, kenv init_path
├── overlays/
│ ├── etc/
│ │ ├── rc.conf minimal — the launchd plists own the rest
│ │ ├── motd.template banner
│ │ └── ld-elf.so.conf.d/system.conf one line: /System/Library/Libraries
│ └── (more as needed)
├── plists/ our rc.d-replacement LaunchDaemon plists
│ ├── org.freebsd.devd.plist
│ ├── org.freebsd.syslogd.plist
│ ├── org.freebsd.sshd.plist
│ ├── org.freebsd.cron.plist
│ ├── org.freebsd.rpcbind.plist
│ ├── org.freebsd.mountd.plist
│ ├── org.freebsd.nfsd.plist
│ ├── org.freebsd.nfsclient.plist
│ └── org.freebsd.phase.{filesystems,network,kld}-ready.plist
├── tests/
│ └── boot-test.sh qemu+expect smoke test (extended marker set)
├── .github/workflows/
│ └── build.yml build → test → release (3 jobs, gated)
├── compat/ FreeBSD-specific shims
├── scripts/
│ ├── import-source.sh one-shot Apple launchd import (already done)
│ └── lint.sh forbidden-symbol grep
└── src/ forked Apple launchd-842.1.4 (Mach paths deleted)
├── src/ daemon: launchd.c, runtime.c, ipc.c, core.[cm], log.c
├── liblaunch/ wire-format library: launch.h, liblaunch.c
├── support/ launchctl.c, wait4path.c
├── man/ man pages
└── rc/ legacy rc.common — kept selectively
</code></pre>
<h2 id="arch">3. Architecture</h2>
<div class="ascii-diagram"> +----------------------------------------------+
| launchd (PID 1, persistent) |
| |
| +--------------------------------------+ |
| | libdispatch main queue | |
| | (dispatch_main(), runs forever) | |
| +-------------------+------------------+ |
| | |
| +-----------------+--------------+ |
| | dispatch sources | |
| +---------------------------------+ |
| | SIGNAL: SIGCHLD -> reap | |
| | SIGNAL: SIGTERM -> shutdown | |
| | SIGNAL: SIGHUP -> rescan_dirs | |
| | PROC: per-job -> reap (BSD) | |
| | READ: accept_fd -> accept_ctl | |
| | READ: listen_fd[N] -> spawn | <--- Sockets activation
| | TIMER: throttle -> retry | |
| | TIMER: start_iv -> spawn | |
| | VNODE: watchpath[N] -> notify | <--- WatchPaths
| | READ: PF_ROUTE -> net up | <--- network-ready phase
| +---------------------------------+ |
| | |
| +-----------------+--------------+ |
| | job table (label -> Job) | |
| +--------------------------------+ |
+----------------------+---------------------+-+
|
fork+exec / posix_spawn | SCM_RIGHTS fd passing
for each Job that needs it on socket-activated jobs
|
+-------------+ +-----+--------+ +------------------+
| child job A | | child job B | | launchctl(8) |
| pid=N | | inherited fd | | client over |
| | | from listener| | /var/run/launchd |
+------+------+ +-------+------+ +--------+---------+</div>
<h3>3.1 Why this works on FreeBSD specifically</h3>
<ul>
<li><strong>Single-threaded user-visible state.</strong> All job-table mutation funnels through <code>dispatch_get_main_queue()</code>. Worker fan-out is libdispatch's problem; we never call <code>pthread_create</code>.</li>
<li><strong>PID 1 stays inside <code>dispatch_main()</code>,</strong> which is a kqueue wait under the hood. No custom poll loop.</li>
<li><strong>Per-job <code>DISPATCH_SOURCE_TYPE_PROC</code></strong> with <code>DISPATCH_PROC_EXIT</code> gives clean per-pid death notification. SIGCHLD remains as a backstop for orphans PID 1 inherits.</li>
<li><strong>Shutdown is just <code>dispatch_async</code></strong> to a stop-everyone-and-exit block.</li>
</ul>
<div class="risk">
<strong>One real subtlety:</strong> the SIGCHLD disposition inside launchd must be a <em>no-op handler</em>, not <code>SIG_IGN</code>. Under <code>SIG_IGN</code> on FreeBSD the kernel reaps zombies synchronously and never delivers SIGCHLD, which means <code>EVFILT_SIGNAL</code> never fires and the dispatch source is silent.
</div>
<h2 id="paths">4. Install paths</h2>
<p><strong>This repo follows FreeBSD <code>hier(7)</code> for binaries</strong> and only uses the <code>/System</code> tree for things that genuinely belong there (libraries the launchd daemon links against, plist directories the daemon scans).</p>
<table>
<thead>
<tr><th>Artifact</th><th>Path</th><th>Why</th></tr>
</thead>
<tbody>
<tr><td><code>launchd</code> binary</td><td><code>/sbin/launchd</code></td><td>PID 1 must live on the root partition; matches <code>init</code>'s location.</td></tr>
<tr><td><code>launchctl</code> binary</td><td><code>/sbin/launchctl</code></td><td>Needed during single-user before <code>/usr/local</code> mounts; matches <code>service(8)</code>.</td></tr>
<tr><td><code>wait4path</code> helper</td><td><code>/usr/bin/wait4path</code></td><td>Non-essential; user-callable.</td></tr>
<tr><td>Man pages</td><td><code>/usr/share/man/man{1,5,8}/</code></td><td>Base-system man path.</td></tr>
<tr><td>System LaunchDaemons</td><td><code>/System/Library/LaunchDaemons/</code></td><td>NeXT/Apple convention; default scan dir baked into <code>launchd.c</code>.</td></tr>
<tr><td>Third-party LaunchDaemons</td><td><code>/Local/Library/LaunchDaemons/</code></td><td>Second scan dir; for ports/pkg-installed services. Uses the gershwin <code>/Local/Library/</code> tree (where tools-make installs third-party gnustep-make resources) rather than Apple's <code>/Library/</code>.</td></tr>
<tr><td>Per-user LaunchAgents</td><td><code>~/Library/LaunchAgents/</code> + <code>/Local/Library/LaunchAgents/</code></td><td>User half matches macOS exactly (gershwin home dirs are <code>/Users/<u>/Library/...</code>); local-system half uses gershwin <code>/Local/Library/</code>. Not used on a server but the support stays.</td></tr>
<tr><td>Control socket (PID 1)</td><td><code>/var/run/launchd/sock</code></td><td>Matches <code>IPC_DEFAULT_PID1_SOCK</code> in <code>ipc.c</code>.</td></tr>
<tr><td>libdispatch (.so + headers)</td><td><code>/System/Library/Libraries/libdispatch.so</code><br><code>/System/Library/Headers/dispatch/</code></td><td>Built by <code>build.sh</code> in-chroot; out of <code>/usr/local</code> so the FreeBSD pkg copy doesn't shadow it.</td></tr>
<tr><td>libobjc2, libgnustep-base, libgnustep-corebase</td><td><code>/System/Library/Libraries/</code></td><td>Same.</td></tr>
<tr><td>gnustep-make</td><td><code>/System/Library/Makefiles/</code></td><td>Build-time only; needed to compile downstream daemons against Foundation.</td></tr>
</tbody>
</table>
<p><strong>Why split it this way:</strong> a sysadmin shelling into a FreeBSD box should find <code>launchctl</code> at the path <code>service(8)</code> would have been at — <code>/sbin</code>. Burying admin tools under <code>/System/Library/Tools/</code> would only make sense for an all-in OS-distribution pivot; in a FreeBSD-shaped install where everything else lives in <code>hier(7)</code> locations, it's hostile.</p>
<h2 id="decisions">5. Locked architectural decisions</h2>
<table>
<thead>
<tr><th>Decision</th><th>Choice</th></tr>
</thead>
<tbody>
<tr><td>Target kernel</td><td>FreeBSD 14.x and 15.x. No Linux, no NetBSD, no portability gates.</td></tr>
<tr><td>Binary format</td><td>ELF (FreeBSD native). No Mach-O, no <code>dyld</code>.</td></tr>
<tr><td>Toolchain</td><td>clang + lld + llvm-ar (FreeBSD base). Never gcc.</td></tr>
<tr><td>Mach IPC</td><td>None. All <code><mach/...></code>, MIG <code>.defs</code> deleted.</td></tr>
<tr><td>XPC</td><td>None. <code>libxpc</code> not in tree.</td></tr>
<tr><td>Service IPC</td><td>AF_UNIX socket activation via launchd's <code>Sockets</code> plist key. Out-of-band fd passing via <code>SCM_RIGHTS</code>.</td></tr>
<tr><td>Init / PID 1</td><td>This launchd. Replaces <code>/sbin/init</code>'s rc-chain via <code>init_path</code> kenv.</td></tr>
<tr><td>Event loop</td><td>libdispatch dispatch sources only.</td></tr>
<tr><td>libdispatch source</td><td><code>apple/swift-corelibs-libdispatch</code> built from source by <code>build.sh</code> in the staging chroot. <strong>Not</strong> <code>devel/libdispatch</code> from FreeBSD pkg, because that lands in <code>/usr/local</code> and would shadow ours.</td></tr>
<tr><td>Plist parsing</td><td>GNUstep <code>NSPropertyListSerialization</code> from <code>libgnustep-base</code>. Handles XML and binary plists.</td></tr>
<tr><td>Foundation in PID 1</td><td>Yes. macOS does it; cost ~10 MB linked, payable once.</td></tr>
<tr><td>Where the GNUstep libs come from</td><td><code>build.sh</code> clones each upstream repo into the chroot, builds, installs to <code>/System/Library/</code>. <strong>No git submodule, no Gershwin fork.</strong></td></tr>
<tr><td>License (top-level)</td><td>BSD-2-Clause (matches FreeBSD ecosystem). Apple's launchd files retain Apache 2.0 headers per-file.</td></tr>
<tr><td>Build platform</td><td>FreeBSD only, inside <code>vmactions/freebsd-vm@v1</code>. No macOS, no Linux. Mac is edit-only.</td></tr>
<tr><td>Release artifact</td><td>A bootable hybrid BIOS+UEFI cd9660 ISO with launchd as PID 1, published as <code>continuous</code> on every push to <code>main</code>.</td></tr>
<tr><td>Release gate</td><td>Boot test must observe both <code>launchd: PID 1 ready</code> and <code>login:</code> on serial within 8 minutes.</td></tr>
</tbody>
</table>
<h2 id="files">6. File-by-file plan (<code>src/</code>)</h2>
<p>Imported source: Apple <code>launchd-842.1.4</code>. 26,779 lines, 68 files originally. Phase 1 amputation deletes the wholly-Mach files; what remains is the substrate Phase 2 replaces. The Phase 2 rewrites (<code>runtime.c</code>, <code>ipc.c</code>, <code>core.[cm]</code>, <code>log.c</code>, <code>launchctl.c</code>, plus the Mach-pruning of <code>liblaunch.c</code>) land as <strong>code reused from a previous attempt to install launchd in system</strong>; the file-by-file table below describes the target shape, not work typed from scratch.</p>
<h3>6.1 Deleted on import (Mach-only)</h3>
<ul>
<li><code>launchd.xcodeproj/</code>, <code>xcconfigs/</code>, <code>xcscripts/</code> — Xcode build infra</li>
<li><code>src/*.defs</code> — MIG interface definitions (Mach-RPC IDL)</li>
<li><code>src/kill2.{c,h}</code> — Apple <code>kill2()</code> syscall wrapper; replaced by POSIX <code>kill(2)</code></li>
<li><code>src/ktrace.{c,h}</code> — Apple <code>kdebug</code> emitters</li>
<li><code>SystemStarter/</code> — pre-launchd <code>/System/Library/StartupItems</code> mechanism (legacy since 10.4)</li>
<li><code>liblaunch/libbootstrap.c</code>, <code>bootstrap.h</code>, <code>bootstrap_priv.h</code> — Mach bootstrap-server client</li>
<li><code>support/launchproxy.c</code> — Mach-IPC inetd-style wrapper</li>
</ul>
<h3>6.2 Retained — Phase 2 fate</h3>
<table>
<thead>
<tr><th>File</th><th>Apple LOC</th><th>Mach refs</th><th>Action</th><th>Target LOC</th></tr>
</thead>
<tbody>
<tr><td><code>src/launchd.c</code></td><td>~660</td><td>1</td><td>Prune. Drop <code>task_set_bootstrap_port</code>; replace <code>monitor_networking_state</code> (Darwin <code>PF_SYSTEM</code>/<code>KEV_NETWORK_CLASS</code>) with a FreeBSD <code>PF_ROUTE</code> dispatch source watching <code>RTM_NEWADDR</code>/<code>RTM_DELADDR</code>.</td><td>~400</td></tr>
<tr><td><code>src/runtime.c</code></td><td>1,453</td><td>85</td><td>Rewrite from scratch on libdispatch.</td><td>~600</td></tr>
<tr><td><code>src/ipc.c</code></td><td>537</td><td>?</td><td>Rewrite (small). Replace <code>kevent_mod</code> with <code>DISPATCH_SOURCE_TYPE_READ</code>; drop the <code>MachServices</code> checkin branch.</td><td>~500</td></tr>
<tr><td><code>src/core.[cm]</code></td><td>11,973</td><td>207</td><td>Rewrite from a near-empty file. Port the data structures, plist-key parsers, KeepAlive policy state machine, Sockets activation. Drop: <code>MachServices</code>, <code>bootstrap_subset_t</code>, <code>domain_t</code>, XPC, <code>jetsam_*</code>, audit-session, <code>PerUserLaunchd</code>. Keep as Objective-C using <code>NSPropertyListSerialization</code>.</td><td>~2000-2500</td></tr>
<tr><td><code>src/log.{c,h}</code></td><td>—</td><td>3-6</td><td>Prune <code>mach_error_string</code>. Keep syslog buffer, console fallback, level mask.</td><td>~300</td></tr>
<tr><td><code>liblaunch/liblaunch.c</code></td><td>—</td><td>8</td><td>Prune Mach refs (8 spots). Keep <code>launch_data_pack</code>/<code>unpack</code>, <code>launchd_msg_send</code>/<code>recv</code> wire-format code.</td><td>~1200</td></tr>
<tr><td><code>liblaunch/launch.h</code> et al</td><td>—</td><td>few</td><td>Drop <code>LAUNCH_DATA_MACHPORT</code> and the three accessors.</td><td>~250</td></tr>
<tr><td><code>liblaunch/libvproc.c</code></td><td>1,061</td><td>42</td><td><strong>Delete entirely.</strong> No Mach, no ports to pass.</td><td>0</td></tr>
<tr><td><code>support/launchctl.c</code></td><td>4,549</td><td>21</td><td>Substantial rewrite. Drop <code>bsexec</code>, <code>bslist</code>, <code>bstree</code>, <code>bootstrap</code>, <code>asuser</code>. Keep <code>load</code>, <code>unload</code>, <code>start</code>, <code>stop</code>, <code>list</code>, <code>submit</code>, <code>getenv</code>, etc. Plist reading via <code>NSPropertyListSerialization</code>.</td><td>~1500</td></tr>
<tr><td><code>support/wait4path.c</code></td><td>—</td><td>0</td><td>Keep as is.</td><td>unchanged</td></tr>
<tr><td><code>man/</code>, <code>rc/</code></td><td>—</td><td>0</td><td>Keep selectively. <code>rc.netboot</code> gone.</td><td>shrinks</td></tr>
</tbody>
</table>
<p>Total post-Phase-2: roughly <strong>7-8k LOC</strong> vs Apple's ~22k pre-amputation. About 65% deletion.</p>
<h3>6.3 Plist-key support — current state vs Apple's full surface</h3>
<p>Apple's <code>launchd</code> parses ~80+ plist keys. Our vendored source from prior work handles a working subset; the rest are silently ignored at parse time (the plist still loads — the key just has no effect). The table below tracks what's implemented today, what's load-bearing for project-shipped plists, and what's pending.</p>
<table>
<thead><tr><th>Plist key</th><th>Status</th><th>Notes</th></tr></thead>
<tbody>
<tr><td><code>Label</code></td><td><span class="verdict go">implemented</span></td><td>Job identifier; required.</td></tr>
<tr><td><code>ProgramArguments</code></td><td><span class="verdict go">implemented</span></td><td>argv to <code>posix_spawn</code>.</td></tr>
<tr><td><code>RunAtLoad</code></td><td><span class="verdict go">implemented</span></td><td>Spawn at scan time. Verified by every shipped plist.</td></tr>
<tr><td><code>KeepAlive</code></td><td><span class="verdict go">implemented</span></td><td>Respawn on exit. Verified by getty + syslogd surviving across login sessions.</td></tr>
<tr><td><code>Sockets</code></td><td><span class="verdict go">implemented</span></td><td>launchd opens AF_UNIX listener, hands fd via env. Verified by the IPC socket at <code>/var/run/launchd/sock</code>.</td></tr>
<tr><td><code>inetdCompatibility</code> (<code>Wait</code> subkey)</td><td><span class="verdict maybe">partial</span></td><td>Minimal sshd-shape works; verify edge cases when sshd lands in Phase 4.</td></tr>
<tr><td><code>EnvironmentVariables</code></td><td><span class="verdict no">NOT implemented</span></td><td><code>core.m:391</code> calls <code>posix_spawn(..., environ)</code> unconditionally — plist env is ignored. <strong>Workaround:</strong> use absolute paths in <code>ProgramArguments</code>, or wrap with <code>/bin/sh -c "PATH=...; cmd"</code>. Symptom: shell-wrapped plist exits 127 (command not found).</td></tr>
<tr><td><code>StartCalendarInterval</code></td><td><span class="verdict no">NOT implemented</span></td><td>Apple's cron-replacement key (dict with optional Minute/Hour/Day/Weekday/Month). <strong>Workaround:</strong> ship <code>/usr/sbin/cron</code> as a compat daemon (we do — <code>org.freebsd.cron.plist</code>) and put scheduled tasks in crontabs. Long-term answer: add this key to <code>core.m</code> (~50 LOC, NSDate-based timer waking at next match), migrate <code>periodic(8)</code>-style daily/weekly/monthly tasks to native launchd plists, optionally drop cron from the ISO entirely.</td></tr>
<tr><td><code>WorkingDirectory</code></td><td><span class="verdict no">unverified, likely not</span></td><td>Should chdir before exec. Add when a daemon needs it.</td></tr>
<tr><td><code>UserName</code> / <code>GroupName</code></td><td><span class="verdict no">unverified, likely not</span></td><td>Drop privileges before exec. Will be needed when sshd plist lands in Phase 4.</td></tr>
<tr><td><code>StandardOutPath</code> / <code>StandardErrorPath</code></td><td><span class="verdict no">unverified, likely not</span></td><td>Redirect spawned-child stdio. Add when a per-job log file is wanted.</td></tr>
<tr><td><code>Umask</code></td><td><span class="verdict no">unverified, likely not</span></td><td>Per-job umask before exec.</td></tr>
<tr><td><code>ThrottleInterval</code></td><td><span class="verdict no">unverified, likely not</span></td><td>KeepAlive currently respawns immediately on exit; no rate-limit. Add when a flapping daemon makes us want backoff.</td></tr>
<tr><td><code>Disabled</code></td><td><span class="verdict maybe">unverified — likely partial</span></td><td>Per-job "skip me at scan time" boolean. Apple-shipped plists ship with <code>Disabled=true</code> for opt-in services (sshd, nfsd, etc.) so the user has to explicitly enable them. <strong>Today, even if our launchd parses this key, the persistent override mechanism that lets users flip it without editing the shipped plist is not yet implemented</strong> — see §12.7.</td></tr>
<tr><td><code>RequiresPhase</code> (proposed extension)</td><td><span class="verdict no">NOT implemented</span></td><td>Project-specific schema extension per §11.3 — block job until a named phase stamp file exists. Will land alongside the configd / kmodloader work since those need ordering.</td></tr>
</tbody>
</table>
<p><strong>Implementation pattern</strong> for a new key (~30-50 LOC each, in <code>core.m</code>):</p>
<ol>
<li>Read the key from the plist's <code>NSDictionary</code> at job-load time.</li>
<li>Validate type (string vs dict vs array vs integer).</li>
<li>Apply at the appropriate point: env-build for <code>EnvironmentVariables</code>; <code>chdir(2)</code> before exec for <code>WorkingDirectory</code>; <code>setuid(2)</code>/<code>setgid(2)</code> for <code>UserName</code>/<code>GroupName</code>; <code>open(2) + dup2(2)</code> for <code>Standard{Out,Error}Path</code>; <code>dispatch_source_create(DISPATCH_SOURCE_TYPE_TIMER, ...)</code> for <code>StartCalendarInterval</code>; etc.</li>
</ol>
<p><strong>Phase-5 batch:</strong> implement <code>EnvironmentVariables</code>, <code>StartCalendarInterval</code>, <code>WorkingDirectory</code>, <code>UserName</code>/<code>GroupName</code>, <code>Standard{Out,Error}Path</code>, <code>Umask</code> as a focused "match more of Apple's plist schema" pass. Unlocks: cron-to-native-launchd migration, sshd with privsep user, per-daemon log redirection, and the rest of the conventional Apple plist idioms.</p>
<h2 id="bsd-wins">7. FreeBSD-only wins (vs a portable BSD+Linux design)</h2>
<table>
<thead>
<tr><th>Feature</th><th>Portable BSD+Linux approach</th><th>This repo (FreeBSD-only)</th></tr>
</thead>
<tbody>
<tr><td>Per-pid child death</td><td>SIGCHLD source + <code>waitpid(WNOHANG)</code> drain + pid hash</td><td><code>DISPATCH_SOURCE_TYPE_PROC</code> with <code>DISPATCH_PROC_EXIT</code> per job; SIGCHLD as backstop only</td></tr>
<tr><td><code>WatchPaths</code></td><td>"BSD only in v1; Linux gets inotify shim later"</td><td><code>DISPATCH_SOURCE_TYPE_VNODE</code> with full flag set. Just works.</td></tr>
<tr><td>Peer creds on AF_UNIX</td><td><code>#ifdef SO_PEERCRED</code> / <code>LOCAL_PEERCRED</code> branches</td><td><code>LOCAL_PEERCRED</code> + <code>struct xucred</code>. One path.</td></tr>
<tr><td>Network-ready signal</td><td>"TODO: netlink shim or always-up placeholder"</td><td><code>PF_ROUTE</code> socket as <code>DISPATCH_SOURCE_TYPE_READ</code>, parse <code>RTM_NEWADDR</code></td></tr>
<tr><td>VNODE flag for read-only</td><td><code>O_EVTONLY</code> shim (Darwin-only)</td><td><code>O_RDONLY</code> works directly</td></tr>
<tr><td>Build-system gates</td><td>Pervasive <code>#ifdef __FreeBSD__</code> / <code>__linux__</code></td><td>Delete them all</td></tr>
</tbody>
</table>
<h2 id="deps">8. Dependencies — built in-chroot, no submodules</h2>
<p>Five upstream libraries are cloned and built by <code>build.sh</code> inside the staging chroot, in dependency order, before launchd itself is built. We track upstream HEAD; if a breakage surfaces we'll address it then, not preemptively.</p>
<h3>8.1 Host-side clone, chroot-side build — both in <code>build.sh</code></h3>
<p>Rule lifted from <code>gershwin-on-freebsd</code>: <strong>all git operations happen on the host, the chroot stays git-free</strong>. <code>build.sh</code> handles both halves inline:</p>
<ol>
<li><strong>Host stage (early in <code>build.sh</code>):</strong> <code>git clone</code> the six upstreams (5 GNUstep + Tessil/robin-map) into <code>repos/<name>/</code> at the repo root, or <code>git pull --ff-only</code> if already present. Idempotent.</li>
<li><strong>Chroot stage:</strong> extract pkgbase, mount the chroot, <code>pkg install</code> runtime + build deps, <code>rsync -a ./repos/ work/rootfs/tmp/repos/</code> into the chroot, sed-patch libobjc2's <code>CMakeLists.txt</code> per §8.3, then run the build invocations from §8.4 with <code>REPOS_DIR=/tmp/repos</code> set in the environment.</li>
<li><strong>launchd stage:</strong> after the GNUstep libs are installed, <code>build.sh</code> calls <code>./make-launchd.sh</code> (chroot'd). That's the standalone-reusable script — it builds <code>src/</code> against <code>/System/Library/</code> and installs <code>launchd</code>/<code>launchctl</code> to <code>/sbin/</code>.</li>
</ol>
<p><code>repos/</code> is in <code>.gitignore</code>. CI caches it on the runner so unchanged upstreams skip re-clone.</p>
<h3>8.2 Upstream URLs & build order</h3>
<table>
<thead><tr><th>Order</th><th>Upstream</th><th>Build system</th><th>Installs to</th></tr></thead>
<tbody>
<tr><td>1</td><td><a href="https://github.com/apple/swift-corelibs-libdispatch.git">apple/swift-corelibs-libdispatch</a></td><td>cmake</td><td><code>/System/Library/Libraries/libdispatch.so</code> + headers in <code>/System/Library/Headers/{dispatch,os}/</code></td></tr>
<tr><td>2</td><td><a href="https://github.com/gnustep/tools-make.git">gnustep/tools-make</a></td><td>autoconf</td><td><code>/System/Library/Makefiles/</code> + <code>/System/Library/Preferences/GNUstep.conf</code></td></tr>
<tr><td>3</td><td><a href="https://github.com/gnustep/libobjc2.git">gnustep/libobjc2</a></td><td>cmake</td><td><code>/System/Library/Libraries/libobjc.so</code></td></tr>
<tr><td>4</td><td><a href="https://github.com/gnustep/libs-base.git">gnustep/libs-base</a></td><td>gnustep-make</td><td><code>/System/Library/Libraries/libgnustep-base.so</code></td></tr>
<tr><td>5</td><td><a href="https://github.com/gnustep/libs-corebase.git">gnustep/libs-corebase</a></td><td>gnustep-make</td><td><code>/System/Library/Libraries/libgnustep-corebase.so</code></td></tr>
</tbody>
</table>
<p>Order is significant: <code>tools-make</code> writes <code>/System/Library/Preferences/GNUstep.conf</code> (the <code>--with-layout=gershwin</code> map), <code>libobjc2</code> needs that config plus an established <code>BlocksRuntime</code> from libdispatch, and <code>libs-base</code>/<code>libs-corebase</code> drive their installs through <code>gnustep-make</code> with <code>GNUSTEP_INSTALLATION_DOMAIN=SYSTEM</code>.</p>
<h3>8.3 Upstream patches</h3>
<h4>libdispatch — none, for now</h4>
<p>We start with <strong>stock <code>swift-corelibs-libdispatch</code></strong> at HEAD, no patches. The gershwin tree carries a 126-line FreeBSD kevent fix (busy-wait on certain timer fflags) but we want to see how launchd behaves against unpatched upstream first. If timer-driven jobs (StartInterval, ThrottleInterval) misbehave during Phase 3 the patch goes in <code>patches/</code> and gets applied at host-side checkout; until then the directory doesn't exist.</p>
<h4>libobjc2 — robinmap FetchContent → SOURCE_DIR</h4>
<p>libobjc2's <code>CMakeLists.txt</code> calls <code>FetchContent_Declare(robinmap GIT_REPOSITORY ...)</code>, which fires <code>git</code> at configure-time. The chroot is git-free by design, so this fails. The fix has two parts:</p>
<ol>
<li>Add <a href="https://github.com/Tessil/robin-map.git"><code>Tessil/robin-map</code></a> to the host clone loop (§8.6) as a sibling to libobjc2.</li>
<li>After rsyncing <code>repos/</code> into the chroot, before the libobjc2 cmake invocation, sed-patch the chroot copy of <code>libobjc2/CMakeLists.txt</code> to use <code>SOURCE_DIR</code> at the rsynced sibling path:
<pre class="shell"><code>sed -i '' \
-e 's|GIT_REPOSITORY https://github.com/Tessil/robin-map/|SOURCE_DIR /tmp/repos/robin-map)|' \
-e '/GIT_TAG[[:space:]]*v1\.4\.0)/d' \
"$WORK/rootfs/tmp/repos/libobjc2/CMakeLists.txt"</code></pre>
</li>
</ol>
<p>Patching the chroot copy (not the host clone) keeps future <code>git pull --ff-only</code> runs clean against upstream libobjc2.</p>
<h3>8.4 The exact build invocations</h3>
<p>This block is what <code>build.sh</code> runs inside the chroot, after <code>checkout.sh</code> has populated <code>repos/</code> on the host and stage 3 has rsync'd it to <code>$REPOS_DIR=/tmp/repos</code>. Verbatim — these commands are known-good for installing this set into <code>/System/Library/</code>.</p>
<p><strong>FreeBSD-only simplification:</strong> we don't need the multi-OS <code>detect_platform()</code> dance the gershwin scripts carry. <code>MAKE_CMD</code> and <code>CPUS</code> are constants here:</p>
<pre class="shell"><code>MAKE_CMD=gmake
CPUS=$(sysctl -n hw.ncpu)</code></pre>
<p>No <code>case $OS in FreeBSD|GhostBSD|Linux ...</code> switch. Anyone wanting Linux/NetBSD support forks the repo.</p>
<h4>libdispatch</h4>
<pre class="shell"><code>cd "$REPOS_DIR/swift-corelibs-libdispatch/Build"
cmake .. \
-DCMAKE_INSTALL_PREFIX=/System/Library \
-DCMAKE_INSTALL_LIBDIR=Libraries \
-DINSTALL_DISPATCH_HEADERS_DIR=/System/Library/Headers/dispatch \
-DINSTALL_BLOCK_HEADERS_DIR=/System/Library/Headers \
-DINSTALL_OS_HEADERS_DIR=/System/Library/Headers/os \
-DINSTALL_PRIVATE_HEADERS=ON \
-DCMAKE_INSTALL_MANDIR=Documentation/man \
-DCMAKE_BUILD_TYPE=Release \
-DCMAKE_C_COMPILER=clang \
-DCMAKE_CXX_COMPILER=clang++
"$MAKE_CMD" -j"$CPUS" || exit 1
"$MAKE_CMD" install || exit 1</code></pre>
<h4>tools-make</h4>
<pre class="shell"><code>cd "$REPOS_DIR/tools-make"
$MAKE_CMD distclean 2>/dev/null || true
./configure \
--with-config-file=/System/Library/Preferences/GNUstep.conf \
--with-layout=gershwin \
--with-library-combo=ng-gnu-gnu \
--with-objc-lib-flag=" " \
LDFLAGS="-L/System/Library/Libraries" \
CPPFLAGS="-I/System/Library/Headers" \
libobjc_LIBS=" "
$MAKE_CMD || exit 1
$MAKE_CMD install</code></pre>
<h4>Source GNUstep.sh after tools-make install</h4>
<p>Required between tools-make and libobjc2. tools-make's install creates <code>/System/Library/Makefiles/GNUstep.sh</code>; sourcing it exports <code>GNUSTEP_HEADERS</code>, <code>GNUSTEP_LIBRARY</code>, <code>GNUSTEP_MAKEFILES</code> and adds <code>/System/Library/Tools</code> to <code>PATH</code> so <code>gnustep-config</code> resolves. Without this, libobjc2's cmake (which calls <code>gnustep-config --variable=GNUSTEP_${GNUSTEP_INSTALL_TYPE}_HEADERS</code> via <code>find_program</code>) can't find the tool, and install paths collapse to <code>/usr/local/lib/libobjc.so</code> + <code>/objc/*.h</code> — libs-base configure then fails with "Could not find Objective-C headers". libs-base's own <code>AC_CONFIG_AUX_DIR</code> also reads <code>$GNUSTEP_MAKEFILES</code>; sourcing once after tools-make covers both.</p>
<pre class="shell"><code>. /System/Library/Makefiles/GNUstep.sh</code></pre>
<h4>libobjc2</h4>
<pre class="shell"><code>if [ -d "$REPOS_DIR/libobjc2/Build" ]; then
rm -rf "$REPOS_DIR/libobjc2/Build"
fi
mkdir -p "$REPOS_DIR/libobjc2/Build"
cd "$REPOS_DIR/libobjc2/Build"
cmake .. \
-DGNUSTEP_INSTALL_TYPE=SYSTEM \
-DCMAKE_BUILD_TYPE=Release \
-DCMAKE_C_COMPILER=clang \
-DCMAKE_CXX_COMPILER=clang++ \
-DEMBEDDED_BLOCKS_RUNTIME=OFF \
-DBlocksRuntime_INCLUDE_DIR=/System/Library/Headers \
-DBlocksRuntime_LIBRARIES=/System/Library/Libraries/libBlocksRuntime.so
"$MAKE_CMD" -j"$CPUS" || exit 1
"$MAKE_CMD" install || exit 1</code></pre>
<h4>libs-base (Foundation)</h4>
<p><code>--disable-tls</code> is passed because launchd doesn't speak TLS; pulling in libgnutls/openssl just to satisfy NSStream/NSURLConnection's TLS code path would bloat the ISO. XSLT is not disabled — configure emits a warning but doesn't error.</p>
<pre class="shell"><code>export GNUSTEP_INSTALLATION_DOMAIN="SYSTEM"
cd "$REPOS_DIR/libs-base"
./configure \
--with-dispatch-include=/System/Library/Headers \
--with-dispatch-library=/System/Library/Libraries \
--disable-tls
$MAKE_CMD -j"$CPUS" || exit 1
$MAKE_CMD install
$MAKE_CMD clean</code></pre>
<h4>libs-corebase (CoreFoundation)</h4>
<pre class="shell"><code>cd "$REPOS_DIR/libs-corebase"
./configure \
CPPFLAGS="-I/System/Library/Headers" \
LDFLAGS="-L/System/Library/Libraries"
$MAKE_CMD -j"$CPUS" || exit 1
$MAKE_CMD install
$MAKE_CMD clean</code></pre>
<h3>8.5 Runtime linker</h3>
<p>Two parts. (a) launchd is linked with <code>-Wl,-rpath,/System/Library/Libraries</code> in its Makefile so it finds its own libs without environment help. (b) For everything else on the system that wants to link our libs, a one-line file <code>overlays/etc/ld-elf.so.conf.d/system.conf</code> containing <code>/System/Library/Libraries</code> ships in the ISO and is picked up by <code>ldconfig</code> at first boot.</p>
<div class="risk">
<strong>BlocksRuntime sourcing.</strong> The libobjc2 invocation expects <code>/System/Library/Libraries/libBlocksRuntime.so</code> already exists with the matching headers in <code>/System/Library/Headers/</code>. libdispatch's cmake builds and installs <code>libBlocksRuntime</code> when <code>INSTALL_PRIVATE_HEADERS=ON</code> + the Block headers are routed to <code>/System/Library/Headers</code>; verify in Phase 2 that this is sufficient, otherwise <code>build.sh</code> needs an explicit pre-step that builds BlocksRuntime separately (it lives in <code>swift-corelibs-libdispatch/src/BlocksRuntime/</code>).
</div>
<h3>8.6 The clone loop inside <code>build.sh</code></h3>
<p>The git-clone stage is just a shell loop near the top of <code>build.sh</code>, before pkgbase extraction. The five GNUstep upstreams plus <code>Tessil/robin-map</code> (sibling source for libobjc2's robinmap dep — see §8.3). No pinning, no patches at clone-time — tracks upstream HEAD.</p>
<pre class="shell"><code>REPOS_DIR="$(pwd)/repos"
REPOS="
https://github.com/apple/swift-corelibs-libdispatch.git
https://github.com/gnustep/tools-make.git
https://github.com/gnustep/libobjc2.git
https://github.com/Tessil/robin-map.git
https://github.com/gnustep/libs-base.git
https://github.com/gnustep/libs-corebase.git
"
mkdir -p "$REPOS_DIR"
cd "$REPOS_DIR"
for REPO in $REPOS; do
NAME=$(basename "$REPO" .git)
if [ -d "$NAME/.git" ]; then
echo "Updating $NAME..."
( cd "$NAME" && git fetch --all --tags && git pull --ff-only )
else
echo "Cloning $NAME..."
git clone "$REPO"
fi
done
cd -
</code></pre>
<h3>8.7 Package lists — start minimal, add when something breaks</h3>
<p><strong>Stance: install nothing we don't need.</strong> The starting point is the absolute minimum that lets <code>build.sh</code> get past <code>cmake</code> and <code>./configure</code>; everything else is added one package at a time, only when a build or runtime failure surfaces it. The gershwin desktop's <code>Bootstrap.sh</code> carries ~60 packages because it builds the full GUI stack — we are not building any of that, so we should not cargo-cult the list.</p>
<h4><code>buildpkgs.txt</code> — what Phase 2 shipped</h4>
<pre><code>cmake
ninja
gmake
autoconf
libtool
pkgconf
</code></pre>
<p><code>clang</code>/<code>clang++</code> ship in FreeBSD base, so they aren't listed. <code>git</code> is intentionally absent: all <code>git clone</code>s happen on the host before the chroot exists; the chroot is git-free. <code>pkgconf</code> was added when libs-base configure couldn't find ICU — it queries via <code>pkg-config icu-i18n / icu-uc</code>. <code>automake</code>, <code>llvm</code> still left out.</p>
<h4><code>pkglist.txt</code> — what Phase 2 shipped</h4>
<pre><code>libxml2
icu
</code></pre>
<p>Both are runtime deps of <code>libgnustep-base.so</code>:</p>
<ul>
<li><code>libxml2</code> — libs-base XML branch (GSXML / NSXMLNode / XML <code>NSPropertyListSerialization</code>). Configure has a <code>--disable-xml</code> flag but we keep XML on; binary plists are not the only format we need to read.</li>
<li><code>icu</code> — libs-base unicode (NSLocale / NSString). Hard dep, no <code>--disable</code> flag.</li>
</ul>
<p><strong>Surprises during Phase 2 surfacing:</strong></p>
<ul>
<li><code>libffi</code> — predicted as a likely candidate, but already supplied by FreeBSD base. Not added.</li>
<li><code>libgnutls</code> — also predicted; we took the <code>--disable-tls</code> path (libs-base configure flag, see §8.4) instead. Not added.</li>
</ul>
<p>Things from the gershwin <code>Bootstrap.sh</code> we explicitly do not add unless something forces our hand:</p>
<ul>
<li><code>libxslt</code> — XSLT transforms only; we parse plists.</li>
<li><code>gnutls</code> / <code>openssl</code> — TLS in NSURLConnection; launchd doesn't speak TLS. Configure libs-base with <code>--disable-tls</code> if it has the flag; if not, deal with it then.</li>
<li><code>mDNSResponder</code>, <code>dbus</code>, <code>cups</code>, <code>wget</code> — no Bonjour, no D-Bus, no printing, no fetching in-chroot.</li>
<li><code>squashfs-tools-ng</code>, <code>fusefs-squashfuse</code> — we use <code>mkuzip</code> + <code>geom_uzip</code> + in-kernel <code>unionfs</code>, not squashfs.</li>
<li><code>libvncserver</code>, <code>freerdp</code> — remote desktop, not in scope.</li>
<li>libjpeg-turbo, <code>tiff</code>, <code>png</code>, <code>giflib</code>, <code>ImageMagick7</code>, <code>cairo</code>, <code>libXft</code>, <code>libXt</code>, <code>libxcb</code>, <code>xcb-util-*</code>, <code>xrandr</code>, <code>libXrandr</code>, <code>libXcomposite</code>, <code>xorg-fonts-truetype</code>, <code>freeglut</code> — all GUI/image/X11. We have no display server.</li>
<li><code>flite</code>, <code>portaudio</code>, <code>libao</code> — audio. Not on a server.</li>
</ul>
<p>The pattern: <strong>every package added to either list gets a one-line commit message naming the build step that demanded it.</strong> Future readers (and future-you) can then audit whether that demand still holds when the upstream changes.</p>
<h3>8.8 Two scripts: <code>build.sh</code> (livecd) and <code>make-launchd.sh</code> (reusable)</h3>
<p>Everything livecd-specific lives in <code>build.sh</code>: the git clones, the pkgbase extraction, the chroot setup, the GNUstep-library builds, the slim pass, the mkuzip, the cd9660 wrap. <code>make-launchd.sh</code> is the only standalone-reusable piece — it's what gershwin-on-freebsd will call to add launchd to a system that already has the GNUstep stack.</p>
<table>
<thead><tr><th>Script</th><th>Runs where</th><th>Standalone?</th><th>Purpose</th></tr></thead>
<tbody>
<tr><td><code>build.sh</code></td><td>Host (FreeBSD VM)</td><td>(it <em>is</em> the livecd builder)</td><td>Inline git-clone of the 6 upstreams (5 GNUstep + Tessil/robin-map) into <code>repos/</code>. Extract pkgbase, mount chroot, install runtime + build pkgs, rsync repos in, sed-patch libobjc2's CMakeLists.txt (§8.3), run the §8.4 build invocations (libdispatch → tools-make → <code>. /System/Library/Makefiles/GNUstep.sh</code> → libobjc2 → libs-base → libs-corebase), then call <code>make-launchd.sh</code> chrooted, purge buildpkgs, slim, mkuzip, cd9660.</td></tr>
<tr><td><code>make-launchd.sh</code></td><td>Chroot or live host</td><td><strong>Yes — designed for this</strong></td><td>cd into <code>src/</code>, build launchd against <code>/System/Library/</code>, install <code>launchd</code> + <code>launchctl</code> to <code>/sbin/</code>, install plists from <code>plists/</code> to <code>/System/Library/LaunchDaemons/</code>. Hard-checks for <code>/System/Library/Libraries/libdispatch.so</code> and <code>libgnustep-base.so</code> before doing anything; exits with a clear error if they're missing.</td></tr>
<tr><td><code>tests/boot-test.sh</code></td><td>Host (CI Linux)</td><td>Yes</td><td>qemu+expect smoke test against an ISO path passed as <code>$1</code>.</td></tr>
</tbody>
</table>
<h4>The gershwin-on-freebsd integration story</h4>
<p>The whole reason for splitting <code>make-launchd.sh</code> out as standalone: gershwin-on-freebsd already builds the full GNUstep stack (libdispatch + libobjc2 + tools-make + libs-base + libs-corebase + libs-gui + libs-back + Workspace + LoginWindow + everything) into <code>/System/Library/</code> via the upstream <code>gershwin-developer</code> meta-installer. It does <em>not</em> have launchd. To add launchd, that ISO builder just does:</p>
<pre class="shell"><code># Inside the gershwin-on-freebsd chroot, after gershwin-developer's
# Install-System-Domain.sh has placed libdispatch + Foundation in /System/Library/
git clone https://github.com/pkgdemon/freebsd-launchd /tmp/launchd
cd /tmp/launchd
./make-launchd.sh # checks for /System/Library/Libraries/*, builds, installs to /sbin/
# Wire launchd as PID 1 in the gershwin-on-freebsd loader.conf:
echo 'init_path="/sbin/launchd"' >> "$WORK/cdroot/boot/loader.conf"</code></pre>
<p><strong>That's the entire integration.</strong> No git submodule, no shared CI, no version coupling. gershwin-on-freebsd clones the launchd repo in its own build script and gets a tested, boot-verified launchd dropped on top of its existing /System/Library/ tree. Symmetrically, our own <code>build.sh</code> calls <code>make-launchd.sh</code> in its chroot — same script, same behavior, no code duplication.</p>
<div class="open-q">
<strong>Standalone-script contract.</strong> <code>make-launchd.sh</code> takes one optional argument: <code>--prefix</code> (default <code>/</code>). All paths are resolved relative to that prefix, so the same script works for chroot installs (<code>--prefix=/path/to/chroot</code>) and live-system installs (<code>--prefix=/</code>). Settle the exact CLI in Phase 3 when the script first goes in.
</div>
<h2 id="livecd">9. The livecd build pipeline</h2>
<p>Lifted directly from <a href="https://github.com/pkgdemon/freebsd-livecd-unionfs"><code>freebsd-livecd-unionfs</code></a>. The skeleton — pkgbase extraction, chroot mount, slim, mkuzip, hybrid cd9660 — is unchanged. We add two new chroot stages between "pkg install" and "slim".</p>
<div class="ascii-diagram"> +-------------------------------------------+
| 0. host: ./checkout.sh | NEW
| git clone 6 upstreams into repos/ |
| at HEAD (git pull --ff-only on re-run) |
| (cached on CI runner; gitignored) |
+---------------------+---------------------+
|
v
+-------------------------------------------+
| 1. fetch base.txz + kernel.txz |
| from download.freebsd.org |
| (cached in distfiles/) |
+---------------------+---------------------+
|
v
+-------------------------------------------+
| 2. extract -> work/rootfs/ |
| cap_mkdb, pwd_mkdb |
+---------------------+---------------------+
|
v
+-------------------------------------------+
| 3. chroot + pkg bootstrap |
| pkg install -y < pkglist.txt | <-- runtime deps
| pkg install -y < buildpkgs.txt | <-- build deps (purged later)
| rsync repos/ -> chroot:/tmp/repos/ | <-- chroot stays git-free
| rsync src/ -> chroot:/tmp/launchd/ |
+---------------------+---------------------+
|
v NEW
+-------------------------------------------+
| 4. chroot: build GNUstep system domain |
| libdispatch -> tools-make -> libobjc2 |
| -> libs-base -> libs-corebase |
| install to /System/Library/ |
+---------------------+---------------------+
|
v NEW
+-------------------------------------------+
| 5. chroot: build launchd |
| cd /tmp/launchd && make / install |
| -> /sbin/launchd, /sbin/launchctl |
| -> /System/Library/LaunchDaemons/* |
+---------------------+---------------------+
|
v
+-------------------------------------------+
| 6. pkg delete -af buildpkgs | <-- purge before slim
| pkg clean -ay |
+---------------------+---------------------+
|
v
+-------------------------------------------+
| 7. slim: rm man/doc/info/locale/games/ |
| examples/include/tests/lib/debug |
| rm kernel/*.symbols |
+---------------------+---------------------+
|
v
+-------------------------------------------+
| 8. cp overlays/. -> work/rootfs/ |
| rewrite /etc/fstab |
+---------------------+---------------------+
|
v
+-------------------------------------------+
| 9. makefs ffs2 -> rootfs.img |
| mkuzip -A zstd -C 19 -> rootfs.uzip |
+---------------------+---------------------+
|
v
+-------------------------------------------+
|10. cdroot/: loader, kernel.gz, .ko mods, |
| /rescue, ramdisk/init.sh, rootfs.uzip |
| /sbin/init -> /rescue/init (cd9660) |
| boot/loader.conf init_path=/sbin/launchd|
+---------------------+---------------------+
|
v
+-------------------------------------------+
|11. makefs cd9660 rockridge |
| El Torito BIOS + EFI |
| -> out/livecd.iso |
+-------------------------------------------+</div>
<h3>9.1 Why build inside the chroot, not on the host VM?</h3>
<ul>
<li><strong>Hermetic.</strong> What we link against = what we ship. No risk of accidentally linking against a host library that won't exist in the booted system.</li>
<li><strong>Same toolchain as the runtime.</strong> Compiler version, libc version, headers all match the rootfs we're shipping.</li>
<li><strong>The chroot already has every base header we need.</strong> The pkgbase <code>base.txz</code> contains the full base userland; <code>buildpkgs.txt</code> only adds cmake/ninja/clang-extras/git that aren't in base.</li>
</ul>
<h3>9.2 PID 1 handoff</h3>
<p>Single-stage shebang chain. <code>loader.conf</code> sets
<code>init_path="/init.sh:/rescue/init"</code>. The kernel hits the
<code>#!/rescue/sh</code> shebang at the top of <code>/init.sh</code>
(handled unconditionally by <code>imgact_shell</code> — see §9.2.1)
and exec's <code>/rescue/sh</code> as PID 1 with <code>/init.sh</code>
as <code>argv[1]</code>. The script then:</p>
<ol>
<li>Mounts <code>devfs</code> at <code>/dev</code> and reopens stdio
from <code>/dev/console</code> (kernel hands PID 1 with
<code>fds 0/1/2</code> closed).</li>
<li><code>mdconfig</code> + <code>mount -t ufs/tmpfs/unionfs</code>
to layer the writable upper over the read-only uzip lower at
<code>/sysroot</code>.</li>
<li><code>exec /rescue/chroot /sysroot /sbin/launchd</code> — every
<code>exec</code> preserves the same PID, so launchd inherits as
the original PID 1 the kernel created.</li>
</ol>
<p>The colon-fallback to <code>/rescue/init</code> is for the case
where the kernel rejects <code>/init.sh</code> for any reason. In that
fallback, <code>/rescue/init</code> is PID 1, reads
<code>init_script=/init.sh</code> kenv, and invokes the same script
as a child. The script detects <code>$$ != 1</code> and uses the
classic <code>kenv init_chroot=/sysroot</code> + exit pattern,
falling back to FreeBSD's normal init flow (no launchd).</p>
<h4 id="why-not-init-c">9.2.1 Why we don't go through init.c</h4>
<p>An earlier draft of this section proposed setting
<code>kenv init_path=/sbin/launchd</code> in the script and relying
on init.c to re-read it after the <code>init_chroot</code> pivot.
<strong>Source review settled this:</strong> FreeBSD's
<code>/sbin/init</code> (and <code>/rescue/init</code>, which is the
same code, just crunchgen'd) reads <code>init_path</code> exactly
once at startup (<code>sbin/init/init.c:245</code>), and only re-checks
it on the <code>reroot</code> recovery path
(<code>init.c:814</code>) — never after the
<code>init_chroot</code> pivot in the normal boot flow. The only
re-exec hook in init.c is the <code>init_exec</code> kenv at
<code>init.c:321</code>, fired before <em>any</em> userland code runs
(too early to be useful for our pivot).</p>
<p>The shebang path bypasses init.c entirely. Verified by reading
<code>sys/kern/init_main.c</code> +
<code>sys/kern/imgact_shell.c</code>: <code>start_init()</code>
calls <code>kern_execve()</code> with no PID-1-special-case (line 797);
<code>do_execve()</code> iterates the imgact chain unconditionally
(<code>kern_exec.c:664-668</code>); <code>imgact_shell.c:108-110</code>
has no guards on <code>p->p_pid</code> or <code>P_SYSTEM</code>. The
shebang fires for the very first userspace exec exactly as it does
for any other.</p>
<p><strong>Caveats.</strong> Shell-script-as-init is undocumented in
FreeBSD — no <code>init(8)</code> mention, no shipped
<code>init_path</code> default uses it, no test in
<code>tests/sys/kern/</code> exercises it. The mechanism is purely a
consequence of the kernel exec path being uniform. Documented here so
future maintainers don't have to re-discover it.</p>
<h2 id="ci">10. CI & release pipeline</h2>
<p>Three GitHub Actions jobs. Pattern lifted from <code>freebsd-livecd-unionfs/.github/workflows/build.yml</code>.</p>
<table>
<thead><tr><th>Job</th><th>Runner</th><th>What it does</th><th>Gate</th></tr></thead>
<tbody>
<tr>
<td><code>build</code></td>
<td>ubuntu-latest hosting <code>vmactions/freebsd-vm@v1</code> (FreeBSD 15.0, 8 GB RAM, 4 CPU)</td>
<td>Disk-space cleanup, restore distfiles cache (keyed on <code>pkglist.txt</code> + <code>buildpkgs.txt</code>), <code>sh build.sh</code>, copy <code>out/</code> back to host, upload <code>livecd.iso</code> + <code>SHA256SUMS</code> as artifact.</td>
<td>Exit 0 from <code>build.sh</code></td>
</tr>
<tr>
<td><code>test</code></td>
<td>ubuntu-latest</td>
<td><code>apt-get install qemu-system-x86 expect ovmf</code>; download artifact; run <code>tests/boot-test.sh out/livecd.iso</code>; on failure upload <code>tests/boot.log</code>.</td>
<td>Both serial markers seen within 8 min</td>
</tr>
<tr>
<td><code>release</code></td>
<td>ubuntu-latest</td>
<td><code>needs: [build, test]</code> + <code>if: github.ref == 'refs/heads/main' && github.event_name == 'push'</code>. Renames ISO to <code>FreeBSD-15.0-amd64-launchd-YYYYMMDD.iso</code> + <code>.sha256</code>; <code>gh release delete continuous --yes --cleanup-tag</code>; <code>softprops/action-gh-release@v2</code> re-publishes with <code>tag_name: continuous</code>, <code>prerelease: true</code>.</td>
<td>Both prior jobs green</td>
</tr>
</tbody>
</table>
<h3>10.1 Boot-test marker set</h3>
<p>The current livecd-unionfs test waits for one marker: <code>login:</code> on serial. We extend to two:</p>
<pre class="shell"><code>spawn qemu-system-x86_64 -m 4G -machine q35 -bios $OVMF \
-cdrom $iso -display none -serial stdio -no-reboot
# Marker 1: launchd announces itself early
expect {
timeout { puts "\nFAIL: launchd did not announce within 8 min"; exit 1 }
"launchd: PID 1 ready" { puts "OK: launchd is PID 1" }
}
# Marker 2: getty came up via the launchd plist (or via init's /etc/ttys path)
expect {
timeout { puts "\nFAIL: 'login:' prompt not seen"; exit 1 }
"login:" { puts "OK: boot reached the login prompt" }
}</code></pre>
<p><code>launchd: PID 1 ready</code> is logged by our <code>launchd.c</code> after directory scan completes and the AF_UNIX server is accepting. Two markers means: <em>the binary is PID 1</em> AND <em>launchd's job model actually came up enough to spawn getty</em>. Either marker missing fails the release gate — no broken ISO ships.</p>
<h2 id="boot">11. Boot flow without rcorder</h2>
<p>FreeBSD <code>rc.d</code> uses <code>rcorder(8)</code> to topologically sort scripts by their <code># PROVIDE</code>/<code># REQUIRE</code>/<code># BEFORE</code> headers. launchd philosophically rejects this in favor of <strong>runtime dependency</strong> — a job blocks on the resource it needs and launchd starts the provider on demand.</p>
<h3>11.1 Socket-activated daemons (the easy 80%)</h3>
<p>Daemons that listen on sockets — <code>sshd</code>, <code>rpcbind</code>, <code>mountd</code>, <code>syslogd</code> — declare their listen sockets in their plist's <code>Sockets</code> key. launchd opens the socket, listens on the daemon's behalf, and only forks the daemon when a connection arrives. Anything that needs the daemon just <code>connect()</code>s; the kernel queues the connection until the daemon answers. <strong>Eliminates ordering for socket clients</strong>: <code>nfsd</code> calling <code>rpcbind</code> via the loopback socket Just Works regardless of start order.</p>
<h3>11.2 Phase no-op jobs (the awkward 15%)</h3>
<p>Some "dependencies" aren't sockets — "filesystems mounted", "network configured", "kernel modules loaded". Each becomes a one-shot plist with a well-known label that touches a stamp file on success:</p>
<table>
<thead><tr><th>Phase job label</th><th>Does</th><th>Stamp file</th></tr></thead>
<tbody>
<tr><td><code>org.freebsd.phase.kld-loaded</code></td><td><code>kldload</code> everything in <code>kld_list</code></td><td><code>/var/run/.phase.kld-loaded</code></td></tr>
<tr><td><code>org.freebsd.phase.filesystems-ready</code></td><td><code>fsck -p</code> + <code>mount -a -t nonfs,nullfs</code> + <code>swapon -a</code></td><td><code>/var/run/.phase.filesystems-ready</code></td></tr>
<tr><td><code>org.freebsd.phase.network-ready</code></td><td>Touch the file once <code>PF_ROUTE</code> reports <code>RTM_NEWADDR</code> for any non-loopback iface</td><td><code>/var/run/.phase.network-ready</code></td></tr>
</tbody>
</table>
<h3>11.3 Explicit ordering (the last 5%)</h3>
<p>Narrow extension to the plist schema: a <code>RequiresPhase</code> array key the bootstrapper resolves at load time. Daemons in <code>WAITING</code> until each listed phase's stamp file exists. Gives back rcorder-style declarative power for the few cases that need it without re-introducing a full topological sort.</p>
<h3>11.4 <code>devd</code> is special</h3>
<p>devd must run before <code>netif</code> because NIC <code>IFATTACH</code> events flow through it. Plan: a <code>LaunchDaemons.early/</code> directory the bootstrapper loads before the main scan. Keeps the launchd binary simple and the early tier declarative.</p>
<h2 id="rcd">12. rc.d port scope</h2>
<h3>12.0 Configuration mechanism — plists, not <code>rc.conf</code></h3>
<p>Every per-host configuration concern — hostname, interface IP, default route, WiFi credentials, daemon arguments — is expressed as a plist, not as a key in <code>/etc/rc.conf</code>, and not as a single-purpose file like <code>/etc/hostname</code>. There is <strong>no <code>rc.conf</code> parsing layer</strong> in this project. Users edit the plist directly (or, more commonly, edit it via GUI tooling that manipulates the plist).</p>
<p>Path conventions:</p>
<ul>
<li><code>/System/Library/LaunchDaemons/</code> — project-shipped plists (getty, devd, syslogd, etc.). Untouched by users.</li>
<li><code>/Local/Library/LaunchDaemons/</code> — admin-installed and user-edited plists (hostname, per-iface networking, custom services). The "third-party" tier in the gershwin layout.</li>
</ul>
<p>Concrete examples of the per-component pattern:</p>
<table>
<thead><tr><th>Concern</th><th>Plist file</th><th>What runs</th></tr></thead>
<tbody>
<tr><td>Hostname</td><td><code>/Local/Library/LaunchDaemons/org.freebsd.hostname.plist</code></td><td><code>/bin/hostname my-host</code> (the string is in the plist's <code>ProgramArguments</code>)</td></tr>
<tr><td>Static IP on em0</td><td><code>/Local/Library/LaunchDaemons/org.freebsd.netif.em0.plist</code></td><td><code>/sbin/ifconfig em0 inet 192.168.1.10/24 up</code></td></tr>
<tr><td>DHCP on em0</td><td><code>/Local/Library/LaunchDaemons/org.freebsd.dhclient.em0.plist</code></td><td><code>/sbin/dhclient -d em0</code> (KeepAlive=true)</td></tr>
<tr><td>WiFi on wlan0</td><td><code>/Local/Library/LaunchDaemons/org.freebsd.wpa.wlan0.plist</code></td><td><code>/usr/sbin/wpa_supplicant -i wlan0 -c /etc/wpa_supplicant.conf</code> (KeepAlive=true)</td></tr>
<tr><td>Default route</td><td><code>/Local/Library/LaunchDaemons/org.freebsd.routes.plist</code></td><td><code>/sbin/route add default 192.168.1.1</code></td></tr>
</tbody>
</table>
<p><strong>Why no <code>rc.conf</code> parser:</strong> rc.conf has 1000+ knobs accumulated over 30 years. Supporting "some" of them creates a confusing partial-compatibility layer. A plist's <code>ProgramArguments</code> shows the literal command that runs — no magic translation, no surprises. For users coming from FreeBSD muscle memory, the substitution is one-line: whatever you'd put after <code>ifconfig_em0=</code> in rc.conf goes into <code>ProgramArguments</code> verbatim.</p>
<p><strong>Why no <code>/etc/hostname</code> shortcut:</strong> consistency. Every other concern is a plist; hostname becoming the lone single-line file would be the inconsistent choice.</p>
<p><strong>WiFi keeps <code>/etc/wpa_supplicant.conf</code></strong> because it's <code>wpa_supplicant</code>'s own configuration file (we'd be reinventing the wheel to replace it). The plist just <em>invokes</em> wpa_supplicant; configuration of the WiFi credentials themselves stays in the file format the tool already reads.</p>
<h3>12.1 Milestone 1 — launchd boots, console login</h3>
<p>Smallest possible scope that proves launchd is PID 1: no plists, just the daemon itself + getty via the existing <code>/etc/ttys</code> path. The boot test watches for <code>launchd: PID 1 ready</code> and <code>login:</code> on serial.</p>
<table>
<thead><tr><th>Service</th><th>Plist label</th><th>Notes</th></tr></thead>
<tbody>
<tr><td>getty</td><td>—</td><td>Not a launchd job in this milestone. <code>init</code> handoff path in <code>launchd.c</code> reads <code>/etc/ttys</code> and execs getty per line. Same code path Apple's launchd has had since 10.4.</td></tr>
</tbody>
</table>
<h3>12.2 Milestone 2 — sshd as the first launchd-supervised daemon</h3>
<p><strong>This is the proof-of-life milestone for the supervisor.</strong> Pick one daemon, give it a plist, watch launchd manage it. <code>sshd</code> is the right choice because it (a) is universally useful, (b) gives us an end-to-end smoke test (boot ISO → ssh in from outside → kill the sshd → see launchd respawn it), and (c) forces just enough networking to be real, no more.</p>
<p>Bring-up set for this milestone:</p>
<table>
<thead><tr><th>Service</th><th>Plist label</th><th>Notes</th></tr></thead>
<tbody>
<tr><td><code>netif</code> (one-shot)</td><td><code>org.freebsd.netif</code></td><td>One-shot: <code>ifconfig</code> interfaces up from <code>rc.conf</code>. Touches <code>/var/run/.phase.netif-up</code>.</td></tr>
<tr><td><code>dhclient</code> (one iface for now)</td><td><code>org.freebsd.dhclient.<iface></code></td><td>Single hand-written plist for the primary iface. Per-iface dynamic generation deferred to milestone 3. KeepAlive=true. RequiresPhase=[netif-up].</td></tr>
<tr><td><code>sshd</code></td><td><code>org.freebsd.sshd</code></td><td>Socket-activated on TCP/22; <code>inetdCompatibility={Wait:true}</code>. RequiresPhase=[network-ready]. <strong>The headline test target.</strong></td></tr>
<tr><td>Phase: netif-up</td><td><code>org.freebsd.phase.netif-up</code></td><td>Touched by <code>netif</code>.</td></tr>
<tr><td>Phase: network-ready</td><td><code>org.freebsd.phase.network-ready</code></td><td>Long-running watcher; touches stamp on first <code>RTM_NEWADDR</code>.</td></tr>
</tbody>
</table>
<p>Boot-test extension: third marker watches for sshd listening (e.g. <code>sshd: Server listening on 0.0.0.0 port 22</code>). Gate the release on it.</p>
<h3>12.3 Milestone 3 — remaining base services (shipped, not goal-gating)</h3>
<p>Once milestone 2 is green, fill in plists for the rest of the FreeBSD base service set. <strong>These ship in the repo so users can <code>launchctl load</code> what they want; they don't gate the release.</strong> The ISO doesn't have to <em>start</em> nfsd to pass CI — it just has to ship a working plist for users who do want NFS.</p>
<table>
<thead><tr><th>Service</th><th>Plist label</th><th>Goal-essential?</th><th>Notes</th></tr></thead>
<tbody>
<tr><td><code>devd</code></td><td><code>org.freebsd.devd</code></td><td>Yes — most systems want it</td><td>Early-tier; loads before main scan. KeepAlive=true.</td></tr>
<tr><td><code>syslogd</code></td><td><code>org.freebsd.syslogd</code></td><td>Yes</td><td>Socket-activated on <code>/var/run/log</code>; KeepAlive=true.</td></tr>
<tr><td><code>cron</code></td><td><code>org.freebsd.cron</code></td><td>Yes</td><td><code>cron -s</code> (no fork); KeepAlive=true.</td></tr>
<tr><td><code>wpa_supplicant</code> (per iface)</td><td><code>org.freebsd.wpa_supplicant.<iface></code></td><td>Opt-in (wireless only)</td><td>Per-wireless-interface; generated dynamically. KeepAlive=true. RequiresPhase=[netif-up].</td></tr>
<tr><td><code>dhclient</code> (per iface, dynamic)</td><td><code>org.freebsd.dhclient.<iface></code></td><td>Yes</td><td>Replaces the milestone-2 hand-written plist with the dynamic per-iface generation pattern from §12.6.</td></tr>
<tr><td><code>rpcbind</code></td><td><code>org.freebsd.rpcbind</code></td><td><strong>Ship plist; not goal-essential</strong></td><td>KeepAlive=true; needed only if user opts into NFS. RequiresPhase=[network-ready].</td></tr>
<tr><td><code>mountd</code></td><td><code>org.freebsd.mountd</code></td><td><strong>Ship plist; not goal-essential</strong></td><td>KeepAlive=true; <code>WatchPaths=[/etc/exports, /etc/zfs/exports]</code> for live reload.</td></tr>
<tr><td><code>nfsd</code></td><td><code>org.freebsd.nfsd</code></td><td><strong>Ship plist; not goal-essential</strong></td><td>KeepAlive=true; RequiresPhase=[network-ready].</td></tr>
<tr><td><code>nfsclient</code></td><td><code>org.freebsd.nfsclient</code></td><td><strong>Ship plist; not goal-essential</strong></td><td>One-shot: <code>kldload nfscl</code> + <code>vfs.nfs.*</code> sysctls.</td></tr>
<tr><td>Phase: kld-loaded</td><td><code>org.freebsd.phase.kld-loaded</code></td><td>Yes</td><td>One-shot.</td></tr>
<tr><td>Phase: filesystems-ready</td><td><code>org.freebsd.phase.filesystems-ready</code></td><td>Yes</td><td>One-shot.</td></tr>
</tbody>
</table>
<h3>12.4 Sketch: <code>org.freebsd.sshd.plist</code></h3>
<pre class="plist"><code><?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>Label</key> <string>org.freebsd.sshd</string>
<key>ProgramArguments</key> <array>
<string>/usr/sbin/sshd</string>
<string>-i</string>
</array>
<key>inetdCompatibility</key> <dict><key>Wait</key><true/></dict>
<key>Sockets</key> <dict>
<key>Listeners</key> <dict>
<key>SockServiceName</key> <string>ssh</string>
<key>SockType</key> <string>stream</string>
<key>Bonjour</key> <false/>
</dict>
</dict>
<key>RequiresPhase</key> <array><string>network-ready</string></array>
</dict>
</plist></code></pre>
<h3>12.5 Out of scope (won't port — server scope)</h3>
<ul>
<li><strong>Hardware-laptop:</strong> <code>apm</code>, <code>apmd</code>, <code>powerd</code>, <code>bluetooth</code>, <code>hcsecd</code>, <code>sdpd</code>, <code>watchdogd</code></li>
<li><strong>Legacy / deprecated:</strong> <code>sendmail</code>, <code>sendmail_submit</code>, <code>amd</code>, <code>ntpdate</code>, <code>ftpd</code>, <code>tftpd</code>, <code>inetd</code> (launchd <em>is</em> the inetd replacement), <code>ypbind</code>/<code>ypserv</code>/<code>yppasswdd</code> (NIS, dead)</li>
<li><strong>Niche:</strong> <code>accounting</code>, <code>bsnmpd</code>, <code>hastd</code>, <code>ctld</code>, <code>iscsid</code>, <code>nfscbd</code>, <code>gssd</code></li>
<li><strong>Firewall:</strong> port one path (probably <code>pf</code>); skip <code>ipfw</code>/<code>ipfw_netflow</code>/<code>pflog</code>/<code>pfsync</code> until requested</li>
<li><strong>Caching:</strong> <code>local_unbound</code>, NSS caches — not until requested</li>
</ul>
<h3>12.6 The "anything else becomes a one-shot" rule</h3>
<p>Single most important translation rule: <strong>anything <code>rc.d</code> does that isn't "run a long-lived daemon" becomes a one-shot plist</strong> with <code>RunAtLoad=true, KeepAlive=false, LaunchOnlyOnce=true</code>. Examples: <code>sysctl</code> from <code>/etc/sysctl.conf</code>, <code>kldload</code> from <code>kld_list</code>, <code>fsck -p</code> + <code>mount -a</code>, <code>swapon -a</code>, <code>/var/run</code> + <code>/tmp</code> cleanup. Live config-file reload (<code>service foo reload</code>) is a strict improvement in launchd via <code>WatchPaths</code> / <code>QueueDirectories</code>.</p>
<h3>12.7 Service enable / disable UX</h3>
<p>Concrete scenario: <code>sshd</code> ships <strong>disabled by default</strong>. Admin enables it on a freshly-installed system. How?</p>
<h4>Apple's model (the target)</h4>
<p>The shipped plist at <code>/System/Library/LaunchDaemons/org.freebsd.sshd.plist</code> contains:</p>
<pre class="plist"><code><key>Disabled</key>
<true/></code></pre>