A FreeBSD-only port of Apple's launchd that drops Mach IPC, replaces rc.d as PID 1, and lives in standard FreeBSD paths. Single repo, single build.sh: builds the GNUstep system-domain libraries + launchd inside a livecd staging chroot, boot-tests the resulting ISO in CI, publishes a continuous release. Pipeline lifted from freebsd-livecd-unionfs.
/etc/rc + rc.d on FreeBSD with Apple launchd 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.libxpc, no darlingserver. All Mach paths in the imported source are deleted; service-to-service IPC uses launchd's own AF_UNIX socket activation (Sockets plist key, predates XPC).libdispatch dispatch sources only (kqueue under the hood). No raw kqueue/kevent in our code.NSPropertyListSerialization (handles XML and binary). Foundation in PID 1 — same shape as macOS.build.sh. No submodule.build.sh, tests/boot-test.sh, and the GitHub Actions workflow from freebsd-livecd-unionfs. Build → boot-test in qemu → publish a continuous release ISO. Boot test watches the serial console for two markers: launchd: PID 1 ready and login:. Either marker missing fails the release gate.hier(7) — /sbin/launchd, /sbin/launchctl. Only the plist directories use the Apple/NeXT layout, because that's what the daemon already scans.NOTICE enumerates Apple, swift-corelibs-libdispatch, GNUstep, and us.Boot a FreeBSD 15.0 live ISO to a usable multi-user prompt with launchd as PID 1. Reach a console login:, then prove the supervisor with sshd. Other base service plists — syslogd, cron, devd, mountd, nfsd, nfsclient, etc. — ship in the repo so users can launchctl load what they want, but they aren't release-gating goals. No /etc/rc, no /etc/rc.d, no service(8). Every push to main rebuilds the ISO, boot-tests it in qemu, and (on green) publishes it as the continuous GitHub release.
rc.d script. FreeBSD ships ~180; we'll port a server-essential ~12 and document the rest as "won't port" with reasons (§11).MachServices get the key silently ignored (and logged at debug level).libxpc emulation. XPC services are Mach-rooted; without Mach there's nothing to emulate.One repo: pkgdemon/freebsd-launchd. It contains:
launchd-842.1.4 source (one-time fork, Mach paths gutted) under src/.plists/.build.sh that — modeled on freebsd-livecd-unionfs — extracts FreeBSD pkgbase, mounts a chroot, clones & builds the GNUstep system-domain libraries into /System/Library/, builds launchd into /sbin/, slims the rootfs, mkuzips it, and wraps it in a hybrid BIOS+UEFI cd9660 ISO.tests/boot-test.sh that boots the ISO under qemu+OVMF and watches serial for launchd-emitted markers.No git submodules. Earlier versions of this plan considered a "system-domain Gershwin fork" as a submodule. Cleaner approach: build.sh simply git clones each upstream library at chroot-build time, builds, installs.
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
dispatch_get_main_queue(). Worker fan-out is libdispatch's problem; we never call pthread_create.dispatch_main(), which is a kqueue wait under the hood. No custom poll loop.DISPATCH_SOURCE_TYPE_PROC with DISPATCH_PROC_EXIT gives clean per-pid death notification. SIGCHLD remains as a backstop for orphans PID 1 inherits.dispatch_async to a stop-everyone-and-exit block.SIG_IGN. Under SIG_IGN on FreeBSD the kernel reaps zombies synchronously and never delivers SIGCHLD, which means EVFILT_SIGNAL never fires and the dispatch source is silent.
This repo follows FreeBSD hier(7) for binaries and only uses the /System tree for things that genuinely belong there (libraries the launchd daemon links against, plist directories the daemon scans).
| Artifact | Path | Why |
|---|---|---|
launchd binary | /sbin/launchd | PID 1 must live on the root partition; matches init's location. |
launchctl binary | /sbin/launchctl | Needed during single-user before /usr/local mounts; matches service(8). |
wait4path helper | /usr/bin/wait4path | Non-essential; user-callable. |
| Man pages | /usr/share/man/man{1,5,8}/ | Base-system man path. |
| System LaunchDaemons | /System/Library/LaunchDaemons/ | NeXT/Apple convention; default scan dir baked into launchd.c. |
| Third-party LaunchDaemons | /Local/Library/LaunchDaemons/ | Second scan dir; for ports/pkg-installed services. Uses the gershwin /Local/Library/ tree (where tools-make installs third-party gnustep-make resources) rather than Apple's /Library/. |
| Per-user LaunchAgents | ~/Library/LaunchAgents/ + /Local/Library/LaunchAgents/ | User half matches macOS exactly (gershwin home dirs are /Users/<u>/Library/...); local-system half uses gershwin /Local/Library/. Not used on a server but the support stays. |
| Control socket (PID 1) | /var/run/launchd/sock | Matches IPC_DEFAULT_PID1_SOCK in ipc.c. |
| libdispatch (.so + headers) | /System/Library/Libraries/libdispatch.so/System/Library/Headers/dispatch/ | Built by build.sh in-chroot; out of /usr/local so the FreeBSD pkg copy doesn't shadow it. |
| libobjc2, libgnustep-base, libgnustep-corebase | /System/Library/Libraries/ | Same. |
| gnustep-make | /System/Library/Makefiles/ | Build-time only; needed to compile downstream daemons against Foundation. |
Why split it this way: a sysadmin shelling into a FreeBSD box should find launchctl at the path service(8) would have been at — /sbin. Burying admin tools under /System/Library/Tools/ would only make sense for an all-in OS-distribution pivot; in a FreeBSD-shaped install where everything else lives in hier(7) locations, it's hostile.
| Decision | Choice |
|---|---|
| Target kernel | FreeBSD 14.x and 15.x. No Linux, no NetBSD, no portability gates. |
| Binary format | ELF (FreeBSD native). No Mach-O, no dyld. |
| Toolchain | clang + lld + llvm-ar (FreeBSD base). Never gcc. |
| Mach IPC | None. All <mach/...>, MIG .defs deleted. |
| XPC | None. libxpc not in tree. |
| Service IPC | AF_UNIX socket activation via launchd's Sockets plist key. Out-of-band fd passing via SCM_RIGHTS. |
| Init / PID 1 | This launchd. Replaces /sbin/init's rc-chain via init_path kenv. |
| Event loop | libdispatch dispatch sources only. |
| libdispatch source | apple/swift-corelibs-libdispatch built from source by build.sh in the staging chroot. Not devel/libdispatch from FreeBSD pkg, because that lands in /usr/local and would shadow ours. |
| Plist parsing | GNUstep NSPropertyListSerialization from libgnustep-base. Handles XML and binary plists. |
| Foundation in PID 1 | Yes. macOS does it; cost ~10 MB linked, payable once. |
| Where the GNUstep libs come from | build.sh clones each upstream repo into the chroot, builds, installs to /System/Library/. No git submodule, no Gershwin fork. |
| License (top-level) | BSD-2-Clause (matches FreeBSD ecosystem). Apple's launchd files retain Apache 2.0 headers per-file. |
| Build platform | FreeBSD only, inside vmactions/freebsd-vm@v1. No macOS, no Linux. Mac is edit-only. |
| Release artifact | A bootable hybrid BIOS+UEFI cd9660 ISO with launchd as PID 1, published as continuous on every push to main. |
| Release gate | Boot test must observe both launchd: PID 1 ready and login: on serial within 8 minutes. |
src/)Imported source: Apple launchd-842.1.4. 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 (runtime.c, ipc.c, core.[cm], log.c, launchctl.c, plus the Mach-pruning of liblaunch.c) land as code reused from a previous attempt to install launchd in system; the file-by-file table below describes the target shape, not work typed from scratch.
launchd.xcodeproj/, xcconfigs/, xcscripts/ — Xcode build infrasrc/*.defs — MIG interface definitions (Mach-RPC IDL)src/kill2.{c,h} — Apple kill2() syscall wrapper; replaced by POSIX kill(2)src/ktrace.{c,h} — Apple kdebug emittersSystemStarter/ — pre-launchd /System/Library/StartupItems mechanism (legacy since 10.4)liblaunch/libbootstrap.c, bootstrap.h, bootstrap_priv.h — Mach bootstrap-server clientsupport/launchproxy.c — Mach-IPC inetd-style wrapper| File | Apple LOC | Mach refs | Action | Target LOC |
|---|---|---|---|---|
src/launchd.c | ~660 | 1 | Prune. Drop task_set_bootstrap_port; replace monitor_networking_state (Darwin PF_SYSTEM/KEV_NETWORK_CLASS) with a FreeBSD PF_ROUTE dispatch source watching RTM_NEWADDR/RTM_DELADDR. | ~400 |
src/runtime.c | 1,453 | 85 | Rewrite from scratch on libdispatch. | ~600 |
src/ipc.c | 537 | ? | Rewrite (small). Replace kevent_mod with DISPATCH_SOURCE_TYPE_READ; drop the MachServices checkin branch. | ~500 |
src/core.[cm] | 11,973 | 207 | Rewrite from a near-empty file. Port the data structures, plist-key parsers, KeepAlive policy state machine, Sockets activation. Drop: MachServices, bootstrap_subset_t, domain_t, XPC, jetsam_*, audit-session, PerUserLaunchd. Keep as Objective-C using NSPropertyListSerialization. | ~2000-2500 |
src/log.{c,h} | — | 3-6 | Prune mach_error_string. Keep syslog buffer, console fallback, level mask. | ~300 |
liblaunch/liblaunch.c | — | 8 | Prune Mach refs (8 spots). Keep launch_data_pack/unpack, launchd_msg_send/recv wire-format code. | ~1200 |
liblaunch/launch.h et al | — | few | Drop LAUNCH_DATA_MACHPORT and the three accessors. | ~250 |
liblaunch/libvproc.c | 1,061 | 42 | Delete entirely. No Mach, no ports to pass. | 0 |
support/launchctl.c | 4,549 | 21 | Substantial rewrite. Drop bsexec, bslist, bstree, bootstrap, asuser. Keep load, unload, start, stop, list, submit, getenv, etc. Plist reading via NSPropertyListSerialization. | ~1500 |
support/wait4path.c | — | 0 | Keep as is. | unchanged |
man/, rc/ | — | 0 | Keep selectively. rc.netboot gone. | shrinks |
Total post-Phase-2: roughly 7-8k LOC vs Apple's ~22k pre-amputation. About 65% deletion.
Apple's launchd 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.
| Plist key | Status | Notes |
|---|---|---|
Label | implemented | Job identifier; required. |
ProgramArguments | implemented | argv to posix_spawn. |
RunAtLoad | implemented | Spawn at scan time. Verified by every shipped plist. |
KeepAlive | implemented | Respawn on exit. Verified by getty + syslogd surviving across login sessions. |
Sockets | implemented | launchd opens AF_UNIX listener, hands fd via env. Verified by the IPC socket at /var/run/launchd/sock. |
inetdCompatibility (Wait subkey) | partial | Minimal sshd-shape works; verify edge cases when sshd lands in Phase 4. |
EnvironmentVariables | NOT implemented | core.m:391 calls posix_spawn(..., environ) unconditionally — plist env is ignored. Workaround: use absolute paths in ProgramArguments, or wrap with /bin/sh -c "PATH=...; cmd". Symptom: shell-wrapped plist exits 127 (command not found). |
StartCalendarInterval | NOT implemented | Apple's cron-replacement key (dict with optional Minute/Hour/Day/Weekday/Month). Workaround: ship /usr/sbin/cron as a compat daemon (we do — org.freebsd.cron.plist) and put scheduled tasks in crontabs. Long-term answer: add this key to core.m (~50 LOC, NSDate-based timer waking at next match), migrate periodic(8)-style daily/weekly/monthly tasks to native launchd plists, optionally drop cron from the ISO entirely. |
WorkingDirectory | unverified, likely not | Should chdir before exec. Add when a daemon needs it. |
UserName / GroupName | unverified, likely not | Drop privileges before exec. Will be needed when sshd plist lands in Phase 4. |
StandardOutPath / StandardErrorPath | unverified, likely not | Redirect spawned-child stdio. Add when a per-job log file is wanted. |
Umask | unverified, likely not | Per-job umask before exec. |
ThrottleInterval | unverified, likely not | KeepAlive currently respawns immediately on exit; no rate-limit. Add when a flapping daemon makes us want backoff. |
Disabled | unverified — likely partial | Per-job "skip me at scan time" boolean. Apple-shipped plists ship with Disabled=true for opt-in services (sshd, nfsd, etc.) so the user has to explicitly enable them. 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 — see §12.7. |
RequiresPhase (proposed extension) | NOT implemented | 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. |
Implementation pattern for a new key (~30-50 LOC each, in core.m):
NSDictionary at job-load time.EnvironmentVariables; chdir(2) before exec for WorkingDirectory; setuid(2)/setgid(2) for UserName/GroupName; open(2) + dup2(2) for Standard{Out,Error}Path; dispatch_source_create(DISPATCH_SOURCE_TYPE_TIMER, ...) for StartCalendarInterval; etc.Phase-5 batch: implement EnvironmentVariables, StartCalendarInterval, WorkingDirectory, UserName/GroupName, Standard{Out,Error}Path, Umask 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.
| Feature | Portable BSD+Linux approach | This repo (FreeBSD-only) |
|---|---|---|
| Per-pid child death | SIGCHLD source + waitpid(WNOHANG) drain + pid hash | DISPATCH_SOURCE_TYPE_PROC with DISPATCH_PROC_EXIT per job; SIGCHLD as backstop only |
WatchPaths | "BSD only in v1; Linux gets inotify shim later" | DISPATCH_SOURCE_TYPE_VNODE with full flag set. Just works. |
| Peer creds on AF_UNIX | #ifdef SO_PEERCRED / LOCAL_PEERCRED branches | LOCAL_PEERCRED + struct xucred. One path. |
| Network-ready signal | "TODO: netlink shim or always-up placeholder" | PF_ROUTE socket as DISPATCH_SOURCE_TYPE_READ, parse RTM_NEWADDR |
| VNODE flag for read-only | O_EVTONLY shim (Darwin-only) | O_RDONLY works directly |
| Build-system gates | Pervasive #ifdef __FreeBSD__ / __linux__ | Delete them all |
Five upstream libraries are cloned and built by build.sh 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.
build.shRule lifted from gershwin-on-freebsd: all git operations happen on the host, the chroot stays git-free. build.sh handles both halves inline:
build.sh): git clone the six upstreams (5 GNUstep + Tessil/robin-map) into repos/<name>/ at the repo root, or git pull --ff-only if already present. Idempotent.pkg install runtime + build deps, rsync -a ./repos/ work/rootfs/tmp/repos/ into the chroot, sed-patch libobjc2's CMakeLists.txt per §8.3, then run the build invocations from §8.4 with REPOS_DIR=/tmp/repos set in the environment.build.sh calls ./make-launchd.sh (chroot'd). That's the standalone-reusable script — it builds src/ against /System/Library/ and installs launchd/launchctl to /sbin/.repos/ is in .gitignore. CI caches it on the runner so unchanged upstreams skip re-clone.
| Order | Upstream | Build system | Installs to |
|---|---|---|---|
| 1 | apple/swift-corelibs-libdispatch | cmake | /System/Library/Libraries/libdispatch.so + headers in /System/Library/Headers/{dispatch,os}/ |
| 2 | gnustep/tools-make | autoconf | /System/Library/Makefiles/ + /System/Library/Preferences/GNUstep.conf |
| 3 | gnustep/libobjc2 | cmake | /System/Library/Libraries/libobjc.so |
| 4 | gnustep/libs-base | gnustep-make | /System/Library/Libraries/libgnustep-base.so |
| 5 | gnustep/libs-corebase | gnustep-make | /System/Library/Libraries/libgnustep-corebase.so |
Order is significant: tools-make writes /System/Library/Preferences/GNUstep.conf (the --with-layout=gershwin map), libobjc2 needs that config plus an established BlocksRuntime from libdispatch, and libs-base/libs-corebase drive their installs through gnustep-make with GNUSTEP_INSTALLATION_DOMAIN=SYSTEM.
We start with stock swift-corelibs-libdispatch 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 patches/ and gets applied at host-side checkout; until then the directory doesn't exist.
libobjc2's CMakeLists.txt calls FetchContent_Declare(robinmap GIT_REPOSITORY ...), which fires git at configure-time. The chroot is git-free by design, so this fails. The fix has two parts:
Tessil/robin-map to the host clone loop (§8.6) as a sibling to libobjc2.repos/ into the chroot, before the libobjc2 cmake invocation, sed-patch the chroot copy of libobjc2/CMakeLists.txt to use SOURCE_DIR at the rsynced sibling path:
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"
Patching the chroot copy (not the host clone) keeps future git pull --ff-only runs clean against upstream libobjc2.
This block is what build.sh runs inside the chroot, after checkout.sh has populated repos/ on the host and stage 3 has rsync'd it to $REPOS_DIR=/tmp/repos. Verbatim — these commands are known-good for installing this set into /System/Library/.
FreeBSD-only simplification: we don't need the multi-OS detect_platform() dance the gershwin scripts carry. MAKE_CMD and CPUS are constants here:
MAKE_CMD=gmake
CPUS=$(sysctl -n hw.ncpu)
No case $OS in FreeBSD|GhostBSD|Linux ... switch. Anyone wanting Linux/NetBSD support forks the repo.
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
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
Required between tools-make and libobjc2. tools-make's install creates /System/Library/Makefiles/GNUstep.sh; sourcing it exports GNUSTEP_HEADERS, GNUSTEP_LIBRARY, GNUSTEP_MAKEFILES and adds /System/Library/Tools to PATH so gnustep-config resolves. Without this, libobjc2's cmake (which calls gnustep-config --variable=GNUSTEP_${GNUSTEP_INSTALL_TYPE}_HEADERS via find_program) can't find the tool, and install paths collapse to /usr/local/lib/libobjc.so + /objc/*.h — libs-base configure then fails with "Could not find Objective-C headers". libs-base's own AC_CONFIG_AUX_DIR also reads $GNUSTEP_MAKEFILES; sourcing once after tools-make covers both.
. /System/Library/Makefiles/GNUstep.sh
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
--disable-tls 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.
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
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
Two parts. (a) launchd is linked with -Wl,-rpath,/System/Library/Libraries 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 overlays/etc/ld-elf.so.conf.d/system.conf containing /System/Library/Libraries ships in the ISO and is picked up by ldconfig at first boot.
/System/Library/Libraries/libBlocksRuntime.so already exists with the matching headers in /System/Library/Headers/. libdispatch's cmake builds and installs libBlocksRuntime when INSTALL_PRIVATE_HEADERS=ON + the Block headers are routed to /System/Library/Headers; verify in Phase 2 that this is sufficient, otherwise build.sh needs an explicit pre-step that builds BlocksRuntime separately (it lives in swift-corelibs-libdispatch/src/BlocksRuntime/).
build.shThe git-clone stage is just a shell loop near the top of build.sh, before pkgbase extraction. The five GNUstep upstreams plus Tessil/robin-map (sibling source for libobjc2's robinmap dep — see §8.3). No pinning, no patches at clone-time — tracks upstream HEAD.
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 -
Stance: install nothing we don't need. The starting point is the absolute minimum that lets build.sh get past cmake and ./configure; everything else is added one package at a time, only when a build or runtime failure surfaces it. The gershwin desktop's Bootstrap.sh 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.
buildpkgs.txt — what Phase 2 shippedcmake
ninja
gmake
autoconf
libtool
pkgconf
clang/clang++ ship in FreeBSD base, so they aren't listed. git is intentionally absent: all git clones happen on the host before the chroot exists; the chroot is git-free. pkgconf was added when libs-base configure couldn't find ICU — it queries via pkg-config icu-i18n / icu-uc. automake, llvm still left out.
pkglist.txt — what Phase 2 shippedlibxml2
icu
Both are runtime deps of libgnustep-base.so:
libxml2 — libs-base XML branch (GSXML / NSXMLNode / XML NSPropertyListSerialization). Configure has a --disable-xml flag but we keep XML on; binary plists are not the only format we need to read.icu — libs-base unicode (NSLocale / NSString). Hard dep, no --disable flag.Surprises during Phase 2 surfacing:
libffi — predicted as a likely candidate, but already supplied by FreeBSD base. Not added.libgnutls — also predicted; we took the --disable-tls path (libs-base configure flag, see §8.4) instead. Not added.Things from the gershwin Bootstrap.sh we explicitly do not add unless something forces our hand:
libxslt — XSLT transforms only; we parse plists.gnutls / openssl — TLS in NSURLConnection; launchd doesn't speak TLS. Configure libs-base with --disable-tls if it has the flag; if not, deal with it then.mDNSResponder, dbus, cups, wget — no Bonjour, no D-Bus, no printing, no fetching in-chroot.squashfs-tools-ng, fusefs-squashfuse — we use mkuzip + geom_uzip + in-kernel unionfs, not squashfs.libvncserver, freerdp — remote desktop, not in scope.tiff, png, giflib, ImageMagick7, cairo, libXft, libXt, libxcb, xcb-util-*, xrandr, libXrandr, libXcomposite, xorg-fonts-truetype, freeglut — all GUI/image/X11. We have no display server.flite, portaudio, libao — audio. Not on a server.The pattern: every package added to either list gets a one-line commit message naming the build step that demanded it. Future readers (and future-you) can then audit whether that demand still holds when the upstream changes.
build.sh (livecd) and make-launchd.sh (reusable)Everything livecd-specific lives in build.sh: the git clones, the pkgbase extraction, the chroot setup, the GNUstep-library builds, the slim pass, the mkuzip, the cd9660 wrap. make-launchd.sh 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.
| Script | Runs where | Standalone? | Purpose |
|---|---|---|---|
build.sh | Host (FreeBSD VM) | (it is the livecd builder) | Inline git-clone of the 6 upstreams (5 GNUstep + Tessil/robin-map) into repos/. 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 → . /System/Library/Makefiles/GNUstep.sh → libobjc2 → libs-base → libs-corebase), then call make-launchd.sh chrooted, purge buildpkgs, slim, mkuzip, cd9660. |
make-launchd.sh | Chroot or live host | Yes — designed for this | cd into src/, build launchd against /System/Library/, install launchd + launchctl to /sbin/, install plists from plists/ to /System/Library/LaunchDaemons/. Hard-checks for /System/Library/Libraries/libdispatch.so and libgnustep-base.so before doing anything; exits with a clear error if they're missing. |
tests/boot-test.sh | Host (CI Linux) | Yes | qemu+expect smoke test against an ISO path passed as $1. |
The whole reason for splitting make-launchd.sh 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 /System/Library/ via the upstream gershwin-developer meta-installer. It does not have launchd. To add launchd, that ISO builder just does:
# 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"
That's the entire integration. 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 build.sh calls make-launchd.sh in its chroot — same script, same behavior, no code duplication.
make-launchd.sh takes one optional argument: --prefix (default /). All paths are resolved relative to that prefix, so the same script works for chroot installs (--prefix=/path/to/chroot) and live-system installs (--prefix=/). Settle the exact CLI in Phase 3 when the script first goes in.
Lifted directly from freebsd-livecd-unionfs. The skeleton — pkgbase extraction, chroot mount, slim, mkuzip, hybrid cd9660 — is unchanged. We add two new chroot stages between "pkg install" and "slim".
base.txz contains the full base userland; buildpkgs.txt only adds cmake/ninja/clang-extras/git that aren't in base.Single-stage shebang chain. loader.conf sets
init_path="/init.sh:/rescue/init". The kernel hits the
#!/rescue/sh shebang at the top of /init.sh
(handled unconditionally by imgact_shell — see §9.2.1)
and exec's /rescue/sh as PID 1 with /init.sh
as argv[1]. The script then:
devfs at /dev and reopens stdio
from /dev/console (kernel hands PID 1 with
fds 0/1/2 closed).mdconfig + mount -t ufs/tmpfs/unionfs
to layer the writable upper over the read-only uzip lower at
/sysroot.exec /rescue/chroot /sysroot /sbin/launchd — every
exec preserves the same PID, so launchd inherits as
the original PID 1 the kernel created.The colon-fallback to /rescue/init is for the case
where the kernel rejects /init.sh for any reason. In that
fallback, /rescue/init is PID 1, reads
init_script=/init.sh kenv, and invokes the same script
as a child. The script detects $$ != 1 and uses the
classic kenv init_chroot=/sysroot + exit pattern,
falling back to FreeBSD's normal init flow (no launchd).
An earlier draft of this section proposed setting
kenv init_path=/sbin/launchd in the script and relying
on init.c to re-read it after the init_chroot pivot.
Source review settled this: FreeBSD's
/sbin/init (and /rescue/init, which is the
same code, just crunchgen'd) reads init_path exactly
once at startup (sbin/init/init.c:245), and only re-checks
it on the reroot recovery path
(init.c:814) — never after the
init_chroot pivot in the normal boot flow. The only
re-exec hook in init.c is the init_exec kenv at
init.c:321, fired before any userland code runs
(too early to be useful for our pivot).
The shebang path bypasses init.c entirely. Verified by reading
sys/kern/init_main.c +
sys/kern/imgact_shell.c: start_init()
calls kern_execve() with no PID-1-special-case (line 797);
do_execve() iterates the imgact chain unconditionally
(kern_exec.c:664-668); imgact_shell.c:108-110
has no guards on p->p_pid or P_SYSTEM. The
shebang fires for the very first userspace exec exactly as it does
for any other.
Caveats. Shell-script-as-init is undocumented in
FreeBSD — no init(8) mention, no shipped
init_path default uses it, no test in
tests/sys/kern/ 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.
Three GitHub Actions jobs. Pattern lifted from freebsd-livecd-unionfs/.github/workflows/build.yml.
| Job | Runner | What it does | Gate |
|---|---|---|---|
build |
ubuntu-latest hosting vmactions/freebsd-vm@v1 (FreeBSD 15.0, 8 GB RAM, 4 CPU) |
Disk-space cleanup, restore distfiles cache (keyed on pkglist.txt + buildpkgs.txt), sh build.sh, copy out/ back to host, upload livecd.iso + SHA256SUMS as artifact. |
Exit 0 from build.sh |
test |
ubuntu-latest | apt-get install qemu-system-x86 expect ovmf; download artifact; run tests/boot-test.sh out/livecd.iso; on failure upload tests/boot.log. |
Both serial markers seen within 8 min |
release |
ubuntu-latest | needs: [build, test] + if: github.ref == 'refs/heads/main' && github.event_name == 'push'. Renames ISO to FreeBSD-15.0-amd64-launchd-YYYYMMDD.iso + .sha256; gh release delete continuous --yes --cleanup-tag; softprops/action-gh-release@v2 re-publishes with tag_name: continuous, prerelease: true. |
Both prior jobs green |
The current livecd-unionfs test waits for one marker: login: on serial. We extend to two:
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" }
}
launchd: PID 1 ready is logged by our launchd.c after directory scan completes and the AF_UNIX server is accepting. Two markers means: the binary is PID 1 AND launchd's job model actually came up enough to spawn getty. Either marker missing fails the release gate — no broken ISO ships.
FreeBSD rc.d uses rcorder(8) to topologically sort scripts by their # PROVIDE/# REQUIRE/# BEFORE headers. launchd philosophically rejects this in favor of runtime dependency — a job blocks on the resource it needs and launchd starts the provider on demand.
Daemons that listen on sockets — sshd, rpcbind, mountd, syslogd — declare their listen sockets in their plist's Sockets 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 connect()s; the kernel queues the connection until the daemon answers. Eliminates ordering for socket clients: nfsd calling rpcbind via the loopback socket Just Works regardless of start order.
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:
| Phase job label | Does | Stamp file |
|---|---|---|
org.freebsd.phase.kld-loaded | kldload everything in kld_list | /var/run/.phase.kld-loaded |
org.freebsd.phase.filesystems-ready | fsck -p + mount -a -t nonfs,nullfs + swapon -a | /var/run/.phase.filesystems-ready |
org.freebsd.phase.network-ready | Touch the file once PF_ROUTE reports RTM_NEWADDR for any non-loopback iface | /var/run/.phase.network-ready |
Narrow extension to the plist schema: a RequiresPhase array key the bootstrapper resolves at load time. Daemons in WAITING 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.
devd is specialdevd must run before netif because NIC IFATTACH events flow through it. Plan: a LaunchDaemons.early/ directory the bootstrapper loads before the main scan. Keeps the launchd binary simple and the early tier declarative.
rc.confEvery per-host configuration concern — hostname, interface IP, default route, WiFi credentials, daemon arguments — is expressed as a plist, not as a key in /etc/rc.conf, and not as a single-purpose file like /etc/hostname. There is no rc.conf parsing layer in this project. Users edit the plist directly (or, more commonly, edit it via GUI tooling that manipulates the plist).
Path conventions:
/System/Library/LaunchDaemons/ — project-shipped plists (getty, devd, syslogd, etc.). Untouched by users./Local/Library/LaunchDaemons/ — admin-installed and user-edited plists (hostname, per-iface networking, custom services). The "third-party" tier in the gershwin layout.Concrete examples of the per-component pattern:
| Concern | Plist file | What runs |
|---|---|---|
| Hostname | /Local/Library/LaunchDaemons/org.freebsd.hostname.plist | /bin/hostname my-host (the string is in the plist's ProgramArguments) |
| Static IP on em0 | /Local/Library/LaunchDaemons/org.freebsd.netif.em0.plist | /sbin/ifconfig em0 inet 192.168.1.10/24 up |
| DHCP on em0 | /Local/Library/LaunchDaemons/org.freebsd.dhclient.em0.plist | /sbin/dhclient -d em0 (KeepAlive=true) |
| WiFi on wlan0 | /Local/Library/LaunchDaemons/org.freebsd.wpa.wlan0.plist | /usr/sbin/wpa_supplicant -i wlan0 -c /etc/wpa_supplicant.conf (KeepAlive=true) |
| Default route | /Local/Library/LaunchDaemons/org.freebsd.routes.plist | /sbin/route add default 192.168.1.1 |
Why no rc.conf parser: rc.conf has 1000+ knobs accumulated over 30 years. Supporting "some" of them creates a confusing partial-compatibility layer. A plist's ProgramArguments 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 ifconfig_em0= in rc.conf goes into ProgramArguments verbatim.
Why no /etc/hostname shortcut: consistency. Every other concern is a plist; hostname becoming the lone single-line file would be the inconsistent choice.
WiFi keeps /etc/wpa_supplicant.conf because it's wpa_supplicant's own configuration file (we'd be reinventing the wheel to replace it). The plist just invokes wpa_supplicant; configuration of the WiFi credentials themselves stays in the file format the tool already reads.
Smallest possible scope that proves launchd is PID 1: no plists, just the daemon itself + getty via the existing /etc/ttys path. The boot test watches for launchd: PID 1 ready and login: on serial.
| Service | Plist label | Notes |
|---|---|---|
| getty | — | Not a launchd job in this milestone. init handoff path in launchd.c reads /etc/ttys and execs getty per line. Same code path Apple's launchd has had since 10.4. |
This is the proof-of-life milestone for the supervisor. Pick one daemon, give it a plist, watch launchd manage it. sshd 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.
Bring-up set for this milestone:
| Service | Plist label | Notes |
|---|---|---|
netif (one-shot) | org.freebsd.netif | One-shot: ifconfig interfaces up from rc.conf. Touches /var/run/.phase.netif-up. |
dhclient (one iface for now) | org.freebsd.dhclient.<iface> | Single hand-written plist for the primary iface. Per-iface dynamic generation deferred to milestone 3. KeepAlive=true. RequiresPhase=[netif-up]. |
sshd | org.freebsd.sshd | Socket-activated on TCP/22; inetdCompatibility={Wait:true}. RequiresPhase=[network-ready]. The headline test target. |
| Phase: netif-up | org.freebsd.phase.netif-up | Touched by netif. |
| Phase: network-ready | org.freebsd.phase.network-ready | Long-running watcher; touches stamp on first RTM_NEWADDR. |
Boot-test extension: third marker watches for sshd listening (e.g. sshd: Server listening on 0.0.0.0 port 22). Gate the release on it.
Once milestone 2 is green, fill in plists for the rest of the FreeBSD base service set. These ship in the repo so users can launchctl load what they want; they don't gate the release. The ISO doesn't have to start nfsd to pass CI — it just has to ship a working plist for users who do want NFS.
| Service | Plist label | Goal-essential? | Notes |
|---|---|---|---|
devd | org.freebsd.devd | Yes — most systems want it | Early-tier; loads before main scan. KeepAlive=true. |
syslogd | org.freebsd.syslogd | Yes | Socket-activated on /var/run/log; KeepAlive=true. |
cron | org.freebsd.cron | Yes | cron -s (no fork); KeepAlive=true. |
wpa_supplicant (per iface) | org.freebsd.wpa_supplicant.<iface> | Opt-in (wireless only) | Per-wireless-interface; generated dynamically. KeepAlive=true. RequiresPhase=[netif-up]. |
dhclient (per iface, dynamic) | org.freebsd.dhclient.<iface> | Yes | Replaces the milestone-2 hand-written plist with the dynamic per-iface generation pattern from §12.6. |
rpcbind | org.freebsd.rpcbind | Ship plist; not goal-essential | KeepAlive=true; needed only if user opts into NFS. RequiresPhase=[network-ready]. |
mountd | org.freebsd.mountd | Ship plist; not goal-essential | KeepAlive=true; WatchPaths=[/etc/exports, /etc/zfs/exports] for live reload. |
nfsd | org.freebsd.nfsd | Ship plist; not goal-essential | KeepAlive=true; RequiresPhase=[network-ready]. |
nfsclient | org.freebsd.nfsclient | Ship plist; not goal-essential | One-shot: kldload nfscl + vfs.nfs.* sysctls. |
| Phase: kld-loaded | org.freebsd.phase.kld-loaded | Yes | One-shot. |
| Phase: filesystems-ready | org.freebsd.phase.filesystems-ready | Yes | One-shot. |
org.freebsd.sshd.plist<?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>
apm, apmd, powerd, bluetooth, hcsecd, sdpd, watchdogdsendmail, sendmail_submit, amd, ntpdate, ftpd, tftpd, inetd (launchd is the inetd replacement), ypbind/ypserv/yppasswdd (NIS, dead)accounting, bsnmpd, hastd, ctld, iscsid, nfscbd, gssdpf); skip ipfw/ipfw_netflow/pflog/pfsync until requestedlocal_unbound, NSS caches — not until requestedSingle most important translation rule: anything rc.d does that isn't "run a long-lived daemon" becomes a one-shot plist with RunAtLoad=true, KeepAlive=false, LaunchOnlyOnce=true. Examples: sysctl from /etc/sysctl.conf, kldload from kld_list, fsck -p + mount -a, swapon -a, /var/run + /tmp cleanup. Live config-file reload (service foo reload) is a strict improvement in launchd via WatchPaths / QueueDirectories.
Concrete scenario: sshd ships disabled by default. Admin enables it on a freshly-installed system. How?
The shipped plist at /System/Library/LaunchDaemons/org.freebsd.sshd.plist contains:
<key>Disabled</key>
<true/>
At every boot, launchd scans, sees the key, skips the job. The shipped plist is never edited by users or admins — it stays bit-for-bit stable across upgrades.
State changes go through one of three paths:
| Method | Persistence | How |
|---|---|---|
launchctl load -w (CLI, persistent) | Survives reboots | sudo launchctl load -w /System/Library/LaunchDaemons/org.freebsd.sshd.plist |
launchctl load (CLI, one-shot) | Lost on reboot | sudo launchctl load /System/Library/LaunchDaemons/org.freebsd.sshd.plist |
| GUI toggle (e.g., gershwin "Sharing" pane) | Survives reboots | GUI talks to launchd over IPC, flips the same persistent state the -w flag does. |
The persistence trick is a separate overrides file:
/var/db/launchd.db/com.apple.launchd/overrides.plist/var/db/com.apple.xpc.launchd/disabled.plist/var/db/org.freebsd.launchd/overrides.plistAt boot, launchd merges (a) the shipped plist's Disabled key (default state) with (b) the overrides file (user's persistent intent). The overrides file is a plist of {label: bool} entries — only present when a user has expressed an opt-in or opt-out for a specific job.
Modern launchctl verbs (macOS 10.10+) make this explicit:
sudo launchctl enable system/org.freebsd.sshd # set persistent "enabled"
sudo launchctl disable system/org.freebsd.sshd # set persistent "disabled"
sudo launchctl bootstrap system /System/Library/LaunchDaemons/org.freebsd.sshd.plist
sudo launchctl bootout system/org.freebsd.sshd
| Capability | Status |
|---|---|
Disabled key parsed at scan time | probably partial — needs core.m audit |
| Overrides-file mechanism | NOT implemented — launchd doesn't read or merge any override file today |
launchctl load <path> / unload <path> | basic — works in the vendored 178-line launchctl.c |
launchctl load -w / persistent flag | NOT implemented |
launchctl enable / disable (modern verb) | NOT implemented |
launchctl start <label> / stop <label> | probably NOT implemented — no manual one-shot start |
~250 LOC of focused launchd surgery, lands alongside the EnvironmentVariables / StartCalendarInterval / etc. work in §6.3:
core.m (~150 LOC): at plist-scan time, read /var/db/org.freebsd.launchd/overrides.plist. For each scanned job, merge shipped.Disabled XOR override[label]. Apply the merged value. Add a vnode source on the overrides file so changes flush to running state without a daemon restart.launchctl.c (~80 LOC): implement the -w flag and the modern enable / disable verbs. They write to the overrides file via launchd's IPC (or directly with file lock + send a "reload" IPC).ipc.c (~30 LOC): new IPC verbs for "set persistent enable", "set persistent disable", "manual start", "manual stop".mkdir -p /var/db/org.freebsd.launchd so the overrides file has a home.For the immediate sshd milestone (Phase 4 in §14), two options:
org.freebsd.sshd.plist with RunAtLoad=true, no Disabled key. Fastest to the proof-of-life milestone. Acceptable for a livecd; less acceptable for a secure-server posture.sudo launchctl load -w /System/Library/LaunchDaemons/org.freebsd.sshd.plist to enable. The right Apple-shaped answer. Adds ~250 LOC to Phase 4's scope.Recommendation: A for Phase 4 (get configd + sshd-as-supervised-daemon working end-to-end), B as a focused Phase 5 commit before we'd consider the system installable. Live ISO with sshd-on-by-default is fine — every boot has the same default-password posture, and that's already accepted.
The repo is a composition of three source families. They coexist cleanly under a single top-level LICENSE with per-file headers preserved — no dual-licensing needed. Top-level: BSD-2-Clause (matches the FreeBSD ecosystem and your other repos; aligns with FreeBSD base itself).
| Source | License | How we handle it in-tree |
|---|---|---|
Apple launchd-842.1.4 (@APPLE_APACHE_LICENSE_HEADER_START@ in every file) | Apache 2.0 | Keep the Apple header verbatim. Our edits to those files inherit Apache 2.0 via inbound=outbound — those individual files stay Apache regardless of top-level. |
Files lifted from freebsd-livecd-unionfs (build.sh, tests/boot-test.sh, ramdisk/init.sh, the workflow YAML) | BSD-2-Clause | Your own code. Keep the BSD-2-Clause header from the source repo. Same license as top-level, no friction. |
swift-corelibs-libdispatch (linked, not in tree) | Apache 2.0 with Runtime Library Exception | Listed in NOTICE. Runtime exception means linking against it doesn't impose Apache on the linker. |
| GNUstep base / corebase / libobjc2 (linked, not in tree) | LGPL 2.1+ / LGPL 3 (with linking exception) / MIT (libobjc2) | Listed in NOTICE. Linking exception lets PID 1 link Foundation without itself becoming LGPL. Per LGPL §6, ISO release notes link to the upstream commit the build was made from. |
This repo's new code (checkout.sh, plists, our launchd rewrite hunks, compat/) | BSD-2-Clause | Each new file gets a BSD-2-Clause SPDX header. Matches the top-level and the FreeBSD norm. |
freebsd-livecd-unionfs is BSD-2-Clause; a FreeBSD sysadmin opening the repo expects BSD-2-Clause. Apache 2.0 would be a momentary surprise.@APPLE_APACHE_LICENSE_HEADER_START@ blocks stay on every Apple file regardless. Top-level license only governs files we ourselves author.NOTICE requirement, no §4(d) ceremony. Cleaner.Real-world precedent: FreeBSD base itself contains Apache-licensed code (e.g. portions of OpenJDK) under a BSD-2-Clause-licensed project umbrella. Apache files keep their headers; the project is BSD. Same pattern as here.
Dual licensing (e.g. "BSD-2-Clause OR Apache-2.0") makes sense when consumers need to pick either license at use time — typical for libraries that want to be drop-in for both GPL and permissive consumers. Nothing here is that case: consumers download the ISO, they don't relink against our .sos. Dual-licensing the top-level adds friction (every NOTICE-equivalent, every contribution, every dependency review considers two licenses) for zero downstream benefit.
We will modify Apple files extensively (gut Mach paths, rewrite core.c, etc.). Apache 2.0 explicitly permits this. The compliance checklist per Apache 2.0 §4:
@APPLE_APACHE_LICENSE_HEADER_START@ through @APPLE_APACHE_LICENSE_HEADER_END@ block stays at the top of the file, even after we delete 80% of the body./*
* Modified 2026 by Joe Maloney for the freebsd-launchd project.
* SPDX-License-Identifier: Apache-2.0 (this file remains Apache 2.0)
*/
The SPDX line clarifies for tooling that this individual file stays Apache even though the project top-level is BSD-2-Clause.
NOTICE file at repo root. Apple's launchd source ships with a NOTICE requirement. Even though BSD-2-Clause itself doesn't require one, we still ship a NOTICE at the repo root because some Apache files in our tree carry that requirement. Listing what's in there:
freebsd-launchd
Copyright 2026 Joe Maloney and contributors
Licensed under BSD-2-Clause (see LICENSE).
This product includes software developed by:
- Apple Inc. (launchd, 2005-2012, Apache 2.0)
https://github.com/apple-oss-distributions/launchd
Modified by freebsd-launchd contributors; see git log.
- Apple Inc. and the Swift project authors
(swift-corelibs-libdispatch, Apache 2.0 + Runtime Library Exception)
Linked at runtime; not included in this source tree.
- The GNUstep Project
(libs-base, libs-corebase, LGPL 2.1+ / 3 with linking exception)
Linked at runtime; not included in this source tree.
- The libobjc2 contributors
(libobjc2, MIT) — linked at runtime; not in source tree.
/*
* Copyright 2026 Joe Maloney and freebsd-launchd contributors
* SPDX-License-Identifier: BSD-2-Clause
*/
None of this is novel — every Apache-derived BSD project (and there are many in the FreeBSD world) follows this pattern. A reader can run find . -name '*.[ch]' | xargs grep -l SPDX-License-Identifier and immediately see which files are Apache and which are BSD.
The ISO ships libgnustep-base.so and libgnustep-corebase.so (LGPL 2.1+ / LGPL 3 with linking exception) as built binaries. Per LGPL §6, recipients must be able to obtain the corresponding source. Two ways to satisfy this:
/System/Library/Source/ in the ISO. Self-contained, large.Plan goes with option 2 — see Q5 in §15.
pkgdemon/freebsd-launchd, add LICENSE (BSD-2-Clause), NOTICE, README.md, .gitignore, link to this published plan.freebsd-livecd-unionfs: boot/loader.conf, ramdisk/init.sh, tests/boot-test.sh, .github/workflows/build.yml, build.sh.build.sh: rename labels from "unionfs" to "launchd", bump artifact filename pattern, update tag_name body text. Build still produces an ISO identical to livecd-unionfs at this stage — proves the pipeline works in the new repo.login:, published as continuous.scripts/import-source.sh clones apple-oss-distributions/launchd at tag matching launchd-842.1.4, strips .git, drops it under src/.build.shbuild.sh (per §8.6).repos/ to .gitignore.buildpkgs.txt (cmake, ninja, gmake, autoconf, libtool — no git; chroot stays git-free).build.sh with the chroot-side rsync of repos/ in, the libobjc2 robinmap sed-patch (§8.3), and the build invocations from §8.4 (libdispatch → tools-make → source GNUstep.sh → libobjc2 → libs-base → libs-corebase).build.sh with the buildpkgs purge step.etc/ld-elf.so.conf.d/system.conf.login: only — the GNUstep libs are present but nothing in the ISO uses them yet. Confirms the install didn't break boot.runtime.c rewrite on libdispatch; launchd.c minimal main; foreground daemon with one preloaded plist.ipc.c AF_UNIX server; launchctl list/load/unload against a running daemon.core.[cm] job model with KeepAlive, Throttle, Sockets, WatchPaths, RequiresPhase./etc/ttys, SIGCHLD reaping for orphans, shutdown handling.make-launchd.sh; extend build.sh to call it after the GNUstep stage.boot/loader.conf with init_path=/sbin/launchd.launchd: PID 1 ready marker. Release gate is now real.netif one-shot, hand-written dhclient plist for the primary iface, phase.network-ready watcher.sshd plist (sketched in §12.4); ship it in /System/Library/LaunchDaemons/ in the ISO.sshd: Server listening. Bonus: extend the test runner to ssh root@<guest-ip> after the marker fires and gate on a successful command exit.dhclient plist generator (replacing the milestone-2 hand-written one).launchctl load.RequiresPhase as a schema extension. No precedent in Apple's launchd; we're adding a new plist key. Risk: anyone porting our plists back to macOS gets a warning. Acceptable cost — we're not pretending to be macOS — but worth documenting in launchd.plist(5).
devd early-tier mechanism. Plan picks a LaunchDaemons.early/ directory the bootstrapper scans first. Alternative: a --early-daemon CLI flag on launchd itself. Settle in Phase 3e.
init_path kenv re-read after chroot. — SETTLED. FreeBSD's init does not re-read init_path after the init_chroot pivot — read once at init.c:245, only re-checked on the reroot recovery path (init.c:814). Two FreeBSD-source-reading agents confirmed independently. We sidestep init.c entirely via shebang-as-init: the kernel's imgact_shell handles #!/rescue/sh in /init.sh for the first userspace exec just like any other, with no PID-1-special-case in start_init() or do_execve(). See §9.2 + §9.2.1 for the full mechanism.
rc.d? Likely answer: keep /etc/rc intact on disk and let the bootloader pick PID 1 via the kernel init_path envar. Document in the migration guide once we leave livecd-only territory.
/System/Library/Source/". Confirm with whoever cares about strict LGPL §6 compliance before tagging an actual release (vs the continuous rolling release).
build.sh, tests/boot-test.sh, .github/workflows/build.yml all lifted from here.launchd-842.1.4).devel/libdispatch port (reference for the kevent patch state): freshports.org/devel/libdispatch.hier(7): man.freebsd.org/hier(7).rc(8), rc.subr(8), rcorder(8): man.freebsd.org/rc.subr(8).Plan v0 — generated 2026-05-04. This is a living document; updates land here before any code commits.