@gjsify/node-gi 0.13.0 → 0.20.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/loop.cc ADDED
@@ -0,0 +1,766 @@
1
+ // SPDX-License-Identifier: MIT
2
+ // libuv <-> GLib main loop bridge: startMainLoop (uv-in-GLib GSource) +
3
+ // iterateMainContext + the uv-driven GLib auto-pump (GLib-in-libuv, the
4
+ // non-blocking case).
5
+
6
+ #include "common.h"
7
+
8
+ #include <unordered_map>
9
+
10
+ #ifndef _WIN32
11
+ // g_source_add_unix_fd / g_source_modify_unix_fd embed libuv's backend fd into a
12
+ // GLib GSource — POSIX-only (declared in glib-unix.h; absent on Windows, where
13
+ // GLib splits platform APIs into -Unix/-Win32 namespaces and libuv uses IOCP, not
14
+ // an fd-polling backend). ONLY the UvLoopSource path (the BLOCKING-GLib-main-loop
15
+ // co-pump) is guarded out on Windows; the uv-driven pump below (prepare/check/timer
16
+ // → g_main_context_iteration) has no unix_fd dependency and stays active, so async
17
+ // Gio/timers/DBus still work on Windows (degraded to timed polling — see SyncPumpPolls).
18
+ #include <glib-unix.h>
19
+ #endif
20
+
21
+ namespace nodegi {
22
+
23
+ // ---- libuv <-> GLib main loop bridge (milestone: mainloop) ----
24
+ //
25
+ // Port of node-gtk's src/loop.cc (romgrk and contributors, MIT) to N-API. Nests
26
+ // Node's libuv loop inside GLib's main loop: a GSource polls libuv's backend fd
27
+ // and runs uv_run(UV_RUN_NOWAIT) on dispatch, so a blocking GLib main loop
28
+ // (GLib.MainLoop.run / GApplication.run) keeps Node timers/promises/IO alive —
29
+ // matching GJS, where the GLib loop IS the process loop. Nesting GLib inside uv
30
+ // is impractical (uv exposes no external prepare/check hook), so we nest the
31
+ // other way, exactly as node-gtk does.
32
+ //
33
+ // Main-thread only (worker_threads would need a per-context source); the GLib
34
+ // default context is iterated on the same thread Node runs on.
35
+ struct UvLoopSource {
36
+ GSource source;
37
+ uv_loop_t* loop;
38
+ gpointer fd_tag; // POSIX: the uv_backend_fd poll tag (unused on Windows)
39
+ gboolean fd_polled; // POSIX: whether the backend fd is currently polled
40
+ #ifdef _WIN32
41
+ gint64 win_next_wake_us; // Windows: monotonic time of the next mandatory uv co-pump
42
+ // (G_MAXINT64 = parked: uv is idle, sleep until a GLib source wakes us)
43
+ #endif
44
+ };
45
+
46
+ static napi_env g_loop_env = nullptr; // captured at startMainLoop (main thread)
47
+ static gboolean g_loop_started = FALSE;
48
+ static napi_ref g_process_ref = nullptr; // process
49
+ static napi_ref g_tick_callback_ref = nullptr; // process._tickCallback
50
+
51
+ // TRUE while the uv-driven auto-pump (below) is inside its own GLib work —
52
+ // draining the context or running the prepare/query hint pass. The UvLoopSource
53
+ // consults it to PARK itself (mask the backend fd, report not-ready, skip
54
+ // uv_run): a pump-driven context iteration must never dispatch the uv-in-GLib
55
+ // source, or it would nest uv_run inside the very uv callback that drives the
56
+ // pump (uv_run is not reentrant) — and recursively re-enter the pump itself.
57
+ static gboolean g_in_uv_pump = FALSE;
58
+
59
+ // Drain Node's nextTick queue + run a microtask checkpoint. process._tickCallback
60
+ // invoked through napi_make_callback runs the tick queue, and the surrounding
61
+ // callback scope's close performs the microtask checkpoint — the N-API analogue
62
+ // of node-gtk's CallMicrotaskHandlers (process._tickCallback +
63
+ // Isolate::PerformMicrotaskCheckpoint). Best-effort: skipped if a JS exception is
64
+ // already pending (it will surface when the blocking run() returns).
65
+ //
66
+ // Limitation (node-gtk #442/#121): when the blocking run() is nested inside an
67
+ // outer async callback scope (node:test, an await, a signal handler), V8 defers
68
+ // the checkpoint to that outer scope, so promise continuations queued before the
69
+ // run() do not drain until run() returns. nextTick still drains; timers/I/O the
70
+ // loop dispatches are unaffected. The robust fix lives in L1 (defer the run() to
71
+ // a macrotask when a microtask checkpoint is in progress).
72
+ //
73
+ // Cross-platform: process._tickCallback drains BOTH the nextTick queue AND the
74
+ // microtask checkpoint explicitly (node's task_queues.js calls runMicrotasks() at
75
+ // its end), so this settles an async DBus reply / GLib-timeout-resolved await
76
+ // continuation regardless of callback-scope depth. On Windows it is the timer-driven
77
+ // UvLoopSource (below) that calls this during a blocking GLib loop.
78
+ static void DrainMicrotasks() {
79
+ if (g_loop_env == nullptr || g_tick_callback_ref == nullptr || g_process_ref == nullptr) return;
80
+ napi_env env = g_loop_env;
81
+ bool pending = false;
82
+ if (napi_is_exception_pending(env, &pending) != napi_ok || pending) return;
83
+
84
+ napi_handle_scope scope;
85
+ if (napi_open_handle_scope(env, &scope) != napi_ok) return;
86
+ napi_value process_v = nullptr, tick = nullptr, result = nullptr;
87
+ if (napi_get_reference_value(env, g_process_ref, &process_v) == napi_ok &&
88
+ napi_get_reference_value(env, g_tick_callback_ref, &tick) == napi_ok &&
89
+ process_v != nullptr && tick != nullptr) {
90
+ napi_make_callback(env, nullptr, process_v, tick, 0, nullptr, &result);
91
+ }
92
+ napi_close_handle_scope(env, scope);
93
+ }
94
+
95
+ // ---- cross-runtime microtask checkpoint (Bun/Deno) --------------------------
96
+ //
97
+ // Node's napi_make_callback performs the nextTick + microtask checkpoint when
98
+ // its callback scope closes, so promise continuations queued by a
99
+ // loop-dispatched GLib→JS callback drain at the callback boundary even while a
100
+ // blocking GLib loop owns the thread. Bun's and Deno's N-API implementations do
101
+ // NOT (Deno's napi_make_callback is a plain Function::Call — refs/deno
102
+ // ext/napi/node_api.rs; Deno's own FFI trampoline drains explicitly for exactly
103
+ // this reason, refs/deno ext/ffi/callback.rs), and with the runtime's event
104
+ // loop paused for the lifetime of a blocking run() the queue never drains — an
105
+ // async (Promise-returning) DBus method handler never sent its reply
106
+ // (client-side "Timeout was reached"), while GJS drains the promise-job queue
107
+ // whenever the last JS frame exits.
108
+ //
109
+ // Portable fix: N-API exposes no engine microtask checkpoint, but both runtimes
110
+ // expose their OWN drain primitive to JS (Bun: `bun:jsc` drainMicrotasks —
111
+ // JSC's VM drain, callable mid-stack by design; Deno: core.runMicrotasks →
112
+ // Isolate::PerformMicrotaskCheckpoint via a reentrant op). index.js registers
113
+ // it here on non-Node runtimes only; the loop-dispatched trampolines
114
+ // (signals.cc / calls.cc / class.cc) invoke NodeGiMaybeDrainMicrotasks at their
115
+ // OUTERMOST boundary (g_loopDispatchDepth == 0). Node never registers — its
116
+ // checkpoint already runs natively — so this is a guaranteed no-op there.
117
+ //
118
+ // NOT drained from a GSource prepare phase: running JS mid-iteration from
119
+ // prepare broke the GDK frame clock (the Excalibur stall — see signals.cc);
120
+ // the drain runs strictly AFTER the dispatched handler returned.
121
+
122
+ // TRUE while the registered drain runs: a drained microtask that re-enters the
123
+ // loop (a nested blocking run dispatching further callbacks) must not recurse
124
+ // into another drain — the engines' own checkpoints refuse re-entry anyway
125
+ // (V8 no-ops while IsRunningMicrotasks; matches Node, where a checkpoint
126
+ // cannot re-enter itself).
127
+ static bool g_in_microtask_drain = false;
128
+
129
+ // setMicrotaskDrain(drain) — register the runtime-native microtask drain for
130
+ // this env. Called by index.js on Bun/Deno only (never on Node).
131
+ Napi::Value SetMicrotaskDrain(const Napi::CallbackInfo& info) {
132
+ Napi::Env env = info.Env();
133
+ if (info.Length() < 1 || !info[0].IsFunction()) {
134
+ Napi::TypeError::New(env, "setMicrotaskDrain(drain: function)").ThrowAsJavaScriptException();
135
+ return env.Undefined();
136
+ }
137
+ NodeGiEnvData* d = EnvData(env);
138
+ if (d == nullptr) return env.Undefined();
139
+ if (d->microtaskDrain != nullptr) {
140
+ napi_delete_reference(env, d->microtaskDrain);
141
+ d->microtaskDrain = nullptr;
142
+ }
143
+ napi_create_reference(env, info[0], 1, &d->microtaskDrain);
144
+ return env.Undefined();
145
+ }
146
+
147
+ void NodeGiMaybeDrainMicrotasks(napi_env env) {
148
+ NodeGiEnvData* d = EnvData(env);
149
+ if (d == nullptr || d->microtaskDrain == nullptr) return; // Node: never registered
150
+ if (g_in_microtask_drain) return;
151
+ // A failed callback skips the queues (Node parity: an InternalCallbackScope
152
+ // marked failed skips its task-queue processing) — and NodeGiJsAvailable also
153
+ // covers env teardown, where JS must not be entered at all.
154
+ if (!NodeGiJsAvailable(env)) return;
155
+ napi_handle_scope scope = nullptr;
156
+ if (napi_open_handle_scope(env, &scope) != napi_ok) return;
157
+ napi_value fn = nullptr;
158
+ if (napi_get_reference_value(env, d->microtaskDrain, &fn) == napi_ok && fn != nullptr) {
159
+ napi_value undef = nullptr;
160
+ napi_get_undefined(env, &undef);
161
+ napi_value result = nullptr;
162
+ g_in_microtask_drain = true;
163
+ napi_status st = napi_call_function(env, undef, fn, 0, nullptr, &result);
164
+ g_in_microtask_drain = false;
165
+ // The drain primitive itself must never wedge the loop on a pending
166
+ // exception (microtask exceptions are reported inside the engines' own
167
+ // checkpoints, so this only fires on a broken registration).
168
+ if (st != napi_ok) SurfacePendingException(env, "microtask drain");
169
+ }
170
+ napi_close_handle_scope(env, scope);
171
+ }
172
+
173
+ #ifndef _WIN32
174
+ static gboolean uv_source_prepare(GSource* base, gint* timeout) {
175
+ UvLoopSource* s = reinterpret_cast<UvLoopSource*>(base);
176
+
177
+ // Parked while the uv-driven auto-pump iterates the context: report not-ready
178
+ // with an infinite timeout and mask the backend fd (the `alive == FALSE` shape
179
+ // below), so a pump-driven iteration never dispatches this source — libuv is
180
+ // already live and running us; co-pumping it from inside itself would nest
181
+ // uv_run reentrantly. Also: while parked the uv backend timeout must not leak
182
+ // into the pump's g_main_context_query() timeout hint (a feedback loop).
183
+ if (g_in_uv_pump) {
184
+ if (s->fd_tag != nullptr && s->fd_polled) {
185
+ g_source_modify_unix_fd(&s->source, s->fd_tag, static_cast<GIOCondition>(0));
186
+ s->fd_polled = FALSE;
187
+ }
188
+ *timeout = -1;
189
+ return FALSE;
190
+ }
191
+
192
+ uv_update_time(s->loop);
193
+ DrainMicrotasks();
194
+
195
+ gboolean alive = uv_loop_alive(s->loop);
196
+ // Toggle whether GLib polls uv's backend fd: an unref'd-but-active uv handle
197
+ // keeps the backend fd perpetually ready, which would busy-spin GLib at 100%
198
+ // CPU when the loop is otherwise dead. Mask the fd while dead so GLib actually
199
+ // blocks until a GLib source wakes us; restore it the moment uv is alive again.
200
+ if (s->fd_tag != nullptr && alive != s->fd_polled) {
201
+ g_source_modify_unix_fd(
202
+ &s->source, s->fd_tag,
203
+ alive ? static_cast<GIOCondition>(G_IO_IN | G_IO_OUT | G_IO_ERR) : static_cast<GIOCondition>(0));
204
+ s->fd_polled = alive;
205
+ }
206
+
207
+ if (!alive) {
208
+ *timeout = -1; // sleep until a GLib source wakes us
209
+ return FALSE;
210
+ }
211
+ int t = uv_backend_timeout(s->loop);
212
+ *timeout = t;
213
+ return t == 0; // ready immediately when uv has work due now
214
+ }
215
+
216
+ static gboolean uv_source_dispatch(GSource* base, GSourceFunc /*callback*/, gpointer /*user_data*/) {
217
+ // Belt-and-braces for the pump parking above: a stale unmasked-fd readiness
218
+ // from an earlier blocking-loop epoch could still dispatch us once — never
219
+ // nest uv_run inside a pump-driven iteration.
220
+ if (g_in_uv_pump) return G_SOURCE_CONTINUE;
221
+ UvLoopSource* s = reinterpret_cast<UvLoopSource*>(base);
222
+ uv_run(s->loop, UV_RUN_NOWAIT);
223
+ DrainMicrotasks();
224
+ return G_SOURCE_CONTINUE;
225
+ }
226
+
227
+ static GSourceFuncs uv_source_funcs = {
228
+ uv_source_prepare, nullptr, uv_source_dispatch, nullptr, nullptr, nullptr,
229
+ };
230
+ #else // _WIN32 — the UvLoopSource, readiness-bounded timer instead of uv-backend-fd-driven.
231
+ //
232
+ // Windows/IOCP has no uv backend fd to embed in a GLib GSource, so the co-pump of
233
+ // Node's loop during a BLOCKING GLib main loop can't wake on backend-fd readiness the
234
+ // way POSIX does. Instead the source schedules its next dispatch off uv's OWN state,
235
+ // using only PUBLIC libuv API (uv_loop_alive + uv_backend_timeout — the same two calls
236
+ // the POSIX prepare uses):
237
+ // • uv idle (no active handles/reqs) → PARK: win_next_wake_us = G_MAXINT64, sleep -1
238
+ // until a GLib source wakes us. The Windows twin of the POSIX backend-fd mask, so a
239
+ // blocking GLib loop with an otherwise-quiet Node loop no longer spins every 5 ms.
240
+ // • uv alive → dispatch after min(uv_backend_timeout, cap): a Node timer due in < cap
241
+ // ms wakes at its actual deadline (not rounded up to a fixed 5 ms), while the CAP
242
+ // bounds the wait so IOCP I/O completions — which arrive on a HANDLE we can NOT embed
243
+ // in GLib's poll (uv_backend_fd() == -1, and loop->iocp is a private libuv struct
244
+ // field we must not depend on) — are still serviced by an unconditional uv_run within
245
+ // cap ms. Full zero-latency IOCP readiness would need that private HANDLE, so the cap
246
+ // stays as the I/O-completion poll (see the honesty note in StartMainLoop).
247
+ // Each dispatch runs uv_run(UV_RUN_NOWAIT) (Node timers/I/O) + DrainMicrotasks() (nextTick
248
+ // + microtask checkpoint via process._tickCallback); the DRAIN settles an async DBus reply
249
+ // (or any GLib-timeout-resolved `await`) posted through a Promise `.then`, exactly as the
250
+ // POSIX fd-driven source does. Parked while the uv-driven pump owns the context (g_in_uv_pump).
251
+ static const gint NODE_GI_WIN_UV_POLL_CAP_MS = 5;
252
+
253
+ // Next mandatory-poll deadline from uv's own next-due time, capped so unwatchable IOCP I/O
254
+ // completions are still serviced within the cap. G_MAXINT64 when uv is idle (park the
255
+ // metronome — sleep until a GLib source wakes us). Public libuv API only.
256
+ static gint64 WinNextUvWake(UvLoopSource* s, gint64 now) {
257
+ if (!uv_loop_alive(s->loop)) return G_MAXINT64;
258
+ int t = uv_backend_timeout(s->loop); // ms to uv's next due work: 0 = now, > 0 = timer, < 0 = I/O-wait
259
+ gint interval = (t >= 0 && t < NODE_GI_WIN_UV_POLL_CAP_MS) ? t : NODE_GI_WIN_UV_POLL_CAP_MS;
260
+ return now + static_cast<gint64>(interval) * 1000;
261
+ }
262
+
263
+ static gboolean uv_source_prepare(GSource* base, gint* timeout) {
264
+ UvLoopSource* s = reinterpret_cast<UvLoopSource*>(base);
265
+ if (g_in_uv_pump) {
266
+ *timeout = -1;
267
+ return FALSE;
268
+ }
269
+ uv_update_time(s->loop);
270
+ DrainMicrotasks(); // drain before we (maybe) sleep — settles a pending async reply
271
+ const gint64 now = g_source_get_time(base);
272
+ // Un-park if uv became live again since the last dispatch (a GLib-dispatched callback
273
+ // may have armed a Node timer / started Node I/O). While parked, sleep until a GLib
274
+ // source wakes us instead of spinning on a fixed cadence.
275
+ if (s->win_next_wake_us == G_MAXINT64) {
276
+ s->win_next_wake_us = WinNextUvWake(s, now);
277
+ if (s->win_next_wake_us == G_MAXINT64) {
278
+ *timeout = -1;
279
+ return FALSE;
280
+ }
281
+ }
282
+ if (now >= s->win_next_wake_us) {
283
+ *timeout = 0;
284
+ return TRUE; // due now — dispatch runs uv_run + drain
285
+ }
286
+ gint64 remaining_ms = (s->win_next_wake_us - now + 999) / 1000;
287
+ *timeout = remaining_ms > NODE_GI_WIN_UV_POLL_CAP_MS ? NODE_GI_WIN_UV_POLL_CAP_MS
288
+ : static_cast<gint>(remaining_ms);
289
+ return FALSE;
290
+ }
291
+
292
+ static gboolean uv_source_check(GSource* base) {
293
+ UvLoopSource* s = reinterpret_cast<UvLoopSource*>(base);
294
+ if (g_in_uv_pump) return FALSE;
295
+ return g_source_get_time(base) >= s->win_next_wake_us; // never ready while parked (G_MAXINT64)
296
+ }
297
+
298
+ static gboolean uv_source_dispatch(GSource* base, GSourceFunc /*callback*/, gpointer /*user_data*/) {
299
+ if (g_in_uv_pump) return G_SOURCE_CONTINUE; // never nest uv_run under a pump iteration
300
+ UvLoopSource* s = reinterpret_cast<UvLoopSource*>(base);
301
+ uv_run(s->loop, UV_RUN_NOWAIT);
302
+ DrainMicrotasks();
303
+ // Re-schedule off uv's post-run state (idle → park; else min(next-due, cap)).
304
+ uv_update_time(s->loop);
305
+ s->win_next_wake_us = WinNextUvWake(s, g_source_get_time(base));
306
+ return G_SOURCE_CONTINUE;
307
+ }
308
+
309
+ static GSourceFuncs uv_source_funcs = {
310
+ uv_source_prepare, uv_source_check, uv_source_dispatch, nullptr, nullptr, nullptr,
311
+ };
312
+ #endif // _WIN32 (UvLoopSource: POSIX fd-driven / Windows readiness-bounded co-pump)
313
+
314
+ // iterateMainContext(mayBlock?) -> boolean
315
+ //
316
+ // Iterate the default GLib main context once, dispatching any ready sources (GIO
317
+ // async callbacks, GLib timeouts/idles, DBus). Pure GLib — touches NO libuv — so
318
+ // it is the PORTABLE main-loop primitive on Bun/Deno, where the uv-nesting bridge
319
+ // (startMainLoop) can't run: Deno exports no libuv symbols and Bun panics on
320
+ // uv_backend_fd. The L1 layer drives it from a JS timer (pumpMainContext), so GLib
321
+ // co-pumps while the runtime's own event loop stays in control — GJS's non-blocking
322
+ // main loop reached the other way around. Returns true if a source was dispatched.
323
+ Napi::Value IterateMainContext(const Napi::CallbackInfo& info) {
324
+ Napi::Env env = info.Env();
325
+ bool may_block = info.Length() > 0 && info[0].ToBoolean().Value();
326
+ gboolean dispatched =
327
+ g_main_context_iteration(g_main_context_default(), may_block ? TRUE : FALSE);
328
+ return Napi::Boolean::New(env, dispatched == TRUE);
329
+ }
330
+
331
+ // ---- uv-driven GLib auto-pump (the non-blocking case) -----------------------
332
+ //
333
+ // The inverse co-pump of UvLoopSource: with NO blocking GLib loop running, a
334
+ // plain `node bundle.mjs` still needs pending GLib sources (Gio async
335
+ // completions, GLib timeouts/idles, DBus) to dispatch — under GJS the GLib loop
336
+ // IS the process loop, so they always do. Here Node's libuv loop drives the
337
+ // default GLib main context instead:
338
+ //
339
+ // • a uv_prepare + uv_check handle pair drains every READY GLib source once
340
+ // per libuv loop turn (a bounded `g_main_context_iteration(ctx, FALSE)`
341
+ // loop — self-contained prepare/query/poll(0)/check/dispatch cycles, so no
342
+ // cross-phase GLib protocol state is held), then
343
+ // • runs a prepare+query HINT pass to learn (a) when GLib's earliest timer is
344
+ // due — mirrored into a uv_timer so libuv wakes for it — and (b) which fds
345
+ // GLib polls (including the context's cross-thread wakeup eventfd, which a
346
+ // completing GTask worker signals) — mirrored into uv_poll watchers so I/O
347
+ // readiness and cross-thread wakeups end libuv's poll sleep.
348
+ //
349
+ // Keep-alive policy (what keeps the Node process alive, matching what a Node
350
+ // developer expects from in-flight work):
351
+ // • the mirrored uv_timer is REF'd while armed — a due GLib timeout behaves
352
+ // like a due `setTimeout` (a REPEATING GLib timeout therefore keeps Node
353
+ // alive like `setInterval`; under `gjs -m` the process would instead exit
354
+ // once the module settles — the one deliberate lifetime divergence);
355
+ // • one-shot in-flight async operations (a GI scope=async callback such as a
356
+ // GAsyncReadyCallback, counted via NodeGiPumpAsyncBegin/End) REF the pump
357
+ // while pending — an in-flight `read_async` behaves like in-flight Node I/O;
358
+ // • everything else (the prepare/check pair, the fd watchers) stays UNREF'd —
359
+ // the pump alone never keeps a finished program alive, so a purely-sync
360
+ // node-gi program still exits immediately. Consequence: a *passive* GLib fd
361
+ // source with no pending async op (e.g. only a listening Gio.SocketService)
362
+ // does not keep the process alive on its own.
363
+ //
364
+ // Guards: the pump only touches the context at g_main_depth() == 0 — during a
365
+ // blocking GLib.MainLoop.run()/Application.run() (which iterates the context
366
+ // itself, with UvLoopSource co-pumping libuv) every pump callback is a no-op, so
367
+ // there is no double-dispatch. g_in_uv_pump parks UvLoopSource during pump-driven
368
+ // iterations (see uv_source_prepare) so the two co-pumps never nest uv_run.
369
+ //
370
+ // Main-thread only, armed once by StartMainLoop (Node only — Bun/Deno have no
371
+ // usable libuv; they keep the L1 startMainContextPump timer pump).
372
+
373
+ struct PumpPoll {
374
+ uv_poll_t handle;
375
+ int fd;
376
+ int events; // currently-subscribed uv event mask
377
+ gboolean seen; // mark/sweep flag for SyncPumpPolls
378
+ };
379
+
380
+ static gboolean g_pump_inited = FALSE;
381
+ static gboolean g_pump_context_acquired = FALSE;
382
+ static uv_prepare_t g_pump_prepare;
383
+ static uv_check_t g_pump_check;
384
+ static uv_timer_t g_pump_timer;
385
+ static gboolean g_pump_timer_reffed = FALSE;
386
+ static std::unordered_map<int, PumpPoll*>* g_pump_polls = nullptr;
387
+ static std::vector<GPollFD>* g_pump_fds = nullptr; // query scratch (reused)
388
+ static int g_pump_async_pending = 0; // in-flight scope=async GI callbacks
389
+ static gboolean g_pump_prepare_reffed = FALSE;
390
+ static gboolean g_pump_poll_warned = FALSE;
391
+
392
+ // Wake-only callbacks: readiness/expiry just ends libuv's poll sleep; the actual
393
+ // GLib dispatch happens in the check phase of the same loop turn (PumpCheckCb).
394
+ static void PumpTimerCb(uv_timer_t* /*t*/) {}
395
+ static void PumpPollCb(uv_poll_t* /*p*/, int /*status*/, int /*events*/) {}
396
+
397
+ static void PumpPollCloseCb(uv_handle_t* h) {
398
+ delete reinterpret_cast<PumpPoll*>(h->data);
399
+ }
400
+
401
+ // Mirror the queried GLib poll fds into uv_poll watchers (mark/sweep diff — the
402
+ // set rarely changes). Duplicate fds are merged; fds with no events (e.g. uv's
403
+ // own backend fd, masked by the parked UvLoopSource) are skipped. On a watcher
404
+ // failure (fd not pollable / already watched elsewhere in this loop) fall back
405
+ // to a short timer so readiness is still discovered, just later.
406
+ static gboolean SyncPumpPolls(int nfds) {
407
+ #ifdef _WIN32
408
+ // uv_poll requires a SOCKET on Windows, but GLib's queried poll fds are HANDLEs
409
+ // (g_poll uses MsgWaitForMultipleObjects), not pollable via libuv. Report "not
410
+ // fully watched" so PumpArmWakeups falls back to timed main-context polling — the
411
+ // pump still drains ready GLib sources each loop turn (async Gio/timers/DBus
412
+ // work), just discovering readiness on a short cadence instead of on the fd. A
413
+ // HANDLE/IOCP fd-watch integration via the Win32 GLib surface is a follow-up.
414
+ (void)nfds;
415
+ return FALSE;
416
+ #else
417
+ gboolean all_ok = TRUE;
418
+ for (auto& it : *g_pump_polls) it.second->seen = FALSE;
419
+
420
+ // Merge events per fd first (a context can poll one fd from several sources).
421
+ std::unordered_map<int, int> wanted;
422
+ for (int i = 0; i < nfds; i++) {
423
+ const GPollFD& p = (*g_pump_fds)[i];
424
+ if (p.fd < 0 || p.events == 0) continue;
425
+ int ev = 0;
426
+ if (p.events & (G_IO_IN | G_IO_HUP | G_IO_ERR)) ev |= UV_READABLE;
427
+ if (p.events & G_IO_OUT) ev |= UV_WRITABLE;
428
+ if (p.events & G_IO_PRI) ev |= UV_PRIORITIZED;
429
+ if (ev != 0) wanted[p.fd] |= ev;
430
+ }
431
+
432
+ for (const auto& [fd, ev] : wanted) {
433
+ auto it = g_pump_polls->find(fd);
434
+ if (it != g_pump_polls->end()) {
435
+ PumpPoll* pp = it->second;
436
+ pp->seen = TRUE;
437
+ if (pp->events != ev) {
438
+ if (uv_poll_start(&pp->handle, ev, PumpPollCb) == 0) {
439
+ pp->events = ev;
440
+ uv_unref(reinterpret_cast<uv_handle_t*>(&pp->handle));
441
+ } else {
442
+ all_ok = FALSE;
443
+ }
444
+ }
445
+ continue;
446
+ }
447
+ PumpPoll* pp = new PumpPoll();
448
+ pp->fd = fd;
449
+ pp->events = ev;
450
+ pp->seen = TRUE;
451
+ pp->handle.data = pp;
452
+ uv_loop_t* loop = g_pump_prepare.loop;
453
+ if (uv_poll_init(loop, &pp->handle, fd) != 0) {
454
+ delete pp;
455
+ all_ok = FALSE;
456
+ continue;
457
+ }
458
+ if (uv_poll_start(&pp->handle, ev, PumpPollCb) != 0) {
459
+ uv_close(reinterpret_cast<uv_handle_t*>(&pp->handle), PumpPollCloseCb);
460
+ all_ok = FALSE;
461
+ continue;
462
+ }
463
+ uv_unref(reinterpret_cast<uv_handle_t*>(&pp->handle));
464
+ (*g_pump_polls)[fd] = pp;
465
+ }
466
+
467
+ for (auto it = g_pump_polls->begin(); it != g_pump_polls->end();) {
468
+ if (it->second->seen) {
469
+ ++it;
470
+ continue;
471
+ }
472
+ PumpPoll* pp = it->second;
473
+ uv_poll_stop(&pp->handle);
474
+ uv_close(reinterpret_cast<uv_handle_t*>(&pp->handle), PumpPollCloseCb);
475
+ it = g_pump_polls->erase(it);
476
+ }
477
+ return all_ok;
478
+ #endif // _WIN32
479
+ }
480
+
481
+ // Drain every currently-ready GLib source (bounded). Each iteration is a full,
482
+ // self-contained GLib cycle, so readiness (fds, timers, idles) is re-evaluated
483
+ // natively — the uv watchers are pure wake-ups. No-op while a blocking GLib
484
+ // loop is dispatching (g_main_depth() > 0) or when re-entered from a nested
485
+ // uv_run inside our own dispatch (g_in_uv_pump).
486
+ static void PumpDrainContext() {
487
+ if (g_main_depth() > 0 || g_in_uv_pump) return;
488
+ GMainContext* ctx = g_main_context_default();
489
+ // The drain runs from a bare uv_prepare/uv_check C callback — there is NO
490
+ // ambient V8 HandleScope (unlike a blocking run(), which is entered through a
491
+ // JS-initiated N-API frame). Anything the dispatched sources do that creates
492
+ // JS handles outside its own scope (the toggle-ref bridge's
493
+ // napi_get_reference_value on a GTask teardown unref, most prominently) would
494
+ // abort with "Cannot create a handle without a HandleScope" — so provide one
495
+ // for the whole drain.
496
+ napi_handle_scope scope = nullptr;
497
+ if (g_loop_env != nullptr) napi_open_handle_scope(g_loop_env, &scope);
498
+ g_in_uv_pump = TRUE;
499
+ int guard = 0;
500
+ while (g_main_context_iteration(ctx, FALSE) && ++guard < 1000) {
501
+ }
502
+ g_in_uv_pump = FALSE;
503
+ if (scope != nullptr) napi_close_handle_scope(g_loop_env, scope);
504
+ }
505
+
506
+ // The prepare+query HINT pass: learn GLib's earliest-timer deadline + poll fds
507
+ // and mirror them into the uv timer / uv_poll watchers, so libuv's poll sleep
508
+ // ends exactly when GLib next has work. The context is left "prepared" without a
509
+ // matching check/dispatch — safe: the next g_main_context_iteration() (ours or a
510
+ // blocking loop's) simply re-prepares. Runs only at g_main_depth() == 0; the
511
+ // g_in_uv_pump flag is held across it so UvLoopSource stays parked (masking uv's
512
+ // backend fd out of the queried set — it must not be uv_poll'd, and uv's own
513
+ // backend timeout must not feed back into the timer hint).
514
+ static void PumpArmWakeups() {
515
+ if (!g_pump_inited || g_main_depth() > 0 || g_in_uv_pump) return;
516
+ GMainContext* ctx = g_main_context_default();
517
+ gint prio = 0;
518
+ gint timeout = -1;
519
+ g_in_uv_pump = TRUE;
520
+ g_main_context_prepare(ctx, &prio);
521
+ int nfds = g_main_context_query(ctx, prio, &timeout, g_pump_fds->data(),
522
+ static_cast<gint>(g_pump_fds->size()));
523
+ while (nfds > static_cast<int>(g_pump_fds->size())) {
524
+ g_pump_fds->resize(nfds);
525
+ nfds = g_main_context_query(ctx, prio, &timeout, g_pump_fds->data(),
526
+ static_cast<gint>(g_pump_fds->size()));
527
+ }
528
+ g_in_uv_pump = FALSE;
529
+
530
+ if (!SyncPumpPolls(nfds)) {
531
+ // Degraded wake path: poll at a short cadence instead of on readiness. This is
532
+ // the ALWAYS case on Windows (GLib's poll fds are HANDLEs, not uv_poll-able) and
533
+ // an occasional one on Linux (an fd that isn't uv_poll-able). Force the cadence
534
+ // ONLY while async work is in-flight — its completion arrives on an fd we can't
535
+ // watch, so we must poll to catch it. When nothing is pending, keep the query's
536
+ // own timeout (-1 when GLib is idle) so the loop goes to sleep / the process
537
+ // exits instead of a ref'd timer spinning forever — otherwise a purely-sync
538
+ // program (and every finished conformance test) would never exit on Windows.
539
+ if (!g_pump_poll_warned) {
540
+ g_pump_poll_warned = TRUE;
541
+ g_printerr("(node-gi) warning: could not watch a GLib poll fd from libuv; "
542
+ "falling back to timed main-context polling\n");
543
+ }
544
+ if (g_pump_async_pending > 0 && (timeout < 0 || timeout > 32)) timeout = 32;
545
+ }
546
+
547
+ if (timeout >= 0) {
548
+ uv_timer_start(&g_pump_timer, PumpTimerCb, static_cast<uint64_t>(timeout), 0);
549
+ if (!g_pump_timer_reffed) {
550
+ uv_ref(reinterpret_cast<uv_handle_t*>(&g_pump_timer));
551
+ g_pump_timer_reffed = TRUE;
552
+ }
553
+ } else if (g_pump_timer_reffed) {
554
+ uv_timer_stop(&g_pump_timer);
555
+ uv_unref(reinterpret_cast<uv_handle_t*>(&g_pump_timer));
556
+ g_pump_timer_reffed = FALSE;
557
+ }
558
+ }
559
+
560
+ static void PumpPrepareCb(uv_prepare_t* /*h*/) {
561
+ PumpDrainContext();
562
+ PumpArmWakeups();
563
+ }
564
+
565
+ static void PumpCheckCb(uv_check_t* /*h*/) {
566
+ PumpDrainContext();
567
+ // Re-arm after the drain: the dispatched callbacks may have added/removed
568
+ // sources, and a due-now source must schedule another loop turn (the 0-ms
569
+ // ref'd timer) even when Node itself has nothing left pending.
570
+ PumpArmWakeups();
571
+ }
572
+
573
+ // pumpKick() -> void — drain ready GLib sources + re-arm the wake hints NOW.
574
+ // The L1 layer calls this from a `process.on('beforeExit')` hook: with only
575
+ // UNREF'd handles active, uv_run(UV_RUN_DEFAULT) returns without ever running
576
+ // the prepare/check callbacks (uv__loop_alive is false at entry), so an
577
+ // otherwise-empty loop would never arm the GLib timer/fd wake-ups and a pending
578
+ // top-level await on, say, a GLib timeout would exit with an unsettled-TLA error
579
+ // before the timeout could fire. beforeExit is emitted exactly at that point;
580
+ // the kick dispatches what is ready and — when GLib still has scheduled work —
581
+ // arms the REF'd uv timer, reviving the loop.
582
+ Napi::Value PumpKick(const Napi::CallbackInfo& info) {
583
+ // Owner env only: a worker's beforeExit must not drive the MAIN loop's GLib
584
+ // context or its uv handles from the worker thread.
585
+ if (g_pump_inited && static_cast<napi_env>(info.Env()) == g_loop_env) {
586
+ PumpDrainContext();
587
+ PumpArmWakeups();
588
+ }
589
+ return info.Env().Undefined();
590
+ }
591
+
592
+ // The C→JS dispatch window (see common.h): JS run from a pump-driven iteration
593
+ // must not observe g_in_uv_pump, or a blocking loop it starts would keep the
594
+ // UvLoopSource co-pump parked (frozen Node timers for the whole run).
595
+ NodeGiPumpJsDispatchScope::NodeGiPumpJsDispatchScope() : saved_(g_in_uv_pump) {
596
+ g_in_uv_pump = FALSE;
597
+ }
598
+ NodeGiPumpJsDispatchScope::~NodeGiPumpJsDispatchScope() { g_in_uv_pump = saved_; }
599
+
600
+ static void PumpInit(uv_loop_t* loop) {
601
+ if (g_pump_inited) return;
602
+ g_pump_polls = new std::unordered_map<int, PumpPoll*>();
603
+ g_pump_fds = new std::vector<GPollFD>(16);
604
+
605
+ // Own the default context on this (the JS) thread. Required so a cross-thread
606
+ // g_source_attach (a completing GTask worker) signals the context's wakeup
607
+ // eventfd — which our uv_poll watcher turns into a libuv wake. Same-thread
608
+ // recursive acquisition by a later blocking g_main_loop_run() still works.
609
+ g_pump_context_acquired = g_main_context_acquire(g_main_context_default());
610
+ if (!g_pump_context_acquired) {
611
+ g_printerr("(node-gi) warning: default GMainContext is owned by another thread; "
612
+ "cross-thread wakeups may be delayed\n");
613
+ }
614
+
615
+ uv_prepare_init(loop, &g_pump_prepare);
616
+ uv_prepare_start(&g_pump_prepare, PumpPrepareCb);
617
+ uv_unref(reinterpret_cast<uv_handle_t*>(&g_pump_prepare));
618
+
619
+ uv_check_init(loop, &g_pump_check);
620
+ uv_check_start(&g_pump_check, PumpCheckCb);
621
+ uv_unref(reinterpret_cast<uv_handle_t*>(&g_pump_check));
622
+
623
+ uv_timer_init(loop, &g_pump_timer);
624
+ uv_unref(reinterpret_cast<uv_handle_t*>(&g_pump_timer));
625
+
626
+ g_pump_inited = TRUE;
627
+ }
628
+
629
+ // In-flight scope=async keep-alive: while any GI scope=async callback (a
630
+ // GAsyncReadyCallback) is pending, REF the (always-active) prepare handle so the
631
+ // libuv loop stays alive until the completion arrives — the exact analogue of
632
+ // Node's own in-flight I/O keeping the process alive, and the reason a plain
633
+ // top-level `await` on a Gio async op settles instead of exiting with an
634
+ // unsettled-TLA error. Main-thread only; counted only for the pump-owning env.
635
+ bool NodeGiPumpAsyncBegin(napi_env env) {
636
+ if (!g_pump_inited || env != g_loop_env) return false;
637
+ if (++g_pump_async_pending == 1 && !g_pump_prepare_reffed) {
638
+ uv_ref(reinterpret_cast<uv_handle_t*>(&g_pump_prepare));
639
+ g_pump_prepare_reffed = TRUE;
640
+ }
641
+ return true;
642
+ }
643
+
644
+ void NodeGiPumpAsyncEnd() {
645
+ if (!g_pump_inited || g_pump_async_pending == 0) return;
646
+ if (--g_pump_async_pending == 0 && g_pump_prepare_reffed) {
647
+ uv_unref(reinterpret_cast<uv_handle_t*>(&g_pump_prepare));
648
+ g_pump_prepare_reffed = FALSE;
649
+ }
650
+ }
651
+
652
+ // Env-teardown: close the pump's uv handles so the loop can wind down cleanly.
653
+ // Only the env that armed the pump (the main env) tears it down.
654
+ void NodeGiPumpShutdown(napi_env env) {
655
+ if (!g_pump_inited || env != g_loop_env) return;
656
+ g_pump_inited = FALSE;
657
+ uv_prepare_stop(&g_pump_prepare);
658
+ uv_check_stop(&g_pump_check);
659
+ uv_timer_stop(&g_pump_timer);
660
+ uv_close(reinterpret_cast<uv_handle_t*>(&g_pump_prepare), nullptr);
661
+ uv_close(reinterpret_cast<uv_handle_t*>(&g_pump_check), nullptr);
662
+ uv_close(reinterpret_cast<uv_handle_t*>(&g_pump_timer), nullptr);
663
+ if (g_pump_polls != nullptr) {
664
+ for (auto& it : *g_pump_polls) {
665
+ uv_poll_stop(&it.second->handle);
666
+ uv_close(reinterpret_cast<uv_handle_t*>(&it.second->handle), PumpPollCloseCb);
667
+ }
668
+ delete g_pump_polls;
669
+ g_pump_polls = nullptr;
670
+ }
671
+ delete g_pump_fds;
672
+ g_pump_fds = nullptr;
673
+ if (g_pump_context_acquired) {
674
+ g_main_context_release(g_main_context_default());
675
+ g_pump_context_acquired = FALSE;
676
+ }
677
+ }
678
+
679
+ // startMainLoop() -> void
680
+ // Attach the libuv-backed GSource to the default GLib main context (idempotent).
681
+ // Harmless until a GLib main loop actually runs — it adds no uv handle, so it
682
+ // neither keeps Node alive nor runs uv on its own; it only pumps uv while a GLib
683
+ // loop is iterating. The L1 layer calls this once when a namespace is required.
684
+ // Also arms the uv-driven GLib auto-pump (above), so pending GLib sources keep
685
+ // dispatching WITHOUT a blocking GLib loop — the plain `node bundle.mjs` case.
686
+ Napi::Value StartMainLoop(const Napi::CallbackInfo& info) {
687
+ Napi::Env env = info.Env();
688
+ if (g_loop_started) return env.Undefined();
689
+
690
+ uv_loop_t* loop = nullptr;
691
+ if (napi_get_uv_event_loop(env, &loop) != napi_ok || loop == nullptr) {
692
+ Napi::Error::New(env, "failed to obtain the libuv event loop").ThrowAsJavaScriptException();
693
+ return env.Undefined();
694
+ }
695
+
696
+ // Capture env + process._tickCallback for nextTick/microtask draining.
697
+ g_loop_env = env;
698
+ napi_value global = nullptr, process_v = nullptr, tick = nullptr;
699
+ if (napi_get_global(env, &global) == napi_ok &&
700
+ napi_get_named_property(env, global, "process", &process_v) == napi_ok &&
701
+ process_v != nullptr) {
702
+ napi_create_reference(env, process_v, 1, &g_process_ref);
703
+ if (napi_get_named_property(env, process_v, "_tickCallback", &tick) == napi_ok) {
704
+ napi_valuetype vt;
705
+ if (napi_typeof(env, tick, &vt) == napi_ok && vt == napi_function) {
706
+ napi_create_reference(env, tick, 1, &g_tick_callback_ref);
707
+ }
708
+ }
709
+ }
710
+
711
+ // UvLoopSource co-pumps Node's timers/I/O + drains its microtasks during a
712
+ // BLOCKING GLib main loop (GLib.MainLoop.run / Gio.Application.run). POSIX embeds
713
+ // uv's backend fd into the GSource (readiness-driven); Windows/IOCP has no backend
714
+ // fd, so the Windows variant schedules off uv_loop_alive + uv_backend_timeout (see
715
+ // uv_source_funcs above) — same dispatch (uv_run + DrainMicrotasks), woken at uv's
716
+ // next-due time bounded by a 5 ms IOCP-completion poll cap instead of on the fd.
717
+ // PumpInit below is the separate NON-blocking case (bare `node bundle.mjs` async).
718
+ #ifndef _WIN32
719
+ GSource* source = g_source_new(&uv_source_funcs, sizeof(UvLoopSource));
720
+ UvLoopSource* s = reinterpret_cast<UvLoopSource*>(source);
721
+ s->loop = loop;
722
+ s->fd_polled = TRUE;
723
+ // uv_backend_fd is the epoll/kqueue fd on POSIX.
724
+ s->fd_tag = g_source_add_unix_fd(source, uv_backend_fd(loop),
725
+ static_cast<GIOCondition>(G_IO_IN | G_IO_OUT | G_IO_ERR));
726
+ // PRIORITY — below GDK_PRIORITY_REDRAW (G_PRIORITY_HIGH_IDLE + 20), above
727
+ // G_PRIORITY_DEFAULT_IDLE. At the g_source_new default (G_PRIORITY_DEFAULT, 0)
728
+ // a busy Node loop STARVES GTK painting: GLib dispatches only the
729
+ // highest-priority READY band per iteration, so a libuv timer that is due on
730
+ // every prepare — a 1 ms metronome like Excalibur's requestIdleCallback
731
+ // polyfill (`setTimeout(cb, 1)` re-armed from its own callback, run
732
+ // perpetually by its GarbageCollector) — keeps this source ready at priority
733
+ // 0 forever and GDK's frame-clock paint/update sources (GDK_PRIORITY_REDRAW,
734
+ // G_PRIORITY_HIGH_IDLE + 20) never run again: ticks, GLArea renders and rAF
735
+ // all freeze while plain GLib timeouts (priority 0) keep firing — the
736
+ // Excalibur-on-node-gi stall. Under GJS there is no extra source competing
737
+ // with GTK at default priority; the co-pump must not introduce one. Redraw
738
+ // wins over Node work (browser-like: rendering outranks timers); Node I/O
739
+ // still runs in every frame gap.
740
+ g_source_set_priority(source, G_PRIORITY_HIGH_IDLE + 30);
741
+ g_source_attach(source, nullptr); // default GLib main context
742
+ g_source_unref(source); // the context holds the surviving ref
743
+ #else // _WIN32 — readiness-bounded UvLoopSource (no uv backend fd on IOCP).
744
+ GSource* source = g_source_new(&uv_source_funcs, sizeof(UvLoopSource));
745
+ UvLoopSource* s = reinterpret_cast<UvLoopSource*>(source);
746
+ s->loop = loop;
747
+ s->fd_tag = nullptr;
748
+ s->fd_polled = FALSE;
749
+ s->win_next_wake_us = 0; // due immediately on the first iteration (dispatch re-schedules)
750
+ // Same priority rationale as POSIX (below GDK redraw, above default idle) so a
751
+ // future GTK-on-Windows paint is not starved by the co-pump.
752
+ g_source_set_priority(source, G_PRIORITY_HIGH_IDLE + 30);
753
+ g_source_attach(source, nullptr); // default GLib main context
754
+ g_source_unref(source); // the context holds the surviving ref
755
+ #endif // _WIN32 (UvLoopSource blocking-loop co-pump)
756
+
757
+ // Cross-platform: the uv-driven GLib pump (prepare/check/timer → g_main_context_
758
+ // iteration) drains async Gio/timers/DBus without uv's backend fd — active on
759
+ // Windows too (degraded to timed polling; see SyncPumpPolls).
760
+ PumpInit(loop);
761
+
762
+ g_loop_started = TRUE;
763
+ return env.Undefined();
764
+ }
765
+
766
+ } // namespace nodegi