FreeBSD notifyd — porting plan

A FreeBSD-only port of Apple's notifyd + libnotify — Apple's lightweight named-event pub/sub bus. Drops Mach IPC; replaces the wire layer with GNUstep Distributed Objects + AF_UNIX socket; preserves the libnotify C API surface so Apple-derived applications (gershwin desktop apps, ported third-party Cocoa apps) Just Work without source changes. Companion to launchd, configd, kmodloader, asl.

Status: planning v0 — deferred

1. Goal & non-goals

1.1 Goal

Provide a working libnotify + notifyd on FreeBSD so Apple-derived apps (gershwin desktop, ported Cocoa apps, third-party Apple-OSS-distributions tools) can use named pub/sub events without porting work. Replicate the API surface byte-for-byte; replace Mach-tied IPC + shared-memory mechanisms with FreeBSD-native equivalents (DO + POSIX shm). Preserve the performance characteristic that reads of state values are zero-IPC — that's what makes notifyd fundamentally different from a sockets-only pub/sub system.

1.2 Non-goals (this iteration)

2. Repository

Monorepo, same as the other ports. notifyd source lives under notifyd/ at the top of freebsd-launchd:

freebsd-launchd/
├── src/                          launchd Apple-imported source
├── configd/                      Apple configd-imported source
├── kmodloader/                   clean-room kmodloader
├── asl/                          Apple syslog-imported source
├── notifyd/                      Apple Libnotify-imported source (this plan)
│   ├── scripts/import-source.sh
│   ├── Makefile                  top-level: build daemon + libnotify + notifyutil
│   ├── compat/
│   └── src/                      forked Apple Libnotify-98.5
│       ├── libnotify.{c,h}       the libnotify client library — API surface
│       ├── notify.h              public API header
│       ├── notify_keys.h         public well-known notification names
│       ├── notify_client.c       client-side state management
│       ├── notify_internal.h
│       ├── notify_private.h
│       ├── table.{c,h}           hash-table for token registry
│       ├── notifyd/              the notifyd daemon
│       │   ├── notifyd.{c,h}     daemon main, dispatch loop
│       │   ├── service.{c,h}     service registration + name table
│       │   ├── notify_proc.c     per-client process tracking
│       │   ├── pathwatch.{c,h}   path-watch notifications via kqueue
│       │   └── notify.conf       (MacOSX flavor; iOS variants dropped)
│       ├── notifyutil/           notifyutil(1) CLI tool
│       └── notifybench/          throughput benchmark harness
└── make-notifyd.sh               STANDALONE — builds + installs

3. Architecture

+------------------------+ | publisher process | | notify_post("name") | +------------+-----------+ | (NSConnection -postName:) v +----------------+ +--------+--------+ +--------------------+ | /etc/notify. |-->| notifyd |<-->| /var/run/notifyd/ | | conf | | (ObjC, | | state-pages | | (early-access | | GNUstep, | | (POSIX shm; one | | table) | | libdispatch) | | uint64 per state | +----------------+ +--------+--------+ | value, atomically | | | updated) | | (delivery +--------------------+ | per token type: v _DISPATCH | _SIGNAL | _FD) +----------------+----------------+ | subscribers | | (libnotify clients in apps) | | token = notify_register_*("name", ...)| +---------------------------------+ AF_UNIX socket: /var/run/notifyd.sock (DO connection — one server, many clients)

3.1 The state-value optimization (load-bearing)

notifyd's defining feature versus a generic pub/sub system: reads of state values are zero-IPC. Apple does this with shared-memory pages — when a process registers for a name with state, it's mapped a page containing the state's uint64_t slot. The daemon writes the slot directly when state changes; client reads are a memory load. No syscall, no IPC roundtrip.

This is why notifyd works for high-frequency state (volume level updates 60Hz, network reachability checks per HTTP request, etc.) where a sockets-roundtrip-per-read would be too slow.

FreeBSD port preserves this: replace Apple's Mach-allocated shared pages with shm_open(2) POSIX shared memory. The daemon allocates one or more shm segments holding the state-value array; clients mmap() them read-only at registration time. Daemon writes go through atomic store intrinsics (__atomic_store_n) so torn reads aren't possible.

3.2 Replacing Mach IPC with DO

The daemon-client channel uses Mach ports in Apple's source — see notify_ipc.defs + notify_old_ipc.defs (two MIG IDLs; old + current). We replace with:

3.3 Path-watch notifications

notifyd implements file-path-watch notifications: subscribe to a path, get woken when the file changes. Apple's pathwatch.c uses kqueue under the hood (Darwin natively); we keep the implementation as-is. DISPATCH_SOURCE_TYPE_VNODE in libdispatch.

3.4 Event sources (libdispatch)

Source typeWatchesReaction
DISPATCH_SOURCE_TYPE_READDO listener fd (launchd-opened)accept new NSConnection clients; their messages flow through the protocol
DISPATCH_SOURCE_TYPE_VNODEeach registered pathfire path-changed notification to subscribers
DISPATCH_SOURCE_TYPE_PROCeach registered client PIDauto-cancel registrations on DISPATCH_PROC_EXIT (don't deliver to dead clients)
DISPATCH_SOURCE_TYPE_VNODE/etc/notify.confreload early-access table on config change
DISPATCH_SOURCE_TYPE_SIGNALSIGTERM, SIGHUP, SIGINFOSIGTERM: clean shutdown. SIGHUP: reload conf. SIGINFO: dump stats.

4. Install paths

ArtifactPathWhy
notifyd binary/usr/libexec/notifydDaemon not invoked directly. Same tier as /usr/libexec/getty, /usr/libexec/netconfigd.
notifyutil CLI/usr/bin/notifyutilAdmin/dev tool: post a name, watch for posts, set/get state. Standard /usr/bin for user-callable commands.
libnotify.so/System/Library/Libraries/libnotify.soThe client library. Apps link against this.
Public headers/System/Library/Headers/notify.h
/System/Library/Headers/notify_keys.h
Apps include via #include <notify.h>.
DO socket/var/run/notifyd.sockPer-host daemon connection. launchd-opened via Sockets={Listeners=...}.
State-page directory/var/run/notifyd/Daemon-managed POSIX shm names. Created by daemon at startup.
Config/etc/notify.confApple's "early-access" table — names that get a state slot allocated at daemon startup so they're available before the publisher posts.
launchd plist/System/Library/LaunchDaemons/org.freebsd.notifyd.plistProject-shipped daemon plist.

5. Locked architectural decisions

DecisionChoice
Source baselineApple Libnotify-98.5. APSL. Latest tag.
Mach IPCNone. Both .defs files deleted; client-server channel rebuilt on DO over AF_UNIX.
Shared-memory state pagesReplaced with POSIX shm_open(2) + mmap(2). Atomic updates via <stdatomic.h>.
Notification delivery typesNOTIFY_TYPE_DISPATCH, NOTIFY_TYPE_SIGNAL, NOTIFY_TYPE_FD, NOTIFY_TYPE_CHECK all preserved. NOTIFY_TYPE_PORT (Mach) dropped.
Public API stabilityAll notify_* functions in notify.h keep their signatures and observable semantics. Source compatibility with Apple-derived apps is mandatory.
Event looplibdispatch dispatch sources only.
Path-watch implkqueue (already what Apple does on Darwin; preserved).
License (top-level)BSD-2-Clause. Apple's libnotify files retain APSL per-file (mix of 1.1 and 2.0 — preserve whichever the file header carries).

6. File-by-file plan (notifyd/src/)

Imported source: Apple Libnotify-98.5. 48 files, ~17k LOC. 12 Mach-tied; 2 MIG .defs.

6.1 Deleted on import (Mach-only or out-of-scope)

6.2 Retained — Phase 2 fate

FileApple LOCActionTarget LOC
libnotify.c~3.5kSubstantial rewrite of the IPC layer. Replace Mach-port-allocation + MIG-stub-call with NSConnection proxy calls. Public notify_* API unchanged. Replace shared-memory mapping (Mach vm_allocate + port handoff) with POSIX shm_open + mmap.~2k
notify_client.c~2kPort. Per-token bookkeeping. Mostly Mach-free; minor pruning.~1.7k
table.{c,h}, table.in.c~1.5kKeep. Hash-table impl for token registry. Pure data structure.~1.5k
notify.h, notify_keys.h, notify_internal.h, notify_private.h~1kKeep. Public API + internal protocol declarations. Drop only the NOTIFY_TYPE_PORT token type.~900
notifyd/notifyd.{c,h}~2kPrune. Replace Mach service-loop init with dispatch_main + libdispatch sources. Drop bootstrap-port registration. Keep config-file load + signal handling.~1.2k
notifyd/service.{c,h}~3kPort. Service registration + name table. Replace Mach-port-based subscriber tracking with PID-keyed table tied to DISPATCH_SOURCE_TYPE_PROC.~2.2k
notifyd/notify_proc.c~1kPort. Per-client process management. Auto-cancel registrations on PROC_EXIT.~800
notifyd/pathwatch.{c,h}~600Keep mostly intact. kqueue-based; portable.~600
notifyutil/notifyutil.c~700Port. The CLI is mostly libnotify-call-and-print; once libnotify works the CLI follows.~600
notifybench/~500Port. Useful for verifying state-page reads stay zero-IPC after our shm_open swap.~500
Manpages (notify*.3, notifyd.8, notifyutil.1)Keep mostly intact. Drop notify_register_mach_port.3.shrinks

Total post-Phase-2: roughly 11-12k LOC vs Apple's ~17k pre-amputation. About 30% deletion (smaller ratio than configd/asl because notifyd is fundamentally smaller and most of the code is portable already).

7. FreeBSD-only wins (vs Apple's Darwin-tied Libnotify)

FeatureApple's notifyd doesThis port (FreeBSD-only)
Daemon-client IPCMach ports + MIG-generated stubs (two flavors: current + legacy)GNUstep DO over AF_UNIX. ~3k LOC of Mach plumbing replaced by ~600 LOC of DO. notify_old_ipc.defs goes away entirely (was for back-compat with pre-Snow-Leopard apps).
State-value pagesMach vm_allocate + port handoff for shared memoryPOSIX shm_open(2) + mmap(2). Same zero-IPC-read characteristic; portable across any Unix.
Subscriber wakeup (NOTIFY_TYPE_DISPATCH)Mach port message → libdispatch sourceEVFILT_USER + libdispatch source. One less indirection layer.
Path notificationskqueue under the hood (already!)kqueue. Identical.
Subscriber bookkeepingMach send-rights tracked per-client; subscriber-died detected via Mach port-no-senders notificationDISPATCH_SOURCE_TYPE_PROC on each subscriber's PID. DISPATCH_PROC_EXIT fires; daemon cancels that PID's registrations. Cleaner.
Build-system gatesiOS / sim / catalyst #ifDelete. One target.

8. Use cases for gershwin

What we get when notifyd lands and gershwin's apps can use it:

8.1 Workspace (the desktop manager)

ConcernNotification nameEffect
Theme / appearance changeorg.freebsd.appearance.theme-changedAppearance prefs panel writes the new theme; posts the name. Every running app subscribed re-themes instantly. No per-app D-Bus connection or polling.
Application activation / focusorg.freebsd.workspace.app-activatedOther apps de-emphasize selves (dim accent colors, pause animations).
Hide / show all (NeXTSTEP signature)org.freebsd.workspace.hide-othersWorkspace single-post; all subscribed apps minimize.
Display configuration changedorg.freebsd.display.changedconfigd or kmodloader detects framebuffer plug; posts. Workspace reflows windows; full-screen video apps react.
Login / logoutorg.freebsd.session.user-logged-in / ...logged-outPer-user launchd agents (mail-checker, dock helpers, calendar-sync) wake. Mirrors Apple's loginwindow flow.

8.2 System-state fanout

ConcernNotification nameEffect
Battery / powerorg.freebsd.power.battery-changedOne powermon daemon (or extension to configd's KernelEventMonitor) reads ACPI; posts current state. Workspace menubar battery icon, lid-close handler, screen-dim policy all subscribe. One source, many subscribers — without notifyd, every consumer would need its own ACPI listener.
Network stateorg.freebsd.config.network-reachableconfigd posts when an interface becomes reachable. Mail, browsers, sync clients subscribe. Apps check on each post — no polling, no per-app socket to configd.
Sleep / wakeorg.freebsd.power.sleep-requested + ...wakeApps save state, pause downloads, dim UI before sleep; resume after wake.
Time / TZ changeorg.freebsd.time.timezone-changedCalendar, Mail, Clock all subscribe. Currently apps poll or recompute on every operation.
Filesystem mount / unmountorg.freebsd.fs.mounted / ...unmountedWorkspace's File Viewer (Finder-equivalent) updates the sidebar. ZFS pool import — mount-aware apps refresh.
Keyboard layout changeorg.freebsd.input.layout-changedApps refresh shortcut displays.
Audio volume / muteorg.freebsd.audio.volume-changedSound prefs slider follows hardware media keys; menubar volume icon updates.

8.3 Defaults / preferences plumbing

This is where notifyd shines compared to alternatives. NSUserDefaults cross-app coordination on macOS is a notifyd story:

  1. App A calls [defaults setObject:newValue forKey:@"FontFamily"]
  2. cfprefsd-equivalent (the prefs daemon) writes the file, posts org.freebsd.prefs.<domain>.changed
  3. App B (subscribed) gets woken, reloads the relevant key
  4. App B's UI updates without a restart

Without notifyd, every app would either poll the prefs file or rely on filesystem-watching primitives like kqueue(EVFILT_VNODE) — clunky compared to a single named post. gershwin's NSUserDefaults gets a noticeable UX upgrade when this works correctly across processes.

8.4 launchd-event-driven service activation

Apple uses notifyd to trigger launchd jobs without explicit RPC:

<key>LaunchEvents</key>
<dict>
  <key>com.apple.notifyd.matching</key>
  <dict>
    <key>org.freebsd.network-reachable</key>
    <dict/>
  </dict>
</dict>

A daemon plist with this LaunchEvents stanza launches its program only when that named notification posts. Auto-update checker idle until network up; backup daemon idle until specific conditions; etc. Replaces both "phase markers" (the proposed RequiresPhase per launchd plan §11.3) and ad-hoc polling with one unified mechanism. Adding LaunchEvents support to launchd alongside this notifyd port unlocks an Apple-native event-driven supervision model.

8.5 Third-party Apple-derived apps "just work"

Practical concrete: any app you'd port from macOS that calls notify_register_dispatch() or notify_post() works without modification. Without notifyd we'd either need to:

With notifyd present, the calls Just Work.

9. launchd integration

9.1 The plist

<?xml version="1.0" encoding="UTF-8"?>
<plist version="1.0">
<dict>
    <key>Label</key>             <string>org.freebsd.notifyd</string>
    <key>ProgramArguments</key>  <array><string>/usr/libexec/notifyd</string></array>
    <key>RunAtLoad</key>         <true/>
    <key>KeepAlive</key>         <true/>
    <key>Sockets</key>           <dict>
        <key>Listeners</key>     <dict>
            <key>SockPathName</key> <string>/var/run/notifyd.sock</string>
            <key>SockType</key>     <string>stream</string>
            <key>SockPathMode</key> <integer>438</integer> <!-- 0666 -->
        </dict>
    </dict>
</dict>
</plist>

launchd opens the AF_UNIX listener and hands the fd to notifyd; client connections queue in the kernel even when notifyd is restarting.

9.2 Boot ordering

notifyd has no dependencies on other daemons — it's a leaf service. Configd may want to use notifyd internally for fan-out (instead of DO callbacks) once both are present, but configd works fine without it. Order it alongside other system daemons; no explicit ordering needed.

10. Licensing

Apple's Libnotify source is APSL (1.1 in older files, 2.0 in newer; preserve whichever each file carries). Same family of license as ASL; FSF-approved free software; not GPL-compatible (irrelevant for our project — no GPL deps).

SourceLicenseHow we handle it
Apple Libnotify-98.5 (per-file APSL headers)APSL 1.1 / 2.0 mixKeep per-file headers verbatim. Our edits inherit APSL via inbound=outbound — those individual files stay APSL regardless of top-level repo license.
This repo's new code (DO bridge, FreeBSD shims, integration glue)BSD-2-ClauseSPDX header on each new file.
GNUstep Foundation, libdispatch (linked, not in tree)LGPL/Apache as applicableListed in NOTICE. Same calculus as launchd/configd.

11. Phased delivery

Phase 0 — repo scaffold + import

Phase 1 — minimal notifyd + libnotify (DO IPC, no shm)

Phase 2 — POSIX shm state pages

Phase 3 — pathwatch + advanced features

Phase 4 — notifyutil CLI

Phase 5 — launchd LaunchEvents integration

Phase 6 — gershwin integration

Phase 7+ — optional, demand-driven

12. Open questions

Q1. Atomic uint64 across architectures. POSIX shm + atomic store works on amd64 native. arm64 + 32-bit hosts we'd want to verify torn-read semantics — atomic_store_explicit(memory_order_relaxed) is the right primitive but compiler / target-arch-specific. Phase 2 testing covers.
Q2. Coexistence with NSDistributedNotificationCenter. GNUstep ships NSDistributedNotificationCenter, which is GNUstep's own pub/sub mechanism (separate from notifyd). Apps using NSDistributedNotificationCenter Just Work today. Apps using notify_post need our notifyd port. Both should coexist — they don't conflict; pick the API that matches the app's expectation. Long-term gershwin question: should new app code prefer one over the other? Decision deferrable.
Q3. Naming convention for project-emitted events. Apple uses com.apple.* for system events. We propose org.freebsd.* for our project's posts (network, power, theme, etc.) and reserve com.apple.* for app-emitted compat names. org.gershwin.* for desktop-specific events. Keep these consistent across all FreeBSD-launchd-ecosystem code.
Q4. Apple's com.apple.system.config.network_change compat. Apple-derived apps registering for that exact name will expect it to fire on network state change. Should configd post both the Apple-canon name and our org.freebsd.config.network-changed? Decision: yes, dual-post — costs nothing, retains source compat with apps that hard-coded the Apple name.
Q5. State-page location and quotas. POSIX shm names default to /dev/shm/ on Linux; FreeBSD uses /tmp/... or swap/... behind the scenes. Naming scheme: org.freebsd.notifyd.state.<n> for daemon-managed segments. Per-host quota for state-value count: start at 16k slots; bump if real workloads need more.
Q6. Bootstrapping order with launchd. notifyd needs to be running before LaunchEvents-using plists can be evaluated. If notifyd is itself launched by launchd, there's a chicken-and-egg if the launchd plist using LaunchEvents is loaded before notifyd is up. Decision: notifyd's own plist has highest priority RunAtLoad, no LaunchEvents; other plists' LaunchEvents are evaluated after notifyd's IPC socket is up. Same pattern as configd.
Q7. Per-user notifyd vs single system-wide daemon. Apple runs one notifyd per session domain (system + each gui/<uid>). For our scope: start with system-wide only. Per-user notifyd waits until per-user launchd is implemented (post-Phase 6 of the launchd plan).

13. References