AppKit F05 — loop wakeup & external fds
How a second thread (or an arbitrary file descriptor) wakes [NSApp run], and what it costs, per the F05 feature spec. A worker thread drives three wakeup mechanisms into the main run loop for 30 s and the demo reports per-mechanism latency: an NSEventTypeApplicationDefined event posted with postEvent:atStart: (mech=postevent), a version-0 CFRunLoopSource signalled cross-thread (mech=cfrunloopsource), and a pipe(2) integrated through a CFFileDescriptor run-loop source (fd_tick). The program is ./examples/f05-loop-wakeup/app.d (with the shared instrument.d logger), built on the scaffold recipe.
Last reviewed: June 11, 2026
All run findings are A[ssh]: built and executed on mac-bsn (aarch64-darwin, macOS 26.3.1, LDC 1.41.0) over SSH with the console session locked (the window registers with the WindowServer but is not composited — the scaffold's sidecar evidence). F05 has no interactive component, so the locked screen does not distort the measurement: the run loop, the cross-thread injection, and the fd integration all run identically whether or not pixels reach the glass. The one caveat is the cold-start tax (below): the very first wakeup pays for warming up an SSH-launched, non-frontmost app's loop, which an interactive session would partly hide.
| Measurement | Value |
|---|---|
| Wakeup mechanisms compared | 3 — postevent, cfrunloopsource, and an fd via CFFileDescriptor |
| Samples (30 s run) | 300 each for postevent/cfrunloopsource (10 Hz), 210 fd (7 Hz) |
| Steady-state median latency | postevent 319 µs, cfrunloopsource 398 µs, fd 72 µs |
| Steady-state p99 latency | postevent 540 µs, cfrunloopsource 639 µs, fd 470 µs |
| Cold-start tax (first wakeup only) | ~75 ms once per run — warming an SSH-launched, non-frontmost loop |
| Raw fd in the loop | Only via a CF/dispatch adapter — never poll()-style directly |
| Exit | clean 0 (loop_exit wakes=600 fd_ticks=210), worker joined, no leaks |
What the demo does
A single worker thread (D core.thread, which runs fine on darwin) drives every mechanism from a shared monotonic clock, so the three are directly comparable:
- every 100 ms it posts one
posteventwakeup and signals thecfrunloopsource, each carrying the send-timestamp (posteventin the event'sdata1;cfrunloopsourcevia an SPSC ring, since aCFRunLoopSourcecoalesces multiple signals into oneperform); - every ~143 ms (7 Hz) it writes an 8-byte send-timestamp into a
pipe, whose read end is wrapped in aCFFileDescriptorrun-loop source; - the main thread's callbacks — an overridden
-[NSApplication sendEvent:]forpostevent, the source'sperformforcfrunloopsource, and the descriptor's callout forfd_tick— each computenow − sendand append the sample.
At 30 s the worker posts a subtype=DONE application-defined event; sendEvent: sees it, calls stop: plus the synthetic-event post the scaffold documented, run returns, the worker is joined, and per-mechanism min/median/p99/max is logged.
NOTE
To intercept postevent wakeups the demo makes NSApp an instance of a D-defined WakeApp : NSApplication subclass — calling +sharedApplication on the subclass installs it as the shared instance (no Info.plist principal-class entry needed), so the overridden sendEvent: is live. sharedApplication is declared only on the leaf (returning WakeApp) to avoid the Objective-C covariant-return clash the scaffold flagged.
The two cross-thread event mechanisms A[ssh]
The cold start, verbatim — the worker and the run loop both begin at ~88 ms, and the first wakeup lands ~75 ms later:
87826 APPKIT_F05 step name=NSApp_run
162981 APPKIT_F05 wakeup latency_us=75122.0 mech=cfrunloopsource n=1
164489 APPKIT_F05 fd_tick t=1 latency_us=76631.0
164597 APPKIT_F05 wakeup latency_us=76756.0 mech=postevent n=2
190449 APPKIT_F05 wakeup latency_us=158.0 mech=postevent n=3
190508 APPKIT_F05 wakeup latency_us=203.0 mech=cfrunloopsource n=4That first ~75 ms is a one-time tax, not per-wakeup: it is the cost of an SSH-launched, non-frontmost app finishing its first run-loop drawing/activation pass before it services injected work. From the second cycle on, both mechanisms are sub-millisecond and stay there for the whole 30 s:
14589684 APPKIT_F05 wakeup latency_us=300.0 mech=postevent n=291
14589765 APPKIT_F05 wakeup latency_us=361.0 mech=cfrunloopsource n=292
14690155 APPKIT_F05 wakeup latency_us=307.0 mech=postevent n=293
14690243 APPKIT_F05 wakeup latency_us=384.0 mech=cfrunloopsource n=294postevent—[NSApp postEvent:atStart:NO]appends anNSEventTypeApplicationDefinedevent (built withotherEventWithType:…, the timestamp packed intodata1/data2) to the application event queue;[NSApp run]dequeues it and calls oursendEvent:override. It is the lowest-median cross-thread path (319 µs) because the event queue is exactly whatrunis already blocked on.cfrunloopsource—CFRunLoopSourceSignalmarks the source pending andCFRunLoopWakeUpkicks the loop; the loop runs the source'sperformon its next pass. It is consistently ~80 µs slower at the median (398 µs) thanposteventand has a slightly fatter p99 — the extra hop is the loop re-arming a source vs. delivering an already-queued event. Because signals coalesce, the demo carries timestamps in a side ring and theperformcallback drains all pending ones (a singleperformcan answer several signals).
Latency distributions A[ssh]
Per-mechanism, over the 30 s run (300 / 300 / 210 samples). max is the single cold-start sample in every column; the steady-state shape is the min/median/p99:
| Mechanism | n | min | median | p99 | max (cold start) |
|---|---|---|---|---|---|
postevent | 300 | 131 µs | 319 µs | 540 µs | 76 756 µs |
cfrunloopsource | 300 | 167 µs | 398 µs | 639 µs | 75 122 µs |
fd (CFFileDescriptor pipe) | 210 | 38 µs | 72 µs | 470 µs | 76 631 µs |
The headline ordering is stable across runs: fd ≪ postevent < cfrunloopsource. The fd path is ~4–5× faster at the median than either event path because its callout runs as a primitive run-loop source with no NSEvent allocation, queueing, or sendEvent: dispatch in the way — it is the closest AppKit gets to a bare poll()-readiness wakeup. The trade-off is that you reach it only through an adapter (next section), and that the CFFileDescriptor disarms itself after every fire, so the callout must re-CFFileDescriptorEnableCallBacks or it goes deaf after one tick.
Arbitrary fds join the loop only through an adapter A[ssh]
The central F05 finding on macOS: AppKit's run loop never waits on a raw file descriptor the way an epoll/poll loop does. [NSApp run] pumps a CFRunLoop, whose only fd-shaped input primitives are CoreFoundation/libdispatch objects. A bare int fd must be wrapped:
CFFileDescriptor(this demo):CFFileDescriptorCreate(fd) → CFFileDescriptorCreateRunLoopSource → CFRunLoopAddSource. The callout fires on the main loop when the fd is readable; the demo drains the pipe and logs eachfd_tick. Verbatim, interleaved with the event wakeups:text232983 APPKIT_F05 fd_tick t=2 latency_us=49.0 375829 APPKIT_F05 fd_tick t=3 latency_us=74.0Cost: a CF object per fd, manual callback re-arming after each fire, and a
performhop. There is no way to hand AppKit the fd itself.dispatch_source_t(the documented alternative — not run here): adispatch_source_create(DISPATCH_SOURCE_TYPE_READ, fd, 0, dispatch_get_main_queue())delivers an event handler on the main queue, which on a Cocoa app is drained by the same main run loop. It self-rearms (noEnableCallBacksdance) and integrates with GCD cancellation, at the cost of pulling in libdispatch semantics. For a windowing framework that already owns a thread pool,dispatch_sourceis usually the better adapter; for a single fd with no GCD elsewhere,CFFileDescriptoris lighter.
Either way the shape a framework must offer on macOS is a run-loop-source adapter, not fd registration — the mirror image of Wayland/X11, where the window connection is an fd you poll() directly. This is the platform split the readiness-vs-completion discussion predicts: AppKit hides its readiness fds behind run-loop sources, so a portable loop abstraction needs a per-platform "wake the native loop" primitive rather than a single shared poll set.
Thread-safety rules
Who may call each injector, and from where — all three are exercised from the worker thread here and documented thread-safe by Apple:
| Primitive | Safe off the main thread? | Notes |
|---|---|---|
-[NSApplication postEvent:atStart:] | Yes | The canonical way to wake run from another thread |
CFRunLoopSourceSignal + CFRunLoopWakeUp | Yes | Signal is atomic; WakeUp targets the captured main loop |
write(2) to the pipe | Yes | Plain POSIX; the CF source observes readability on the loop |
The callbacks (sendEvent:/perform/callout) | Main thread only | They run on the run loop — append samples without locking |
Because every callback runs on the main thread, the sample buffers need no locking; the only cross-thread datum is the cfrunloopsource timestamp ring (single-producer, single-consumer, lock-free). A secondary D core.thread calling Cocoa wraps its work in an autoreleasepool (objective-d's autoreleasepool_push/_pop); [NSApp run] drains its own per-event pools.
Findings summary (for event-sequences.md)
- Three wakeup mechanisms, one ordering:
fd(median 72 µs) ≪postevent(319 µs) <cfrunloopsource(398 µs). All sub-millisecond at p99 in steady state; all pay a single ~75 ms cold-start tax warming an SSH-launched, non-frontmost loop. posteventbeatscfrunloopsourcebecause the run loop is already blocked on the event queue, while aCFRunLoopSourceadds a re-arm/performhop.posteventis also the simplest to carry a payload (data1/data2);cfrunloopsourceneeds a side channel and must tolerate signal coalescing.- Raw fds never enter the loop directly —
CFFileDescriptorordispatch_sourceis mandatory; the former needs manual re-arming after each fire. This is the inverse of the Wayland/X11 "the connection is apoll-able fd" model and the key integration constraint a cross-platform loop must abstract. - Thread-safety is asymmetric: the injectors (
postEvent:,CFRunLoopSourceSignal,write) are callable from any thread; the callbacks they trigger run only on the main run loop, which is what keeps the sample bookkeeping lock-free. - Clean bounded exit: 600 wakeups + 210 fd ticks in 30 s, worker joined,
0, no leaks.
Sources
- This demo —
./examples/f05-loop-wakeup/app.d,./examples/f05-loop-wakeup/instrument.d; the AppKit scaffold findings (subclass recipe,stop:+ synthetic-post idiom) and the AppKit survey. - Feature specs — F05 loop wakeup; the readiness-vs-completion concept; the related F03 modal loop (run-loop modes), F04 frame pacing.
- Apple Developer documentation (Wayback-pinned, bot-hostile host):
NSApplication,postEvent:atStart:,stop:,NSEvent,NSEventTypeApplicationDefined,CFRunLoopSource,kCFRunLoopCommonModes,CFFileDescriptor,dispatch_source_create.