freebsd-launchd: solving service ordering (and the netconfigd path)

Working notes on the cross-service ordering problem on freebsd-launchd — what makes varrun → cron, syslogd-first, dhcpcd-after-kmodloader work declaratively rather than by alphabetical-filename luck. netconfigd is one downstream consumer of the same primitives; the doc walks both layers.

Companion document to the freebsd-launchd / configd discussion. Written 2026-05-10.

Contents

  1. Background — what we're trying to build, and why this is hard
  2. Primer — the IPC technologies, in plain language
    1. Mach IPC
    2. MIG (Mach Interface Generator)
    3. XPC
    4. Bootstrap server & MachServices
    5. AF_UNIX sockets
    6. GNUstep Distributed Objects
  3. What's actually in the Gershwin tree today
  4. Service ordering today, and how each option changes it
    1. Every launchd plist on the live ISO today
    2. How ordering actually works today
    3. Where the current ordering is fragile
    4. What Mach activation actually does, step by step
    5. The "varrun starts before cron" question, walked through
    6. Apple's other ordering primitives (not Mach)
    7. What changes under each option
  5. Option 1 — Reverse Mach → AF_UNIX inside configd
  6. Option 2 — Port Mach IPC to FreeBSD
  7. Option 3 — New Gershwin-native daemon using GNUstep DO
  8. Side-by-side comparison
  9. Decision framework
  10. Recommendation
  11. The plist-key roadmap: what we have, what we need
  12. Open questions & risks

1. Background — the service ordering problem (and netconfigd as one consumer)

The central topic of this document is cross-service ordering on freebsd-launchd. The launchd port today recognizes 6 plist keys (Label, Program, ProgramArguments, Disabled, RunAtLoad, KeepAlive) and orders services by alphabetical filename scan. That works coincidentally on the live ISO but breaks the moment any real cross-service dependency has to be expressed:

Solving this is the foundation work covered in the recommendation (Section 10) and the plist-key roadmap (Section 11). It's mechanical engineering inside core.m: grow the plist key vocabulary — WatchPaths, Sockets, KeepAlive subkeys, LaunchEvents — so dependencies can be declared instead of inferred from filename order.

netconfigd is one downstream consumer of those same primitives. It's the most-discussed consumer because the gershwin-on-freebsd variant has imported Apple's configd source (configd/src/, version 963.270.3) and a build script (make-configd.sh) intended to produce a daemon called netconfigd. Whether that import actually ships, and in what form, is a real architectural fork (Sections 5–7) — but it sits on top of the ordering work, not parallel to it.

The hard problem inside the netconfigd-specific question: configd is built end-to-end on Mach IPC. Every plugin talks to the core via Mach messages; every client (scutil, SCDynamicStore consumers) reaches the daemon via the bootstrap server's Mach port lookup; the modern XPC layer sits on top of Mach. FreeBSD has no Mach. So you can compile configd, but you can't run it usefully without doing real work first.

That framework manages, on macOS:

This document walks through both layers: the ordering work in the launchd port (Sections 4, 10, 11) and the three netconfigd paths that consume those primitives (Sections 5–7), with cross-references between them.

2. Primer — the IPC technologies, in plain language

If you're confident on what Mach and DO are, skip ahead to section 3. The other terms — MIG, XPC, bootstrap, AF_UNIX framed messages — are easier to evaluate options against if they're not abstract.

2.1 Mach IPC

Mach is the kernel's inter-process communication primitive on macOS (and on Hurd, NeXTSTEP, and a few research kernels). It's not a library; it's built into the kernel, like FreeBSD's pipes or sockets. Two programs talk by sending each other messages — typed, structured data — through Mach ports, which are kernel-managed mailboxes.

Concretely, a Mach message exchange looks like:

// Sender
mach_msg_header_t msg;
msg.msgh_remote_port = some_port;   // who to send to
msg.msgh_local_port  = reply_port;  // where reply comes back
msg.msgh_id          = 1234;        // operation code
mach_msg(&msg, MACH_SEND_MSG, sizeof(msg), 0,
         MACH_PORT_NULL, MACH_MSG_TIMEOUT_NONE, MACH_PORT_NULL);

// Receiver, on the other end of some_port:
mach_msg(&msg, MACH_RCV_MSG, 0, sizeof(msg),
         my_recv_port, MACH_MSG_TIMEOUT_NONE, MACH_PORT_NULL);

Important properties that distinguish Mach from "just sockets":

Key takeaway: Mach is a kernel feature. "Porting Mach" means either implementing it as a userspace library (slow, semantics partial) or putting it in the FreeBSD kernel (large, invasive).

2.2 MIG (Mach Interface Generator)

Writing raw mach_msg calls is tedious and error-prone, so Apple ships a code generator: MIG. You write an interface definition (a .defs file) that looks like an RPC schema:

// SystemConfiguration's actual MIG interface (excerpt from configd):
subsystem config 20000;

routine configopen(
    server : mach_port_t;
    name   : xmlData;
    options: xmlData;
    out    session : mach_port_t;
    out    status  : int);

routine configget(
    server : mach_port_t;
    key    : xmlData;
    out    value  : xmlDataOut;
    out    status : int);

MIG generates two C files: a client stub (configopen() as a function the caller can use) and a server dispatcher (a switch on msgh_id that calls into your handler). Configd's IPC contract is defined as MIG, and configd's source includes generated stubs for config.defs, shared_dns_info.defs, helper.defs, and several others.

Key takeaway: MIG is just a code generator on top of Mach. If you replace Mach, you replace MIG too — every config_user.c / configMIGServer.c stub becomes hand-written transport code.

2.3 XPC

XPC is Apple's modern higher-level IPC API, introduced in Mac OS X 10.7 (2011). It hides Mach behind a friendlier interface:

// XPC client (in configd's nwi/network_information.c):
xpc_connection_t conn = xpc_connection_create_mach_service(
    "com.apple.SystemConfiguration.configd",
    queue,
    XPC_CONNECTION_MACH_SERVICE_PRIVILEGED);

xpc_object_t request = xpc_dictionary_create(NULL, NULL, 0);
xpc_dictionary_set_uint64(request, "cmd", NWI_GET_STATE);

xpc_connection_send_message_with_reply(conn, request, queue,
    ^(xpc_object_t reply) {
        // process reply asynchronously
    });

XPC adds:

Crucially, XPC still uses Mach as transport. xpc_connection_create_mach_service() literally looks up a Mach port via the bootstrap server. Without Mach, XPC has no wire.

Key takeaway: XPC and Mach are not separable in Apple's stack. Anything that uses XPC is using Mach underneath.

2.4 Bootstrap server & MachServices

How does a client know which Mach port to send to? It asks the bootstrap server, a registry that maps human-readable names like com.apple.SystemConfiguration.configd to the kernel port object.

On Apple, the bootstrap server is part of launchd itself. When a daemon's plist contains:

<key>MachServices</key>
<dict>
    <key>com.apple.SystemConfiguration.configd</key>
    <true/>
</dict>

...launchd does three things:

  1. Registers the name com.apple.SystemConfiguration.configd in its bootstrap registry, attaching a Mach port.
  2. When any client calls bootstrap_look_up() with that name, launchd hands them a send right to the port.
  3. If the daemon hasn't started yet, launchd starts it on demand — this is "launch-on-demand activation" — and queues the message until the daemon checks in.

This is the dependency-resolution mechanism we don't have. "Service A depends on service B" on Apple is expressed by service A calling bootstrap_look_up("B"), blocking until launchd has B running.

Key takeaway: MachServices in a plist is a request to launchd's bootstrap server. The freebsd-launchd port doesn't implement bootstrap, so the key is silently ignored.

2.5 AF_UNIX sockets

Unix-domain sockets are FreeBSD's native IPC. They look and act like network sockets (socket(), bind(), connect(), read(), write()), except the address is a filesystem path (/var/run/foo.sock) and the kernel keeps the bytes in memory rather than going over the wire.

// Server
int s = socket(AF_UNIX, SOCK_STREAM, 0);
struct sockaddr_un addr = { .sun_family = AF_UNIX };
strcpy(addr.sun_path, "/var/run/configd.sock");
bind(s, (struct sockaddr*)&addr, sizeof(addr));
listen(s, 5);
int c = accept(s, NULL, NULL);
read(c, buf, sizeof(buf));

// Client
int c = socket(AF_UNIX, SOCK_STREAM, 0);
connect(c, (struct sockaddr*)&addr, sizeof(addr));
write(c, msg, len);

What you give up versus Mach:

What you get in exchange: it works on every Unix, has well-understood semantics, doesn't need any kernel changes, and the file system gives you a natural namespace.

"Framed messages", which appears in the freebsd-launchd ipc.c, just means you put a small header (length + opcode) on top of each message so the receiver knows where one ends and the next begins. The launchd port already does this:

// from freebsd-launchd src/src/ipc_proto.c (sketch)
struct msg_hdr {
    uint32_t length;
    uint32_t cmd;
} __packed;

// Client sends: write(s, &hdr, sizeof hdr); write(s, payload, hdr.length);
// Server reads: read(s, &hdr, sizeof hdr); read(s, payload, hdr.length);

That's the pattern Option 1 would extend to configd: replace MIG-generated mach_msg calls with framed AF_UNIX writes, replace XPC dictionary passing with serialized blobs over the same socket.

2.6 GNUstep Distributed Objects (DO)

DO is a Foundation-level remote-object system. Your daemon vends an Objective-C object; another process gets a transparent proxy and calls methods on it as if it were local.

// Server (the daemon)
@interface NCNetworkStore : NSObject
- (NSString *)valueForKey:(NSString *)key;
- (void)setValue:(NSString *)v forKey:(NSString *)k;
@end

NCNetworkStore *store = [[NCNetworkStore alloc] init];
NSConnection *conn = [NSConnection serviceConnectionWithName:@"NCNetworkStore"
                                                  rootObject:store];
// Process now serves DO requests on a runloop.

// Client (some other process)
id<NCNetworkStoreProto> proxy =
    (id)[NSConnection rootProxyForConnectionWithRegisteredName:@"NCNetworkStore"
                                                          host:nil];
NSString *v = [proxy valueForKey:@"State:/Network/Interface/wlan0/IPv4"];

Underneath, DO uses:

What you get:

What you don't get:

Key takeaway: DO is the "Objective-C native" alternative. It pairs well with new code; pairing it with imported Apple C source is awkward.

3. What's actually in the Gershwin tree today

Three pieces of imported Apple source matter here:

WhatVersionState
src/ — launchd launchd-842.1.4 Daemon rewritten: Mach RPC server replaced with AF_UNIX framed messages (per ipc.c:11-13). Recognizes 5 plist keys: Label, Program, ProgramArguments, RunAtLoad, KeepAlive. No MachServices, no Sockets, no WatchPaths. liblaunch/liblaunch.c (Apple client lib with Mach hooks) is preserved as source but not built or linked.
configd/src/ — configd configd-963.270.3 Imported as-is. Still depends on Mach + MIG + XPC throughout. Includes plugin tree (IPMonitor, IPConfiguration, KernelEventMonitor, etc.), SC framework headers, scutil, IPMonitorControl, nwi. Build script make-configd.sh exists; daemon would be installed as netconfigd. Not currently shipped on the live ISO.
GNUstep base upstream In the system domain at /System/Library/. gdomap runs as org.gnustep.gdomap.plist on Gershwin (per gershwin-on-freebsd repo). DO infrastructure is operational.

So: a launchd that doesn't speak Mach, a configd that only speaks Mach, and a parallel DO infrastructure that's already running for other reasons.

4. Service ordering today, and how each option changes it

This section is the foundation for evaluating the three options realistically. The question "how does service A wait for service B?" is exactly the question Mach was designed to answer for Apple, and it's the question this launchd port currently handles only by luck.

4.1 Every launchd plist on the live ISO today

The complete set, in alphabetical filename order (which is the order the launchd port scans them at boot):

#Plist labelWhat it doesLifecycle
1 org.freebsd.cron Run scheduled tasks from /etc/crontab, /var/cron/tabs/. Self-daemonizes (parent forks + exits, child detaches). RunAtLoad, KeepAlive=false, cron -s
2 org.freebsd.dhcpcd DHCP v4 + v6, RA/SLAAC processing, RTM_NEWLINK hot-plug. Master mode covers all interfaces; recognizes bridge/lagg/vlan layering. Supervises wpa_supplicant for wifi interfaces via its hook. RunAtLoad, KeepAlive=true, dhcpcd -B (foreground)
3 org.freebsd.dmesg.boot Save kernel ring buffer to /var/log/dmesg.boot. Self-mitigates by mkdir -p /var/log first. RunAtLoad once, exits
4 org.freebsd.getty.console Spawn getty on /dev/ttyu0 (serial console). RunAtLoad, KeepAlive=true (respawn after each session)
5 org.freebsd.getty.vty0 Spawn getty on /dev/ttyv0 (VGA console). Wrapper sleeps 24h if device is absent (serial-only hardware). RunAtLoad, KeepAlive=true
6 org.freebsd.kmodloader Run devmatch(8), kldload missing drivers for unattached devices in the live device tree. Plus PCI-vendor scan for the radeonkms-vs-amdgpu split. RunAtLoad once, exits
7 org.freebsd.sysctl Apply /etc/sysctl.conf. No-op on a default live ISO (file is comment-only). RunAtLoad once, exits
8 org.freebsd.syslogd Drain kernel /dev/klog + Unix socket /var/run/log to /var/log/messages. RunAtLoad, KeepAlive=true, syslogd -F -ss
9 org.freebsd.varrun Defensive one-shot: mkdir /var/run /var/log /var/spool /var/empty, sticky /tmp, touch the standard log files. Replaces conventional rc.d/var + rc.d/cleanvar. RunAtLoad once, exits

4.2 How ordering actually works today

Three mechanisms, in decreasing order of importance:

  1. Alphabetical filename order. The launchd port scans /System/Library/LaunchDaemons/ in readdir() order, which on UFS is filename-sorted. That's the only ordering primitive the daemon recognizes. org.freebsd.cron.plist < org.freebsd.dhcpcd.plist < ... < org.freebsd.varrun.plist in lexicographic order.
  2. Self-defensive scripts. Some plists pre-mkdir what they need rather than relying on a previous job. dmesg.boot's ProgramArguments includes /bin/mkdir -p /var/log exactly because varrun sorts after it. The plist comment notes this explicitly: "Self-sufficient is safer than relying on ordering."
  3. Coincidence with base.txz. /var/run, /var/log, /var/spool all exist in the unionfs lower because base.txz populated them. varrun creating them again is a no-op on the live ISO. On a system where /var were a fresh tmpfs, the ordering would matter and it would be wrong.

That is the entire ordering toolkit. MachServices, WatchPaths, KeepAlive's state-predicate subkeys (OtherJobEnabled, PathState, SuccessfulExit), LaunchEvents — none of those are parsed by the daemon. Plists may contain them; they're silently dropped (recall core.m:258-266 recognizes only six keys: Label, Program, ProgramArguments, Disabled, RunAtLoad, KeepAlive — all booleans or simple strings/arrays, no nested structure).

4.3 Where the current ordering is fragile

Hypothetical "want"What actually happens today
cron wants /var/run/cron.pid; varrun should create /var/run first. cron sorts before varrun (c < v). On the live ISO this works only because /var/run already exists in base.txz. On a cleaner rootfs, cron's PID-file write would fail.
syslogd wants to write /var/log/messages; varrun touches it. varrun runs LAST. syslogd starts first and uses an existing file (saved by base.txz pre-population). On a fresh-/var system, syslogd would get a write error.
dhcpcd should run after kmodloader so NIC drivers are loaded first. dhcpcd sorts before kmodloader (d < k). dhcpcd survives because it's in master mode and watches PF_ROUTE for late-arriving interfaces. If it weren't, it would miss the NICs that kmodloader brings up moments later.
kmodloader's log lines should reach syslogd. syslogd sorts after kmodloader (k < s). kmodloader's early stderr/syslog calls go to the kernel ring buffer or are dropped. They eventually appear in /var/log/messages because syslogd drains /dev/klog at startup, but they aren't real-time.
Any service wants config from a hypothetical netconfigd. No mechanism. Phase 2's netconfigd would have to either be RunAtLoad and assume clients sort after it alphabetically, or implement its own readiness signaling (sentinel file? socket-connect retry loop?). This is exactly what Mach activation solves on Apple.

The current state is "works because the test environment is forgiving." It is not a robust ordering model.

4.4 What Mach activation actually does, step by step

Apple's launchd has one core ordering mechanism — launch-on-demand activation via the bootstrap server — plus a few satellite mechanisms (Section 4.6) that handle cases Mach can't.

Here is the complete flow for "daemon B advertises a Mach service; daemon A consumes it":

Step 1: B's plist declares the service

<key>Label</key>
<string>com.apple.configd</string>
<key>ProgramArguments</key>
<array>
    <string>/usr/libexec/configd</string>
</array>
<key>MachServices</key>
<dict>
    <key>com.apple.SystemConfiguration.configd</key>
    <true/>
</dict>
<!-- Note: NO RunAtLoad. Launch-on-demand is the default with MachServices. -->

Step 2: At launchd startup, launchd registers the name

Launchd reads B's plist, creates a Mach receive port, and stores the mapping "com.apple.SystemConfiguration.configd" → port in the bootstrap namespace. Daemon B itself is not running yet. Launchd holds the receive right; nobody is listening on it.

Step 3: Daemon A wants to talk to B

// In A's source:
mach_port_t configd_port;
kern_return_t kr = bootstrap_look_up(bootstrap_port,
                                     "com.apple.SystemConfiguration.configd",
                                     &configd_port);
if (kr != BOOTSTRAP_SUCCESS) { /* error */ }
// A now has a send right to B's port.

This single call has profound consequences:

Step 4: B claims the receive right

// In B's startup, after exec'ing /usr/libexec/configd:
mach_port_t my_recv_port;
bootstrap_check_in(bootstrap_port,
                   "com.apple.SystemConfiguration.configd",
                   &my_recv_port);
// B can now mach_msg(MACH_RCV) on my_recv_port and process A's messages.

Why this solves ordering

The ordering between A and B is now implicit in the IPC contract. There is no "A starts before B" or "B starts before A" configuration anywhere. The actual rule is:

B is started exactly when somebody first wants to talk to it. Until then, B doesn't run. Once it's running, anyone who needs to talk to it can.

This is dependency injection at the OS level. Compared to alphabetical filename ordering: the current scheme requires every plist filename to rank correctly against every other, and breaks silently when a new service is added. Mach activation does away with the ordering question entirely — you don't order, you express what you want, and the bootstrap server resolves it.

4.5 The "varrun starts before cron" question, walked through

You asked specifically: "How do we say varrun starts before cron with Mach? Do we modify cron?" The honest answer is: Mach activation doesn't actually solve this case directly. Walking through each plausible approach:

Approach A: Express it as a Mach IPC dependency

For Mach activation to apply, varrun would need to expose a Mach service. Cron would call bootstrap_look_up("com.gershwin.varrun"). Launchd would start varrun on demand.

This doesn't fit because varrun is a one-shot: it creates directories and exits. There is no daemon to register a Mach port, and no IPC for cron to do once it has a port. You'd have to redesign varrun as a long-running daemon that exposes some pointless query interface, just to give cron something to look up. Wrong tool.

Cron source modification: yes, you'd have to add the bootstrap_look_up call. But that's an ugly way to express "wait for the filesystem to be ready."

Approach B: Express it as exit-status dependency (Apple-style, not Mach)

Apple's launchd has a KeepAlive dictionary with conditional subkeys. The relevant one here:

<!-- Cron's plist would add: -->
<key>KeepAlive</key>
<dict>
    <key>OtherJobEnabled</key>
    <dict>
        <key>org.freebsd.varrun</key>
        <true/>
    </dict>
</dict>

This says: "only keep cron loaded while varrun's job has been enabled and successfully run." It's an explicit ordering at the launchd level. cron source is not modified.

Caveat: this key is not parsed by the freebsd-launchd port today. Adding support for it is a launchd port enhancement, separate from any Mach work.

Approach C: Express it as filesystem-state dependency (also Apple-style, not Mach)

<!-- Cron's plist would add: -->
<key>WatchPaths</key>
<array>
    <string>/var/run</string>
</array>

Cron is launched when /var/run exists. cron source is not modified. Same caveat: not parsed by this launchd port.

Approach D: Pragmatic — self-mitigate in the plist

Change cron's ProgramArguments to /bin/sh -c "/bin/mkdir -p /var/run; exec /usr/sbin/cron -s". No ordering needed; cron self-mitigates the way dmesg.boot already does (which has the comment: "Self-sufficient is safer than relying on ordering"). Ugly but works today.

So: do we modify cron?

ScenarioModify cron?Why
"varrun before cron" — filesystem-state dependency. No. Right tools are launchd-side: KeepAlive { OtherJobEnabled }, WatchPaths. Mach is wrong tool for this case. Pragmatic workaround: self-mitigate in the plist (Approach D).
"cron consults a config service" — IPC dependency. Yes. Cron source calls bootstrap_look_up("com.gershwin.configservice"). The lookup blocks until the service is up. This is where Mach activation shines.

The takeaway: Mach activation is the right answer for IPC-shaped dependencies between long-running daemons. It is the wrong answer for filesystem-state, exit-status, or boot-phase dependencies. Apple's launchd has separate keys for those, all currently absent in the freebsd-launchd port.

4.6 Apple's other ordering primitives (the ones that aren't Mach)

To complete the picture, the keys Apple's launchd recognizes for non-IPC dependencies. None of these is parsed by the freebsd-launchd port today.

KeyWhat it doesUse case
WatchPaths Start the daemon when any of these paths appear or change. Wait for a directory to exist; pickup of dropped-in config files.
QueueDirectories Like WatchPaths, but only fires when a directory becomes non-empty. Spool processors, mail queues, log shippers.
KeepAlive { OtherJobEnabled = { ... } } Run only while another job is loaded. "cron only after varrun"; daemon dependency chains.
KeepAlive { SuccessfulExit = false } Respawn unless the previous instance exited cleanly. Crash-loop recovery without infinite respawn after intentional exit.
KeepAlive { PathState = { ... } } Run only while a path exists / doesn't exist. Conditional services tied to filesystem state.
KeepAlive { NetworkState = ... } Run only when network is up. Network-dependent services (less granular than IPC dep).
LaunchEvents Activate on a notify(3) event. Generic event-driven activation; configd publishes events for many things.
Sockets Have launchd open the socket(s); fire daemon when a connection arrives. Daemon inherits the listening fd. Socket-activation pattern; xinetd-style. syslogd's /var/run/log would be the natural example here.
LimitLoadToSessionType Only load in matching sessions (Aqua, Background, LoginWindow). Distinguishing GUI-session daemons from system daemons.

Apple's launchd has a rich vocabulary for expressing dependencies. Mach activation is just one mechanism — the most powerful for IPC — and these others fill the gaps for non-IPC cases. Phase 2 of the launchd port should plan to grow at least WatchPaths, OtherJobEnabled, and Sockets support, alongside whatever IPC story is chosen.

4.7 What changes under each option

Option 1 — Reverse Mach → AF_UNIX in configd

Option 2 — Port Mach IPC to FreeBSD

Option 3 — New Gershwin-native daemon using GNUstep DO

Common thread across all three options: Mach (and DO, and AF_UNIX socket connect-with-retry) all solve IPC-shaped dependencies. None of them solves filesystem-state dependencies, exit-status dependencies, or boot-phase dependencies. For those, the freebsd-launchd port needs to grow new keys in core.m beyond the current 6 (see section 11 for the full inventory). That's a separate work item from the netconfigd question — but it's adjacent, and Phase 2 should plan to tackle both. The varruncron ordering is the canonical example of the non-Mach half of the problem.

5. Option 1 — Reverse Mach → AF_UNIX inside configd

Recommended Effort: weeks – months

What it actually means

Apply the same surgery to configd that was already applied to launchd. Specifically:

  1. Replace MIG-generated stubs (config_user.c, configMIGServer.c, shared_dns_infoUser.c, etc.) with hand-written client/server pairs over AF_UNIX. Same shape: a function call on the client becomes a framed write; the server dispatches by opcode.
  2. Replace XPC sites (nwi/network_information_server.c, libSystemConfiguration/*) with a small wrapper library that imitates the XPC API surface but transports over the same AF_UNIX socket.
  3. Replace bootstrap_look_up() with connect() to a well-known socket path (e.g. /var/run/configd.sock).
  4. Stub or remove Mach-specific features that have no Unix equivalent: vm_copy for large messages, send_once rights, port-right transfer. Most of configd doesn't use these — they're hot paths in IOKit and the BSD kernel layer, not the SC stack.

What you keep

What you change

Pros

  • Reuses the configd source you already imported — the import wasn't wasted.
  • Keeps the SC framework's API surface, so Gershwin code that calls SC functions matches what macOS code does.
  • Mirrors the precedent set by the launchd port. Same pattern, same justification, future contributors find it consistent.
  • No new kernel work. Everything is userspace.
  • scutil, IPMonitorControl, nwi, SCMonitor — all the satellite tooling continues to work after the surgery.
  • Future configd updates can be re-merged with the same transport patches; you stay close to upstream.

Cons

  • Real work. Configd is ~963 versions of accumulated complexity. The plugin tree alone is dense, and every IPC site has to be touched.
  • You're essentially writing an XPC-shaped library on top of AF_UNIX. That's not nothing — XPC has connection lifecycle, audit tokens, dictionary serialization, async reply queues. A "good-enough" subset is achievable; full fidelity is a project.
  • Some Mach features have no clean Unix analog. Where they're load-bearing in configd, you end up redesigning, not just transporting differently.
  • Debugging the transport layer means understanding both Mach semantics (what configd expects) and AF_UNIX semantics (what you've given it). Two mental models at once.
  • Apple's plugin source is C with Apple-isms (CoreFoundation idioms, dispatch_queue patterns, reference-counting conventions). Not hard, but you'll be reading it constantly during the rewrite.

Concrete time estimate

Hard to be precise without scoping each plugin individually, but the launchd port (a smaller project — one daemon, one IPC interface) was a multi-month effort. Configd has on the order of 8–12 MIG interfaces, plus XPC sites in the framework client library, plus the helper privilege-escalation bridge. Realistic estimate: 2–4 months of focused work to a working netconfigd + IPMonitor + IPConfiguration that can DHCP a wired interface. Wireless and IPv6 add more. Polish to match dhcpcd's current behavior is more on top.

6. Option 2 — Port Mach IPC to FreeBSD

Maximalist Effort: months – years

What it actually means

Make FreeBSD speak Mach. Three sub-paths exist, each with prior art:

  1. Userspace Mach library. Implement mach_msg, ports, port rights, message queues entirely as a userspace library on top of (probably) AF_UNIX. The library would have to manage port-name allocation, port-right reference counting, message buffering — all the things the kernel does on Darwin. This is what Darling does (the macOS compatibility layer for Linux), via a kernel module that emulates Mach syscalls.
    • Reasonable for app-level use; semantics partially diverge under stress (ports outliving processes, IPC across user/kernel boundary, etc.).
    • Performance is acceptable but not native.
  2. Kernel module. Add Mach as a FreeBSD kernel feature, accessed via a new syscall surface. Closer to native semantics; closer to "actually FreeBSD now has Mach." Far more work; touches the kernel, which means review, security audit, and ongoing maintenance against FreeBSD's release cadence.
  3. Pull GNU Mach. The Hurd's microkernel is Mach, and there's a userspace Mach implementation lineage (libports, libpager, etc.). In principle reusable. In practice, Hurd Mach has its own dialect and is not a drop-in for Apple's xnu Mach.

All three approaches assume you're willing to ship something that runs unmodified Apple source. That's the entire point of the option.

What you keep

What you change

Pros

  • Strategic compounding: every future Apple-source port becomes cheaper.
  • Gershwin can host code that nobody else can host on a non-Apple kernel.
  • No per-component transport patches to maintain.
  • Honest engineering: you're not papering over the dependency, you're solving it.

Cons

  • Massive effort for a payoff that's currently theoretical (no other Gershwin component imminently needs Mach).
  • Long time-to-first-result. configd doesn't ship until Mach works, and Mach's "works" bar is high.
  • Maintenance burden continues forever.
  • If only configd needs Mach, you've built a city to host a single building.
  • Fragile in subtle ways — port-rights semantics, exception delivery, signal/Mach interaction. Lots of places where Apple code "just works" because xnu does something specific that your reimplementation might or might not.

Concrete time estimate

Userspace Mach with enough fidelity to run configd: 6–12 months minimum, with a person familiar with both Mach internals and FreeBSD kernel/userspace boundaries. Kernel-module path: 1–2 years, plus ongoing FreeBSD-version maintenance. Either way, configd doesn't ship for the duration of the Mach project.

7. Option 3 — New Gershwin-native daemon using GNUstep DO

Smallest scope Effort: weeks

What it actually means

Don't port configd. Treat the imported configd source as a reference, and write a fresh Objective-C daemon — call it NCDaemon — that fulfills the same role using GNUstep idioms.

Sketch of the architecture:

// NCDaemon.h
@protocol NCNetworkStoreProto
- (NSDictionary *)copyValueForKey:(NSString *)key;
- (void)setValue:(NSDictionary *)value forKey:(NSString *)key;
- (NSArray *)copyKeyList;
- (void)addObserver:(id<NCNetworkObserverProto>)observer
            forKey:(NSString *)key;
@end

@interface NCNetworkStore : NSObject <NCNetworkStoreProto>
@end

// In NCDaemon's main:
NCNetworkStore *store = [[NCNetworkStore alloc] init];
NSConnection *conn = [NSConnection serviceConnectionWithName:@"NCNetworkStore"
                                                  rootObject:store];
[[NSRunLoop currentRunLoop] run];

// Plugin equivalents are NSBundle-loaded classes, e.g.:
// /System/Library/NCBundles/IPMonitor.bundle
// /System/Library/NCBundles/IPConfiguration.bundle
// Each registers with the NCNetworkStore on load and posts state changes.

Clients (including a thin SCDynamicStore-shaped C wrapper, if needed for Apple-source porting) call:

// Client side (in some other process)
id<NCNetworkStoreProto> store =
    (id)[NSConnection rootProxyForConnectionWithRegisteredName:@"NCNetworkStore"
                                                          host:nil];
NSDictionary *ifaceState =
    [store copyValueForKey:@"State:/Network/Interface/wlan0/IPv4"];

What you keep

What you change / give up

Pros

  • Smallest scope. A working NCDaemon + DHCP plugin is on the order of weeks, not months.
  • Idiomatic GNUstep code. New contributors who know Foundation can read it. No need to understand Mach or MIG.
  • Service ordering primitive falls out naturally: rootProxyForConnectionWithRegisteredName: blocks until the name is registered. That's exactly what was missing.
  • Plugins are NSBundle-loadable Objective-C classes. Easier to write, test, and reload than configd's C bundles.
  • No Mach at any level. No surprise dependencies.
  • Plays well with existing Gershwin services that already use DO.

Cons

  • The imported configd source becomes a reference rather than a port. The work spent importing it isn't discarded, but it's not shipped either.
  • Gershwin is no longer "running Apple's SC framework"; it's running a Foundation-shaped service that does the same job. Strategically a fork from macOS's stack rather than a port of it.
  • If a future Gershwin goal requires running unmodified Apple SC code (some app expecting SCDynamicStore's exact wire protocol or notification semantics), you'd be retrofitting.
  • Each plugin's behavior is rewritten by hand. RA/SLAAC corner cases, DHCP option handling, IPMonitor's prioritization rules — all need attention. Apple has invested decades in those edge cases.
  • You're committing to maintaining the API surface yourself going forward. There's no upstream to merge from.

The gdomap bootstrap problem (and how to solve it)

If DO is the IPC, then gdomap is the equivalent of Mach's bootstrap server. Both are name registries that have to be running before any IPC name can be looked up or registered. This creates a chicken-and-egg problem at boot: gdomap must run before any DO-using service.

Apple solved the structural twin of this problem by making the bootstrap server part of launchd PID 1. There is no "launchd starts before bootstrap" race because they are the same process. GNUstep separated them — gdomap is its own daemon — which means we have a real ordering question to answer.

This is the central architectural caveat for Option 3. Choosing DO doesn't just mean "use a different transport." It commits you to solving the gdomap bootstrap dependency for every future DO-using service on Gershwin. That's a one-time cost paid in launchd-port engineering, but it has to be paid before the architecture is sound.

Four approaches, organized by what they actually decide:

The strategic question for B vs D: Is launchd PID 1 on Gershwin structurally Apple's launchd (which IS the bootstrap server), or is it FreeBSD's PID 1 augmented with a GNUstep-shaped bootstrap dependency expressed as a child process?

Both work. They embody different commitments:

Neither is "correct" in the abstract — the choice depends on whether Gershwin commits to the Apple structural model at the PID 1 level or stays GNUstep-process-shaped.

A. Plist filename ordering (stopgap)

The crude answer that works with the existing 6-key launchd parser. Rename gdomap's plist to sort before any DO-using service, or rename DO services to sort after gdomap. Currently org.gnustep.gdomap.plist sorts after org.freebsd.* (g > f), so all freebsd services run first. Two ways to flip the order:

Pros Zero code changes; works today.
Cons Brittle. Cross-vendor alphabetical dependencies fall over the moment a new service is added or labeled inconveniently. Acceptable as a Phase 2 stopgap, awkward as a long-term answer.

B. launchd hardcodes gdomap as its first child

The smallest code change that makes the dependency robust. Treat gdomap the way Apple treats the bootstrap server — not as "just another plist," but as a launchd-internal dependency. launchd's main(), right after setting up its own state and before scanning /System/Library/LaunchDaemons/, spawns gdomap directly:

// In launchd's main(), src/src/launchd.c
int
main(int argc, char *argv[])
{
    // ... existing init: signals, log, IPC socket, etc. ...

    // Bootstrap dependency: gdomap must be running before any DO
    // service can register its name or any client can resolve one.
    // Apple integrates the bootstrap server directly into launchd
    // PID 1. We approximate that by spawning gdomap as our first
    // child before scanning any plist.
    pid_t gdomap_pid;
    char *gdomap_argv[] = { "/usr/bin/gdomap", "-f", NULL };
    if (posix_spawn(&gdomap_pid, gdomap_argv[0], NULL, NULL,
                    gdomap_argv, environ) == 0) {
        wait_for_gdomap_listening();   // brief connect-retry to its UDP port
    }

    // ... existing plist scan and event loop ...
}

The principle: gdomap is a launchd-internal dependency, not a regular service. Apple does the same thing structurally — the bootstrap server is part of launchd itself, so it's "started" by virtue of launchd starting. We emulate that without merging the code: launchd's first action after self-init is to ensure gdomap is up. org.gnustep.gdomap.plist is no longer needed in LaunchDaemons/; gdomap's lifecycle becomes launchd's responsibility.

Refinements:

Pros Small launchd change (~30–50 lines). No new plist keys to parse. No upstream gdomap changes. Solves the bootstrap race robustly. Feasible inside Phase 2 without delaying netconfigd work.
Cons Still doesn't give launch-on-demand for DO services — daemons that vend DO services have to RunAtLoad, or clients have to retry until the registration appears. For full Apple-style activation semantics you still need Approach C below. Also: gdomap's lifecycle becomes implicit/hardcoded rather than declared.

This is the natural Phase 2 stepping stone: ship Approach B early to make the bootstrap dependency robust, then plan toward Approach C as a follow-on launchd-port enhancement. Approach C is unblocked by B (or builds on top of it).

C. Teach launchd a DOServices key (full launch-on-demand)

Add a new plist key analogous to Apple's MachServices. The semantics:

<key>Label</key>
<string>org.gershwin.NCDaemon</string>
<key>ProgramArguments</key>
<array>
    <string>/usr/libexec/NCDaemon</string>
</array>
<key>DOServices</key>
<array>
    <string>NCNetworkStore</string>
</array>
<!-- No RunAtLoad: launch-on-demand, just like MachServices on Apple. -->

What launchd does with this:

  1. At launchd startup, DOServices entries are noted but the daemon isn't started.
  2. Launchd ensures gdomap is up. (gdomap is treated as launchd's own dependency — either special-cased to start first, or merged in per Option C below.)
  3. When any client calls [NSConnection rootProxyForConnectionWithRegisteredName:@"NCNetworkStore" host:nil], the lookup hits gdomap. If gdomap doesn't have a registration for the name, it signals launchd (via a hook into gdomap's lookup-miss path, or via a launchd-side proxy that intercepts misses) and launchd execs the daemon. The daemon registers via standard [NSConnection serviceConnectionWithName:rootObject:] on startup; gdomap publishes the name; the client's lookup completes.

This is the GNUstep-native equivalent of Mach launch-on-demand activation. The user-facing developer experience is identical to Apple's: declare the service in your plist, register the name in your daemon's main(), and the lookup-side magic is launchd's responsibility.

Pros Clean, matches Apple's idiom for the GNUstep stack, handles dependency resolution transparently for every future DO daemon. One-time engineering cost, ongoing zero per-daemon cost.
Cons Requires DOServices parsing in core.m (small) plus a hook into gdomap's lookup-miss path (gdomap upstream change, or a launchd-side wrapper). Needs design work and possibly upstream coordination with the GNUstep project.

C. Merge gdomap into launchd PID 1

The architecturally cleanest option, mirroring Apple's "launchd IS the bootstrap server" decision. Embed gdomap's name-registration and lookup logic as part of the launchd PID 1 process. There is no separate gdomap daemon; clients still talk gdomap protocol, but the registry lives in launchd's address space.

Pros

Cons

Bottom line for ordering with DO

The gdomap-before-X dependency is real but bounded. For Phase 2 you can ship Option A (filename ordering) immediately and plan toward Option B as a launchd port enhancement. Option C is the long-term ideal if Gershwin commits to GNUstep-native architecture across the board.

Importantly, this dependency exists for any DO-based system on Gershwin, not just netconfigd. The moment a Gershwin daemon adopts DO for IPC, every future DO client of that daemon inherits the same gdomap-must-be-up requirement. That's an argument for solving it well early (Option B or C) rather than re-litigating it per daemon.

Concrete time estimate

An NCDaemon + a DHCP-equivalent plugin that handles a wired Ethernet end-to-end: 3–6 weeks. Adding a wireless plugin (wlan-clone creation + wpa_supplicant coordination + association events) on top: another 2–4 weeks. IPv6 RA/SLAAC: another 2–3 weeks. Polish to match dhcpcd's current behavior: 2–3 weeks. Total to feature-parity-with-current-ISO: ~3 months, with the upside that each piece ships incrementally.

Add for the gdomap bootstrap solution depending on choice:

8. Side-by-side comparison

Dimension Option 1: AF_UNIX rewrite Option 2: Port Mach Option 3: DO daemon
Effort 2–4 months 6–24 months ~3 months
Reuses configd source Yes, substantially Yes, fully No (reference only)
Apple source compat Apple source with patches Apple source unmodified Reference only
SCDynamicStore C API Yes, native Yes, native Wrapper layer (extra work)
Future Apple-source ports Same patches, re-applied Free Per-component decision
Mach dependency Removed Solved Not present
Plugin model Apple's bundles, in C Apple's bundles, in C NSBundles, in Objective-C
Idiomatic to Gershwin Mixed (C/Objc, Apple style) Mixed (C/Objc, Apple style) Yes (Foundation/GNUstep)
Service-ordering primitive Build it (socket connect) Free (bootstrap_look_up) Free (DO name registration)
Maintenance burden Per-version transport patches Mach implementation, forever Whole API surface, forever
Risk profile Moderate — bounded scope High — open-ended Mach edge cases Low–moderate — familiar tools
Strategic message "Apple's code, ported" "FreeBSD speaks Mach" "GNUstep-native config layer"

9. Decision framework

The right choice depends on what Gershwin is trying to be. Three different strategic positions, three different right answers:

Strategic goalRight optionWhy
"Gershwin runs Apple's frameworks, ported to FreeBSD where necessary" Option 1 Maximizes reuse of the configd source you already imported, follows the launchd-port precedent, ships in a knowable timeframe.
"Gershwin is FreeBSD with Apple's runtime stack on top" Option 2 Pays the Mach cost once. Every future Apple component runs unmodified. Multi-year project; needs explicit strategic commitment.
"Gershwin is a GNUstep-centric desktop on FreeBSD, drawing inspiration from macOS" Option 3 Use GNUstep idioms top to bottom. Apple source is a reference for behavior, not an asset to ship. Ships fastest, fits the existing GNUstep infrastructure.

The clearest signal in the existing tree is that Option 1 has already been chosen for launchd. The launchd port took Apple's source, ripped out Mach, replaced it with AF_UNIX framed messages, and kept the rest. Choosing Option 1 for configd is consistent with that precedent.

The second-clearest signal is that configd source was imported deliberately — that's a non-trivial commitment of bytes and license-tracking. Option 3 makes that import nearly worthless.

10. Recommendation

The configd-IPC question (Option 1 / 2 / 3) is not the central Phase 2 work item. It looks central because the doc has been organized around it, but the actual bottleneck is a level deeper.

The central work item is growing launchd's plist-key vocabulary.

Until launchd can express dependencies declaratively — WatchPaths for filesystem state, Sockets for activation, OtherJobEnabled for job-state, KeepAlive subkeys for refined supervision — every cross-service ordering question gets answered with "alphabetical filename + lucky timing + self-defensive scripts." That's true for the configd choice today, and it is also true for varrun ↔ cron, syslogd-first-or-lose-logs, and dhcpcd-after-kmodloader. The same engineering work that unblocks netconfigd ALSO unblocks every other current and future ordering question. Doing it inside an option-specific path means redoing it for the next problem.

Phase 2a — Foundation: launchd capability work

Add the plist keys that make dependency resolution declarative. Suggested priority order:

  1. WatchPaths — kqueue EVFILT_VNODE on each path; fires the daemon when a watched path appears or changes. Solves: varrun → cron (cron's plist watches /var/run), the broad class of "wait for a file to exist before running." Wide utility, low complexity. Probably the first key to land.
  2. Sockets activation — launchd bind()s and listen()s the socket itself, holds the listen fd, exec's the daemon when a connection arrives, hands the listen fd to the daemon (Apple's pattern uses env var; systemd's $LISTEN_FDS protocol works the same way). Solves: syslogd-before-X (clients connect to /var/run/log; launchd starts syslogd lazily, no log-loss window because writes block until syslogd inherits the fd). Also unblocks the netconfigd IPC story regardless of which option is chosen — socket activation gives the right ordering for any AF_UNIX-based service.
  3. KeepAlive subkeys: OtherJobEnabled, SuccessfulExit, PathState, NetworkState. Solves: explicit job-to-job and job-to-state dependencies. "Don't run cron unless varrun has loaded successfully" becomes a one-line plist key. Less work than they sound — they're predicates the launchd run loop already needs to evaluate per-job.
  4. A name-registry primitive for IPC services in whatever form fits the eventual netconfigd path. If Apple-faithful (Option 1), this is Sockets-style activation under a well-known path — already in scope from item 2. If GNUstep-native (Option 3), this is the DOServices key from section 7.5 plus the gdomap-bootstrap decision. The shape is downstream of 2b; the work is real either way.
  5. LaunchEvents (notify(3) / devd-event activation) — lower priority but the right primitive for hardware-readiness ordering: dhcpcd's plist subscribes to a "NIC attached" event rather than racing kmodloader. Pairs well with kmodloader's planned Phase 2 hot-plug shape.

Effort: 4–8 weeks. Independent of every other Phase 2 decision. Pays for itself by fixing existing brittleness (varrun, syslogd, dhcpcd ordering) AND being the prerequisite for netconfigd.

This is also the "doesn't suck like systemd" work. The keys above are scoped, optional, and orthogonal — each one solves one ordering problem cleanly, none of them require launchd to ingest the responsibilities of other daemons. Process supervision + activation primitives + dependency predicates is the right job for an init system. DHCP, DNS, journaling, and everything else stays in their own daemons.

Phase 2b — netconfigd path (decision deferred)

Once Phase 2a is in place, the Option 1 / 2 / 3 choice has a different feel: ordering is no longer the decisive concern, because launchd has the primitives. The choice becomes about strategic identity:

LeanPathWhy
Apple-faithful Option 1 (AF_UNIX rewrite of configd) Reuses imported source, follows the launchd-port precedent, ships in 2–4 months from the start of 2b.
GNUstep-native Option 3 (NCDaemon in Foundation/DO) Idiomatic GNUstep code, smaller scope (~3 months from start of 2b), configd source becomes reference. The gdomap-bootstrap question (B vs D in 7.5) becomes the next architectural choice.
Maximalist Option 2 (port Mach to FreeBSD) Justified only if Gershwin's broader plan has multiple Apple-source consumers needing Mach. 6–24 months. Strategic for Apple-source compounding; over-built if the only client is configd.

By the time 2a ships, you'll have months of additional thinking about which 2b path fits the project's identity. The decision doesn't have to be made now. None of 2a's work is wasted in any of the three branches.

Phase 2c+ — downstream consumers

With 2a in place, the rest fall out as small daemons that fire on the right events:

Why this layering matters

The previous version of this section recommended Option 1 first — that was solving the configd-specific problem and leaving the rest of the cross-service ordering brittleness untouched. That's backwards. The same engineering work in launchd that unblocks netconfigd ALSO unblocks varrun↔cron, syslogd-first, dhcpcd-after-kmodloader, and every future ordering question. Doing 2a first is the leverage point. Doing it inside an option-specific path means redoing it for the next problem.

The strategic lean (Apple-faithful vs GNUstep-native) is a real choice the project has to make, but it's a choice 2a doesn't force. That's the whole point: the launchd capability work is identity-neutral foundation, and the netconfigd path becomes a downstream decision once the foundation is solid.

11. The plist-key roadmap: what we have, what we need

Cross-referencing Apple's complete LAUNCH_JOBKEY_* namespace (preserved upstream in src/liblaunch/launch.h — ~95 keys) against what the freebsd-launchd port's core.m actually parses today.

Currently recognized: 6 keys

KeyWhat it does
LabelIdentifier; must match plist filename's reverse-DNS prefix.
ProgramPath to executable (absolute).
ProgramArgumentsargv array.
DisabledBoolean; if true, plist is parsed but the job isn't loaded.
RunAtLoadBoolean; start the job at launchd startup (alphabetical scan order).
KeepAliveBoolean only — the dictionary form (OtherJobEnabled, SuccessfulExit, etc.) is not parsed.

Everything below is in the upstream namespace but ignored by the daemon. Grouped by tier of importance for a Gershwin / FreeBSD-init use case.

Tier 1 — foundational missing keys (Phase 2a candidates)

The set of keys that, once landed, make freebsd-launchd a credible "doesn't-suck-like-systemd" FreeBSD init. Most are small; together they fix every current ordering problem.

KeyWhat it doesWhy we need itEffort
EnvironmentVariables Set env vars for the spawned process. Already flagged in org.freebsd.varrun.plist's comment: we use absolute paths because PATH isn't set. Trivial. Parse dict, build envp for posix_spawn.
UserName / GroupName Drop privileges before exec. Required for any non-root daemon. Currently every plist runs as root. Small. getpwnam/getgrnam, setgid/setuid.
InitGroups Boolean: call initgroups(3) before drop? Pairs with UserName for full group membership. Trivial.
WorkingDirectory chdir() before exec. Standard daemon need. Trivial.
RootDirectory chroot() before exec. Privilege isolation for sensitive daemons. Trivial.
Umask Set umask before exec. File-creation security baseline. Trivial.
StandardInPath / StandardOutPath / StandardErrorPath Redirect stdio to files. Per-daemon log routing without shell wrappers. Small.
WatchPaths kqueue EVFILT_VNODE on each path; fires the daemon when one changes. Fixes filesystem-state ordering (varrun→cron). Broad utility for "wait for X file." Medium. dispatch_source per path; integrate with run loop.
Sockets launchd opens the listen socket itself, exec's daemon on first connection, hands the listen fd to the daemon via env (Apple's $LAUNCH_DAEMON_SOCKET_NAME; systemd's $LISTEN_FDS is the same idea). Fixes syslogd-first / cold-start daemons. Foundation primitive for AF_UNIX-based service activation. Unblocks the netconfigd IPC path regardless of which option is chosen. Medium-large. bind/listen logic, fd inheritance, multiple sockets per job, both AF_INET and AF_UNIX shapes.
KeepAlive { OtherJobEnabled } Run only while another named job is loaded. Explicit job-to-job dependencies (cron after varrun). Small. Predicate evaluator addition.
KeepAlive { SuccessfulExit } Respawn unless prior instance exited 0. Crash-loop recovery without infinite respawn after intentional exit. Small.
KeepAlive { PathState } Run only while a path exists / doesn't exist. Conditional services tied to filesystem state. Small.
KeepAlive { NetworkState } Run only when network is up. Network-dependent services. Could supersede some of dhcpcd-after-X gymnastics if the launchd-side primitive exists. Small-medium. Needs link-state monitor source.
KeepAlive { Crashed } Respawn only on abnormal exit. Symmetric of SuccessfulExit. Trivial after SuccessfulExit.
KeepAlive { AfterInitialDemand } Don't respawn until the service has been demanded once. Cold-start activation guard for socket-activated services. Small.
LaunchEvents Activate on a generic event (Apple uses notify(3); FreeBSD analog would be devd events or EVFILT_USER). devd-event-driven activation: wlan-clone fires on "iwlwifi attached"; hot-plug story. Medium. Hook into devd or notify-equivalent.
ExitTimeOut Seconds between SIGTERM and SIGKILL on shutdown. Graceful shutdown for daemons that need to flush. Trivial.
ThrottleInterval Minimum seconds between successive launches. Anti-respawn-loop protection. Apple's default is 10s. Trivial.

Tier 2 — useful but not blocking

KeyWhat it doesUse case
StartIntervalRun periodically every N seconds.Per-daemon "every 5 minutes" without invoking cron.
StartCalendarIntervalRun at calendar times (Minute / Hour / Day / Month / Weekday).Per-daemon cron-like scheduling without cron. Could eventually retire org.freebsd.cron for system-managed jobs.
QueueDirectoriesLike WatchPaths, but fires only when dir is non-empty.Spool processors, mail queues, log shippers.
StartOnMountRun when any filesystem is newly mounted.Mount-event handlers (auto-fsck, indexing, etc.).
HardResourceLimits / SoftResourceLimitssetrlimit wrapping. Subkeys: CPU, Data, FileSize, NumberOfFiles, NumberOfProcesses, Stack, Core, ResidentSetSize, MemoryLock.Per-daemon resource caps.
Nicesetpriority(2) before exec.Background daemons.
inetdCompatibilityRun as inetd-style: exec per connection, stdio = socket.Legacy compatibility; small services that don't want their own socket logic.
LaunchOnlyOnceDon't relaunch even if a KeepAlive predicate would fire.First-boot-only services.
MultipleInstancesAllow concurrent instances (one per Sockets connection).Connection-per-instance daemons.
AbandonProcessGroupDon't track child process group.Daemons that fork trees and don't want launchd reaping them.
EnableGlobbingGlob-expand argv before exec.Rare; convenience for some plists.
BeginTransactionAtShutdownMark daemon as in-transaction during shutdown.Niche; relevant only if Apple's transaction tracking lands.

Tier 3 — Apple-specific or redundant on FreeBSD

These would only land if the corresponding subsystem comes with them. Listing for completeness so the reader knows they're in Apple's namespace and being deliberately not-implemented.

Tier 4 — output keys (read by launchctl, not user-set)

Suggested Tier 1 implementation order

Within Phase 2a (Section 10's foundation), the prerequisite chain suggests this order:

  1. EnvironmentVariables — already flagged as missing in shipping plists; small win first.
  2. Process-control batch: UserName, GroupName, InitGroups, WorkingDirectory, RootDirectory, Umask, Standard*Path. None depend on each other; lands in one push.
  3. WatchPaths — first event-driven activation. Fixes varrun → cron and similar. Establishes the dispatch_source-per-job pattern for everything that follows.
  4. KeepAlive subkeys — predicate-based supervision. Builds on the existing boolean KeepAlive; reuses much of the existing keep-alive logic.
  5. Sockets activation — bigger lift but unblocks the syslogd-first story and is a prerequisite for the Apple-faithful netconfigd path. Touches IPC fundamentals.
  6. LaunchEvents — devd integration. Enables wlan-clone, hot-plug, and similar event-driven daemons. Pairs naturally with kmodloader's planned Phase 2 long-running shape.
  7. Tier 2 keys as use cases demand them — StartInterval / StartCalendarInterval are the next logical group (could even retire org.freebsd.cron for system-managed periodic jobs once landed), then resource limits, then the rest.
  8. ExitTimeOut / ThrottleInterval — supervision polish; small additions whenever convenient.

By the end of Tier 1 the freebsd-launchd port has roughly Apple's launchd-842 minus Mach — combined with the AF_UNIX surgery already done on ipc.c, that's a credible foundation for any of Section 10's Phase 2b paths AND a coherent answer to "what does a non-systemd FreeBSD init look like."

12. Open questions & risks

Things to verify before committing to Option 1

Risks specific to Option 1

Risks if you choose Option 2 instead

Risks if you choose Option 3 instead

This analysis was prepared 2026-05-10. Source citations: NOTICE (launchd-842.1.4, configd-963.270.3 imports); src/src/ipc.c:11-13 (Mach-rewrite comment); src/src/core.m:258-266 (recognized plist keys); src/Makefile:48-55 (built daemon sources, liblaunch.c excluded); src/liblaunch/liblaunch.c:1008-1037 (dead Mach client code preserved); configd/src/nwi/network_information_server.c (XPC server-side); configd/src/configd.tproj/com.apple.configd.plist (MachServices declaration).