Skip to content

Commit a346a97

Browse files
authored
Merge pull request #17 from ipfizz/perf/event-driven-macos-runloop
Event-driven macOS run loop: sleep-until-input instead of a 60 Hz poll
2 parents d2dbd1a + 7ddf1f8 commit a346a97

6 files changed

Lines changed: 249 additions & 18 deletions

File tree

src/main/platform/macos/cocoa-backend.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ import {
77
generatePageWorldStub,
88
} from '../../../renderer/api/cross-world-bridge';
99
import { generatePreloadBootstrap } from '../../../renderer/preload-bootstrap';
10-
import { CooperativePump } from '../../run-loop';
10+
import { AdaptiveBlockingPump } from '../../run-loop';
1111
import type {
1212
NativeAppKit,
1313
NativeApplication,
@@ -739,7 +739,7 @@ class MacOSApplication implements NativeApplication {
739739
#started = false;
740740
#app: Handle = 0n;
741741
#appDelegate: Handle = 0n;
742-
#pump: CooperativePump | undefined;
742+
#pump: AdaptiveBlockingPump | undefined;
743743
#readyCallbacks: Array<() => void> = [];
744744
#onActivate: ((hasVisibleWindows: boolean) => void) | undefined;
745745
#onOpenUrl: ((url: string) => void) | undefined;
@@ -777,7 +777,7 @@ class MacOSApplication implements NativeApplication {
777777
this.#distantPast = rt.msgSend(rt.classes.get('NSDate'), rt.selectors.get('distantPast'));
778778
this.#eventPumpMode = rt.msgSend(nsString('kCFRunLoopDefaultMode'), rt.selectors.get('retain'));
779779

780-
this.#pump = new CooperativePump(createMacOSDrain(() => this.#pumpAppEvents()));
780+
this.#pump = new AdaptiveBlockingPump(createMacOSDrain(() => this.#pumpAppEvents()));
781781
this.#pump.start();
782782
this.#started = true;
783783
log.info('application started');

src/main/platform/macos/cocoa-run-loop.ts

Lines changed: 22 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -5,12 +5,14 @@ import { bigIntOut, LIBOBJC_PATH, macOSLibraryAccessor, ptrIn } from './objc';
55
/**
66
* macOS native run-loop drain.
77
*
8-
* Provides the non-blocking "drain once" function the {@link CooperativePump}
9-
* calls each tick: it dispatches pending AppKit input events (via the
10-
* `pumpEvents` callback), then runs `CFRunLoopRunInMode(kCFRunLoopDefaultMode,
11-
* 0, true)` until the loop has nothing left to handle. The AppKit loop is
12-
* serviced without ever blocking Bun's thread. Each drain is wrapped in an
13-
* autorelease pool so per-tick temporary objects are released promptly.
8+
* Returns a function the {@link AdaptiveBlockingPump} calls each tick with a
9+
* timeout: it dispatches pending AppKit input events (via `pumpEvents`), then
10+
* sleeps in `CFRunLoopRunInMode(kCFRunLoopDefaultMode, timeout, true)` until a
11+
* native source is handled or the timeout elapses. A UI event returns it
12+
* immediately (returnAfterSourceHandled), so the thread sleeps when idle yet
13+
* wakes the instant input arrives. Returns whether a source was handled — the
14+
* pump stays responsive while that holds and backs off when it doesn't. Each
15+
* drain runs inside an autorelease pool so per-tick temporaries are released.
1416
*/
1517

1618
const CORE_FOUNDATION_PATH = '/System/Library/Frameworks/CoreFoundation.framework/CoreFoundation';
@@ -48,7 +50,7 @@ const getAutoreleasePool = macOSLibraryAccessor('libobjc autorelease pool', () =
4850
* any non-macOS host (via the lazy accessors). The returned function is cheap
4951
* to call repeatedly and never blocks.
5052
*/
51-
export const createMacOSDrain = (pumpEvents?: () => void): (() => void) => {
53+
export const createMacOSDrain = (pumpEvents?: () => void): ((timeoutMs: number) => boolean) => {
5254
const cf = getCoreFoundation();
5355
const pool = getAutoreleasePool();
5456
const mode = bigIntOut(
@@ -59,16 +61,24 @@ export const createMacOSDrain = (pumpEvents?: () => void): (() => void) => {
5961
),
6062
);
6163

62-
return () => {
64+
return (timeoutMs: number) => {
6365
const poolToken = pool.symbols.objc_autoreleasePoolPush();
6466
try {
6567
pumpEvents?.();
66-
for (let i = 0; i < DRAIN_BUDGET; i += 1) {
67-
const result = cf.symbols.CFRunLoopRunInMode(ptrIn(mode), 0, 1);
68-
if (result !== CF_RUN_LOOP_RUN_HANDLED_SOURCE) {
69-
break;
68+
const handled =
69+
cf.symbols.CFRunLoopRunInMode(ptrIn(mode), timeoutMs / 1000, 1) ===
70+
CF_RUN_LOOP_RUN_HANDLED_SOURCE;
71+
if (handled) {
72+
// Dispatch the event that woke us, then clear any other ready sources
73+
// without blocking so a burst is handled in this tick.
74+
pumpEvents?.();
75+
for (let i = 0; i < DRAIN_BUDGET; i += 1) {
76+
if (cf.symbols.CFRunLoopRunInMode(ptrIn(mode), 0, 1) !== CF_RUN_LOOP_RUN_HANDLED_SOURCE) {
77+
break;
78+
}
7079
}
7180
}
81+
return handled;
7282
} finally {
7383
pool.symbols.objc_autoreleasePoolPop(poolToken);
7484
}

src/main/run-loop.ts

Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -76,3 +76,90 @@ export class CooperativePump {
7676
}
7777
}
7878
}
79+
80+
/** Yields to Bun's event loop once, then runs `tick`. Injected in tests. */
81+
export type TickScheduler = (tick: () => void) => void;
82+
83+
export type AdaptiveBlockingPumpOptions = {
84+
/** Drain timeout (ms) after a tick that handled events; kept small for a responsive UI. Default 8. */
85+
readonly minTimeoutMs?: number;
86+
/** Drain timeout (ms) an idle run backs off to; larger sleeps deeper for less CPU. Default 125. */
87+
readonly maxTimeoutMs?: number;
88+
/** Schedules the next tick after yielding to Bun's loop. Defaults to `setTimeout(tick, 0)`. */
89+
readonly schedule?: TickScheduler;
90+
};
91+
92+
const DEFAULT_MIN_TIMEOUT_MS = 8;
93+
const DEFAULT_MAX_TIMEOUT_MS = 125;
94+
95+
const defaultScheduler: TickScheduler = (tick) => {
96+
setTimeout(tick, 0);
97+
};
98+
99+
/**
100+
* Adaptive blocking run-loop pump.
101+
*
102+
* Drives a native drain that sleeps until a UI event or a timeout (see the
103+
* platform `createDrain`). Each tick the drain blocks for the current timeout
104+
* and reports whether it handled events; the pump then resets the timeout to
105+
* its minimum (input is flowing — stay responsive) or doubles it toward the
106+
* maximum (idle — sleep deeper for near-zero CPU). Between ticks it yields to
107+
* Bun's loop so JS timers, microtasks and IO run. A UI event wakes the drain
108+
* immediately, so input latency stays ~0 regardless of the idle backoff.
109+
*/
110+
export class AdaptiveBlockingPump {
111+
readonly #drain: (timeoutMs: number) => boolean;
112+
readonly #minTimeoutMs: number;
113+
readonly #maxTimeoutMs: number;
114+
readonly #schedule: TickScheduler;
115+
#timeoutMs: number;
116+
#running = false;
117+
118+
constructor(drain: (timeoutMs: number) => boolean, options?: AdaptiveBlockingPumpOptions) {
119+
this.#drain = drain;
120+
this.#minTimeoutMs = options?.minTimeoutMs ?? DEFAULT_MIN_TIMEOUT_MS;
121+
this.#maxTimeoutMs = options?.maxTimeoutMs ?? DEFAULT_MAX_TIMEOUT_MS;
122+
this.#schedule = options?.schedule ?? defaultScheduler;
123+
this.#timeoutMs = this.#minTimeoutMs;
124+
}
125+
126+
get isRunning(): boolean {
127+
return this.#running;
128+
}
129+
130+
/** The drain timeout (ms) the next tick will use. */
131+
get timeoutMs(): number {
132+
return this.#timeoutMs;
133+
}
134+
135+
/** Begin pumping. Idempotent — a second call while running is a no-op. */
136+
start(): void {
137+
if (this.#running) {
138+
return;
139+
}
140+
this.#running = true;
141+
this.#tick();
142+
}
143+
144+
/** Stop pumping. Idempotent — safe to call when not running. */
145+
stop(): void {
146+
this.#running = false;
147+
}
148+
149+
#tick(): void {
150+
if (!this.#running) {
151+
return;
152+
}
153+
let active = false;
154+
try {
155+
active = this.#drain(this.#timeoutMs);
156+
} catch (error) {
157+
// A failure draining one tick must not tear down the whole pump.
158+
log.error('drain tick threw', error);
159+
}
160+
this.#timeoutMs = active
161+
? this.#minTimeoutMs
162+
: Math.min(this.#timeoutMs * 2, this.#maxTimeoutMs);
163+
this.#schedule(() => this.#tick());
164+
}
165+
}

tests/integration/macos/cocoa-run-loop.test.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@ if (currentPlatform() === 'macos') {
2121
test('returns a drain function that runs many times without crashing', () => {
2222
const drain = createMacOSDrain();
2323
for (let i = 0; i < 50; i += 1) {
24-
drain();
24+
drain(0);
2525
}
2626
expect(typeof drain).toBe('function');
2727
});
@@ -49,7 +49,7 @@ if (currentPlatform() === 'macos') {
4949

5050
const drain = createMacOSDrain();
5151
for (let i = 0; i < 60; i += 1) {
52-
drain();
52+
drain(0);
5353
}
5454

5555
expect(msgSendReturnsU8(window, rt.selectors.get('isVisible'))).toBe(1);

tests/unit/main/run-loop.test.ts

Lines changed: 129 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,10 @@
11
import { describe, expect, test } from 'bun:test';
2-
import { CooperativePump, type Ticker } from '../../../src/main/run-loop';
2+
import {
3+
AdaptiveBlockingPump,
4+
CooperativePump,
5+
type Ticker,
6+
type TickScheduler,
7+
} from '../../../src/main/run-loop';
38

49
const manualTicker = (): { ticker: Ticker; tick: () => void; cancelled: () => boolean } => {
510
let onTick: (() => void) | undefined;
@@ -126,3 +131,126 @@ describe('CooperativePump interval', () => {
126131
expect(seenMs).toBeLessThanOrEqual(32);
127132
});
128133
});
134+
135+
const manualScheduler = (): {
136+
schedule: TickScheduler;
137+
run: () => void;
138+
pending: () => boolean;
139+
} => {
140+
let next: (() => void) | undefined;
141+
return {
142+
schedule: (tick) => {
143+
next = tick;
144+
},
145+
run: () => {
146+
const tick = next;
147+
next = undefined;
148+
tick?.();
149+
},
150+
pending: () => next !== undefined,
151+
};
152+
};
153+
154+
describe('AdaptiveBlockingPump start / stop', () => {
155+
test('is not running before start, running after', () => {
156+
const pump = new AdaptiveBlockingPump(() => false, { schedule: manualScheduler().schedule });
157+
expect(pump.isRunning).toBe(false);
158+
pump.start();
159+
expect(pump.isRunning).toBe(true);
160+
});
161+
162+
test('start is idempotent — a second start does not drain twice', () => {
163+
let drains = 0;
164+
const pump = new AdaptiveBlockingPump(
165+
() => {
166+
drains += 1;
167+
return false;
168+
},
169+
{ schedule: manualScheduler().schedule },
170+
);
171+
pump.start();
172+
pump.start();
173+
expect(drains).toBe(1);
174+
});
175+
176+
test('no draining occurs after stop', () => {
177+
const s = manualScheduler();
178+
let drains = 0;
179+
const pump = new AdaptiveBlockingPump(
180+
() => {
181+
drains += 1;
182+
return false;
183+
},
184+
{ schedule: s.schedule },
185+
);
186+
pump.start();
187+
pump.stop();
188+
s.run();
189+
expect(drains).toBe(1);
190+
expect(pump.isRunning).toBe(false);
191+
});
192+
});
193+
194+
describe('AdaptiveBlockingPump adaptive timeout', () => {
195+
test('starts at the minimum and drives the drain with the current timeout', () => {
196+
const s = manualScheduler();
197+
const seen: number[] = [];
198+
const pump = new AdaptiveBlockingPump(
199+
(ms) => {
200+
seen.push(ms);
201+
return false;
202+
},
203+
{ minTimeoutMs: 10, maxTimeoutMs: 40, schedule: s.schedule },
204+
);
205+
pump.start();
206+
s.run();
207+
s.run();
208+
expect(seen).toEqual([10, 20, 40]);
209+
});
210+
211+
test('backs off exponentially toward the max while idle, then caps', () => {
212+
const s = manualScheduler();
213+
const pump = new AdaptiveBlockingPump(() => false, {
214+
minTimeoutMs: 8,
215+
maxTimeoutMs: 64,
216+
schedule: s.schedule,
217+
});
218+
pump.start();
219+
expect(pump.timeoutMs).toBe(16);
220+
s.run();
221+
expect(pump.timeoutMs).toBe(32);
222+
s.run();
223+
expect(pump.timeoutMs).toBe(64);
224+
s.run();
225+
expect(pump.timeoutMs).toBe(64);
226+
});
227+
228+
test('snaps back to the minimum when a tick handles events', () => {
229+
const s = manualScheduler();
230+
let active = false;
231+
const pump = new AdaptiveBlockingPump(() => active, {
232+
minTimeoutMs: 8,
233+
maxTimeoutMs: 64,
234+
schedule: s.schedule,
235+
});
236+
pump.start();
237+
s.run();
238+
expect(pump.timeoutMs).toBeGreaterThan(8);
239+
active = true;
240+
s.run();
241+
expect(pump.timeoutMs).toBe(8);
242+
});
243+
244+
test('a throwing drain does not stop the pump and still reschedules', () => {
245+
const s = manualScheduler();
246+
const pump = new AdaptiveBlockingPump(
247+
() => {
248+
throw new Error('native hiccup');
249+
},
250+
{ schedule: s.schedule },
251+
);
252+
pump.start();
253+
expect(pump.isRunning).toBe(true);
254+
expect(s.pending()).toBe(true);
255+
});
256+
});

website/src/content/docs/changelog.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,12 @@ order: 2
66

77
The current version is **`0.1.0-alpha.5`**, live on npm - `npm i bunmaska`. Newest first; still a curated snapshot rather than a per-commit log.
88

9+
## Unreleased
10+
11+
**Event-driven macOS run loop.** The cooperative pump no longer polls AppKit at a fixed 60 Hz. It sleeps in `CFRunLoopRunInMode` until a native event arrives - input wakes it instantly - and backs off adaptively when idle. On an idle window that is roughly **10x less CPU** (~2.5% → ~0.2%) with no added input latency. One honest trade-off: while the UI is idle, *main-process* JS timers run at up to ~125 ms granularity (renderer `requestAnimationFrame` and IPC are unaffected - they ride the native event path).
12+
13+
A true libuv-style integration like Electron's is not possible from pure `bun:ffi` today: Bun's loop is uSockets, not libuv, and its tick/wakeup primitives are not exported ([oven-sh/bun#18546](https://github.com/oven-sh/bun/issues/18546)). This is the best event-driven behavior achievable while staying single-threaded.
14+
915
## `0.1.0-alpha.5`
1016

1117
Frameless windows, a real preload, and a dev loop that doesn't blink.

0 commit comments

Comments
 (0)