Skip to content

Event-driven macOS run loop: sleep-until-input instead of a 60 Hz poll - #17

Merged
ipfizz merged 1 commit into
mainfrom
perf/event-driven-macos-runloop
Jun 29, 2026
Merged

Event-driven macOS run loop: sleep-until-input instead of a 60 Hz poll#17
ipfizz merged 1 commit into
mainfrom
perf/event-driven-macos-runloop

Conversation

@ipfizz

@ipfizz ipfizz commented Jun 29, 2026

Copy link
Copy Markdown
Owner

What

The macOS cooperative pump polled AppKit at a fixed 60 Hz (CFRunLoopRunInMode(…, timeout=0) on a 16 ms setInterval). This replaces that with an event-driven, adaptive loop:

  • The drain now sleeps in CFRunLoopRunInMode(default, timeout, returnAfterSourceHandled) — a native UI event returns it instantly, idle ticks just time out.
  • A new AdaptiveBlockingPump drives it: timeout snaps to a small min (8 ms) while events flow (responsive) and backs off exponentially to a max (125 ms) when idle (deep sleep). It yields to Bun's loop between drains so JS timers/microtasks/IO still run.

Measured on macOS (a real packaged calculator)

Before (60 Hz poll) After (adaptive)
Idle CPU ~2.5 % ~0.2 % (~10× lower)
Input latency (incl. from deep idle) ≤16 ms ~0 (CFRunLoop wakes on the event)
JS timer granularity while idle ~50 ms ~125 ms (= the max backoff)
app.quit() clean exit ✓ (pump stops, process exits)

The one honest trade-off: while the UI is idle, main-process JS timers run at up to ~125 ms granularity. Renderer requestAnimationFrame (native CADisplayLink) and IPC are unaffected — they ride the native event path and keep the pump at min while active.

Why not a true libuv/CFRunLoop embedding (Electron-style)?

Investigated thoroughly against the Bun + Electron sources. It is not reachable from pure bun:ffi today: Bun's main loop is uSockets (us_loop_t), not libuv; the exported uv_* are NAPI stubs (uv_default_loop() panics); and the real tick/wakeup symbols (us_loop_run_bun_tick, us_wakeup_loop) are not exported (oven-sh/bun#18546). Blocking a native loop on the JS thread is also unsafe (JSC heap-access toggling). So this adaptive pump is the best event-driven behavior achievable while keeping bunmaska's single-pumped-thread identity. If Bun later exports those primitives, full integration becomes straightforward.

Scope

macOS only by design — Linux and Windows keep the existing CooperativePump untouched. New: AdaptiveBlockingPump + 7 unit tests; the macOS drain returns a handled-source signal. Validate green (1630 tests, 0 fail).

🤖 Generated with Claude Code

The macOS drain now sleeps in CFRunLoopRunInMode with a timeout instead of
spin-polling at 60Hz: a native event wakes it instantly (returnAfterSourceHandled)
and idle ticks just time out. A new AdaptiveBlockingPump drives it, resetting the
timeout to a small minimum while input flows and backing off exponentially to a
maximum when idle. Idle CPU on a window drops ~10x (~2.5% to ~0.2%) with no added
input latency; main-process JS timers run at up to the max (~125ms) granularity
while idle, which renderer rAF and IPC avoid by riding the native event path.

A true libuv/CFRunLoop embedding (Electron-style) is not reachable from bun:ffi:
Bun's loop is uSockets not libuv and its tick/wakeup symbols are not exported
(oven-sh/bun#18546). Linux and Windows keep the existing cooperative pump.
@ipfizz
ipfizz merged commit a346a97 into main Jun 29, 2026
3 checks passed
@ipfizz
ipfizz deleted the perf/event-driven-macos-runloop branch June 29, 2026 06:06
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant