FreeBSD launchd — porting plan

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.

Status: planning v0

1. Goal & non-goals

1.1 Goal

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.

1.2 Non-goals (this iteration)

2. Repository — single repo, single pipeline

One repo: pkgdemon/freebsd-launchd. It contains:

  1. The Apple launchd-842.1.4 source (one-time fork, Mach paths gutted) under src/.
  2. Our rc.d-replacement plists under plists/.
  3. A 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.
  4. A tests/boot-test.sh that boots the ISO under qemu+OVMF and watches serial for launchd-emitted markers.
  5. A GitHub Actions workflow that runs build → test → release on every push.

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.

2.1 Top-level layout

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

3. Architecture

+----------------------------------------------+ | 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 | +------+------+ +-------+------+ +--------+---------+

3.1 Why this works on FreeBSD specifically

One real subtlety: the SIGCHLD disposition inside launchd must be a no-op handler, not 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.

4. Install paths

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).

ArtifactPathWhy
launchd binary/sbin/launchdPID 1 must live on the root partition; matches init's location.
launchctl binary/sbin/launchctlNeeded during single-user before /usr/local mounts; matches service(8).
wait4path helper/usr/bin/wait4pathNon-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/sockMatches 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.

5. Locked architectural decisions

DecisionChoice
Target kernelFreeBSD 14.x and 15.x. No Linux, no NetBSD, no portability gates.
Binary formatELF (FreeBSD native). No Mach-O, no dyld.
Toolchainclang + lld + llvm-ar (FreeBSD base). Never gcc.
Mach IPCNone. All <mach/...>, MIG .defs deleted.
XPCNone. libxpc not in tree.
Service IPCAF_UNIX socket activation via launchd's Sockets plist key. Out-of-band fd passing via SCM_RIGHTS.
Init / PID 1This launchd. Replaces /sbin/init's rc-chain via init_path kenv.
Event looplibdispatch dispatch sources only.
libdispatch sourceapple/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 parsingGNUstep NSPropertyListSerialization from libgnustep-base. Handles XML and binary plists.
Foundation in PID 1Yes. macOS does it; cost ~10 MB linked, payable once.
Where the GNUstep libs come frombuild.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 platformFreeBSD only, inside vmactions/freebsd-vm@v1. No macOS, no Linux. Mac is edit-only.
Release artifactA bootable hybrid BIOS+UEFI cd9660 ISO with launchd as PID 1, published as continuous on every push to main.
Release gateBoot test must observe both launchd: PID 1 ready and login: on serial within 8 minutes.

6. File-by-file plan (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.

6.1 Deleted on import (Mach-only)

6.2 Retained — Phase 2 fate

FileApple LOCMach refsActionTarget LOC
src/launchd.c~6601Prune. 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.c1,45385Rewrite from scratch on libdispatch.~600
src/ipc.c537?Rewrite (small). Replace kevent_mod with DISPATCH_SOURCE_TYPE_READ; drop the MachServices checkin branch.~500
src/core.[cm]11,973207Rewrite 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-6Prune mach_error_string. Keep syslog buffer, console fallback, level mask.~300
liblaunch/liblaunch.c8Prune Mach refs (8 spots). Keep launch_data_pack/unpack, launchd_msg_send/recv wire-format code.~1200
liblaunch/launch.h et alfewDrop LAUNCH_DATA_MACHPORT and the three accessors.~250
liblaunch/libvproc.c1,06142Delete entirely. No Mach, no ports to pass.0
support/launchctl.c4,54921Substantial rewrite. Drop bsexec, bslist, bstree, bootstrap, asuser. Keep load, unload, start, stop, list, submit, getenv, etc. Plist reading via NSPropertyListSerialization.~1500
support/wait4path.c0Keep as is.unchanged
man/, rc/0Keep selectively. rc.netboot gone.shrinks

Total post-Phase-2: roughly 7-8k LOC vs Apple's ~22k pre-amputation. About 65% deletion.

6.3 Plist-key support — current state vs Apple's full surface

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 keyStatusNotes
LabelimplementedJob identifier; required.
ProgramArgumentsimplementedargv to posix_spawn.
RunAtLoadimplementedSpawn at scan time. Verified by every shipped plist.
KeepAliveimplementedRespawn on exit. Verified by getty + syslogd surviving across login sessions.
Socketsimplementedlaunchd opens AF_UNIX listener, hands fd via env. Verified by the IPC socket at /var/run/launchd/sock.
inetdCompatibility (Wait subkey)partialMinimal sshd-shape works; verify edge cases when sshd lands in Phase 4.
EnvironmentVariablesNOT implementedcore.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).
StartCalendarIntervalNOT implementedApple'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.
WorkingDirectoryunverified, likely notShould chdir before exec. Add when a daemon needs it.
UserName / GroupNameunverified, likely notDrop privileges before exec. Will be needed when sshd plist lands in Phase 4.
StandardOutPath / StandardErrorPathunverified, likely notRedirect spawned-child stdio. Add when a per-job log file is wanted.
Umaskunverified, likely notPer-job umask before exec.
ThrottleIntervalunverified, likely notKeepAlive currently respawns immediately on exit; no rate-limit. Add when a flapping daemon makes us want backoff.
Disabledunverified — likely partialPer-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 implementedProject-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):

  1. Read the key from the plist's NSDictionary at job-load time.
  2. Validate type (string vs dict vs array vs integer).
  3. Apply at the appropriate point: env-build for 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.

7. FreeBSD-only wins (vs a portable BSD+Linux design)

FeaturePortable BSD+Linux approachThis repo (FreeBSD-only)
Per-pid child deathSIGCHLD source + waitpid(WNOHANG) drain + pid hashDISPATCH_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 branchesLOCAL_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-onlyO_EVTONLY shim (Darwin-only)O_RDONLY works directly
Build-system gatesPervasive #ifdef __FreeBSD__ / __linux__Delete them all

8. Dependencies — built in-chroot, no submodules

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.

8.1 Host-side clone, chroot-side build — both in build.sh

Rule lifted from gershwin-on-freebsd: all git operations happen on the host, the chroot stays git-free. build.sh handles both halves inline:

  1. Host stage (early in 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.
  2. Chroot stage: extract pkgbase, mount the chroot, 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.
  3. launchd stage: after the GNUstep libs are installed, 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.

8.2 Upstream URLs & build order

OrderUpstreamBuild systemInstalls to
1apple/swift-corelibs-libdispatchcmake/System/Library/Libraries/libdispatch.so + headers in /System/Library/Headers/{dispatch,os}/
2gnustep/tools-makeautoconf/System/Library/Makefiles/ + /System/Library/Preferences/GNUstep.conf
3gnustep/libobjc2cmake/System/Library/Libraries/libobjc.so
4gnustep/libs-basegnustep-make/System/Library/Libraries/libgnustep-base.so
5gnustep/libs-corebasegnustep-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.

8.3 Upstream patches

libdispatch — none, for now

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 — robinmap FetchContent → SOURCE_DIR

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:

  1. Add Tessil/robin-map to the host clone loop (§8.6) as a sibling to libobjc2.
  2. After rsyncing 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.

8.4 The exact build invocations

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.

libdispatch

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

tools-make

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

Source GNUstep.sh after tools-make 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

libobjc2

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

libs-base (Foundation)

--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

libs-corebase (CoreFoundation)

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

8.5 Runtime linker

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.

BlocksRuntime sourcing. The libobjc2 invocation expects /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/).

8.6 The clone loop inside build.sh

The 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 -

8.7 Package lists — start minimal, add when something breaks

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 shipped

cmake
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 shipped

libxml2
icu

Both are runtime deps of libgnustep-base.so:

Surprises during Phase 2 surfacing:

Things from the gershwin Bootstrap.sh we explicitly do not add unless something forces our hand:

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.

8.8 Two scripts: 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.

ScriptRuns whereStandalone?Purpose
build.shHost (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.shChroot or live hostYes — designed for thiscd 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.shHost (CI Linux)Yesqemu+expect smoke test against an ISO path passed as $1.

The gershwin-on-freebsd integration story

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.

Standalone-script contract. 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.

9. The livecd build pipeline

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".

+-------------------------------------------+ | 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 | +-------------------------------------------+

9.1 Why build inside the chroot, not on the host VM?

9.2 PID 1 handoff

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:

  1. Mounts devfs at /dev and reopens stdio from /dev/console (kernel hands PID 1 with fds 0/1/2 closed).
  2. mdconfig + mount -t ufs/tmpfs/unionfs to layer the writable upper over the read-only uzip lower at /sysroot.
  3. 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).

9.2.1 Why we don't go through init.c

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.

10. CI & release pipeline

Three GitHub Actions jobs. Pattern lifted from freebsd-livecd-unionfs/.github/workflows/build.yml.

JobRunnerWhat it doesGate
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

10.1 Boot-test marker set

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.

11. Boot flow without rcorder

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.

11.1 Socket-activated daemons (the easy 80%)

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.

11.2 Phase no-op jobs (the awkward 15%)

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 labelDoesStamp file
org.freebsd.phase.kld-loadedkldload everything in kld_list/var/run/.phase.kld-loaded
org.freebsd.phase.filesystems-readyfsck -p + mount -a -t nonfs,nullfs + swapon -a/var/run/.phase.filesystems-ready
org.freebsd.phase.network-readyTouch the file once PF_ROUTE reports RTM_NEWADDR for any non-loopback iface/var/run/.phase.network-ready

11.3 Explicit ordering (the last 5%)

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.

11.4 devd is special

devd 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.

12. rc.d port scope

12.0 Configuration mechanism — plists, not rc.conf

Every 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:

Concrete examples of the per-component pattern:

ConcernPlist fileWhat 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.

12.1 Milestone 1 — launchd boots, console login

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.

ServicePlist labelNotes
gettyNot 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.

12.2 Milestone 2 — sshd as the first launchd-supervised daemon

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:

ServicePlist labelNotes
netif (one-shot)org.freebsd.netifOne-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].
sshdorg.freebsd.sshdSocket-activated on TCP/22; inetdCompatibility={Wait:true}. RequiresPhase=[network-ready]. The headline test target.
Phase: netif-uporg.freebsd.phase.netif-upTouched by netif.
Phase: network-readyorg.freebsd.phase.network-readyLong-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.

12.3 Milestone 3 — remaining base services (shipped, not goal-gating)

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.

ServicePlist labelGoal-essential?Notes
devdorg.freebsd.devdYes — most systems want itEarly-tier; loads before main scan. KeepAlive=true.
syslogdorg.freebsd.syslogdYesSocket-activated on /var/run/log; KeepAlive=true.
cronorg.freebsd.cronYescron -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>YesReplaces the milestone-2 hand-written plist with the dynamic per-iface generation pattern from §12.6.
rpcbindorg.freebsd.rpcbindShip plist; not goal-essentialKeepAlive=true; needed only if user opts into NFS. RequiresPhase=[network-ready].
mountdorg.freebsd.mountdShip plist; not goal-essentialKeepAlive=true; WatchPaths=[/etc/exports, /etc/zfs/exports] for live reload.
nfsdorg.freebsd.nfsdShip plist; not goal-essentialKeepAlive=true; RequiresPhase=[network-ready].
nfsclientorg.freebsd.nfsclientShip plist; not goal-essentialOne-shot: kldload nfscl + vfs.nfs.* sysctls.
Phase: kld-loadedorg.freebsd.phase.kld-loadedYesOne-shot.
Phase: filesystems-readyorg.freebsd.phase.filesystems-readyYesOne-shot.

12.4 Sketch: 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>

12.5 Out of scope (won't port — server scope)

12.6 The "anything else becomes a one-shot" rule

Single 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.

12.7 Service enable / disable UX

Concrete scenario: sshd ships disabled by default. Admin enables it on a freshly-installed system. How?

Apple's model (the target)

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:

MethodPersistenceHow
launchctl load -w (CLI, persistent)Survives rebootssudo launchctl load -w /System/Library/LaunchDaemons/org.freebsd.sshd.plist
launchctl load (CLI, one-shot)Lost on rebootsudo launchctl load /System/Library/LaunchDaemons/org.freebsd.sshd.plist
GUI toggle (e.g., gershwin "Sharing" pane)Survives rebootsGUI talks to launchd over IPC, flips the same persistent state the -w flag does.

The persistence trick is a separate overrides file:

At 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

Status today

CapabilityStatus
Disabled key parsed at scan timeprobably partial — needs core.m audit
Overrides-file mechanismNOT 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 flagNOT implemented
launchctl enable / disable (modern verb)NOT implemented
launchctl start <label> / stop <label>probably NOT implemented — no manual one-shot start

Implementation plan (Phase 5 batch)

~250 LOC of focused launchd surgery, lands alongside the EnvironmentVariables / StartCalendarInterval / etc. work in §6.3:

  1. 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.
  2. 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).
  3. ipc.c (~30 LOC): new IPC verbs for "set persistent enable", "set persistent disable", "manual start", "manual stop".
  4. varrun plist (one line): mkdir -p /var/db/org.freebsd.launchd so the overrides file has a home.

Pragmatic Phase 4 path

For the immediate sshd milestone (Phase 4 in §14), two options:

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.

13. Licensing

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).

SourceLicenseHow we handle it in-tree
Apple launchd-842.1.4 (@APPLE_APACHE_LICENSE_HEADER_START@ in every file)Apache 2.0Keep 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-ClauseYour 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 ExceptionListed 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-ClauseEach new file gets a BSD-2-Clause SPDX header. Matches the top-level and the FreeBSD norm.

13.1 Why BSD-2-Clause top-level (and not Apache 2.0)

  1. FreeBSD ecosystem fit. FreeBSD base is BSD-2-Clause; 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.
  2. Apple's headers don't depend on our top-level choice. Apple's @APPLE_APACHE_LICENSE_HEADER_START@ blocks stay on every Apple file regardless. Top-level license only governs files we ourselves author.
  3. The patent-grant argument doesn't cut here. Apache 2.0's patent grant matters when contributors contribute patented inventions and you want downstream protection from their future-self assertions. For a port of someone else's already-Apache-2.0 code, the existing grant on those files is preserved by the file header — top-level license doesn't change that. We're not introducing patentable inventions of our own.
  4. Smaller surface. BSD-2-Clause has no 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.

13.2 Why not dual-license

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.

13.3 Modifying Apple's launchd files — what the rules require

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:

  1. Keep the original copyright header verbatim. Every Apple file's @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.
  2. Add a "Modified by..." note immediately below the Apple header. Apache 2.0 §4(b): "modified files must carry prominent notices stating that You changed the files." A two-line note suffices:
    /*
     * 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.
  3. Modified file stays Apache 2.0. Our changes to that file are Apache-licensed (inbound=outbound). The project top-level (BSD-2-Clause) doesn't override per-file licensing — Apache files in a BSD project remain Apache; BSD files in an Apache project remain BSD. License is per-file, not per-repo.
  4. 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.
  5. New files we author (build.sh, make-launchd.sh, the plists, our compat shims) get a BSD-2-Clause SPDX header:
    /*
     * 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.

13.4 Shipping LGPL source for built-in-libraries

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:

  1. Ship source as a tarball under /System/Library/Source/ in the ISO. Self-contained, large.
  2. Release notes link to the upstream commit the ISO was built from. §6 explicitly permits this. Cheaper, smaller ISO.

Plan goes with option 2 — see Q5 in §15.

14. Phased delivery

Phase 0 — scaffold the repo from livecd-unionfs

Phase 1 — import & amputate launchd

Phase 2 — GNUstep system domain in build.sh

Phase 3 — launchd daemon & client (milestone 1 from §12.1)

Phase 4 — sshd as proof-of-life (milestone 2 from §12.2)

Phase 5 — remaining base services (milestone 3 from §12.3)

15. Open questions

Q1. 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).
Q2. 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.
Q3. 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.
Q4. Rollback story for the installed-on-a-real-system case. If a launchd update bricks boot, how does the user get back to 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.
Q5. Source-tarball shipping for LGPL. Licensing §13.1 picks "release notes link to the upstream pinned SHA" over "ship source tarball under /System/Library/Source/". Confirm with whoever cares about strict LGPL §6 compliance before tagging an actual release (vs the continuous rolling release).

16. References

Plan v0 — generated 2026-05-04. This is a living document; updates land here before any code commits.