@gjsify/node-gi 0.25.1 → 0.26.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/gi.js CHANGED
@@ -19,7 +19,18 @@ import * as native from './index.js';
19
19
  // setImmediate is a Node global but NOT a Deno global (Deno requires the explicit
20
20
  // node:timers import; Bun/Node re-export it there too), so import it for the
21
21
  // macrotask-deferred runAsync to work on all three runtimes.
22
- import { setImmediate } from 'node:timers';
22
+ //
23
+ // setInterval/clearInterval come from here for a second, load-bearing reason:
24
+ // the RUNTIME's timers, never `globalThis.setInterval`. A GJS source running
25
+ // through the bridge routinely replaces the global timers with GLib-backed ones
26
+ // (`@gjsify/node-globals/register` swaps them for `GLib.timeout_add` wrappers) —
27
+ // driving the GLib pump off a GLib timeout is a deadlock: the timer that is
28
+ // supposed to dispatch GLib sources is itself a GLib source nobody dispatches.
29
+ import { setImmediate, setInterval, clearInterval } from 'node:timers';
30
+ // Same reasoning for `process`: `globalThis.process` may be the `@gjsify/process`
31
+ // polyfill, whose EventEmitter never emits the runtime's 'beforeExit'. The
32
+ // loop-liveness hooks below must attach to the real one or they silently never fire.
33
+ import runtimeProcess from 'node:process';
23
34
  // The GJS Gio DBus surface (Gio.DBus.session/system, own_name/watch_name,
24
35
  // Gio.DBusProxy.makeProxyWrapper, Gio.DBusExportedObject) — ported to the L1 model.
25
36
  import { createGioDBus } from './overrides/gio-dbus.js';
@@ -977,11 +988,94 @@ function applicationRunAsync(handle) {
977
988
  // concurrent runAsync calls share one timer.
978
989
  let pumpTimer = null;
979
990
  let pumpRefCount = 0;
991
+ // How many of those refs belong to the AUTO-armed pump (requireGi's permanent
992
+ // hold on Bun/Deno). `pumpRefCount > pumpAutoRefs` therefore means an EXPLICIT
993
+ // `startMainContextPump()` caller currently holds the pump.
994
+ let pumpAutoRefs = 0;
995
+ // Whether the pump timer currently HOLDS the runtime's event loop open (ref'd).
996
+ let pumpHoldsRuntime = false;
997
+
998
+ /** Dispatch every currently-ready GLib source, then return. */
999
+ function drainReadySources() {
1000
+ let guard = 0;
1001
+ while (native.iterateMainContext(false) && guard++ < 100000) {
1002
+ /* drain ready sources */
1003
+ }
1004
+ }
1005
+
1006
+ // Zero-N-API view over the JS-armed-work counter — `pendingView[0] > 0` is
1007
+ // `mainContextHasPending()` as a plain V8 memory read. The IDLE beat must not
1008
+ // enter the addon at all: on Deno, a single napi call from the pump tick during
1009
+ // the between-test-files GC window reproduces the #47 boxed-handle teardown
1010
+ // SIGSEGV (measured on the gtk-smoke leg: a query-only tick crashed 3/3, a tick
1011
+ // that never touches the addon exited 0, the full dispatching tick crashed
1012
+ // 10/10). So every read on the beat's idle path goes through this view.
1013
+ // Created LAZILY on the first pump arm (Bun/Deno only): the @gjsify/napi shim
1014
+ // loud-stubs external arraybuffers, and the gjs host never arms the pump.
1015
+ let pendingView = null;
1016
+
1017
+ /**
1018
+ * One pump beat: dispatch if the program owns GLib work, then re-evaluate the
1019
+ * keep-alive hold. Both the gate and the hold read the SAME counter, so the
1020
+ * auto-armed pump dispatches exactly when it also holds the loop open.
1021
+ *
1022
+ * DISPATCH is gated like the hold — on the program's OWN GLib work. An EXPLICIT
1023
+ * `startMainContextPump()` holder gets unconditional dispatch (its documented
1024
+ * contract: co-pump the context, whatever is in it). The AUTO-armed pump
1025
+ * dispatches only while a JS-armed GI callback is outstanding (scope=async
1026
+ * completions + scope=notified sources), so a FINISHED program stops touching
1027
+ * the addon entirely instead of grinding through GDK/GIO's C-armed housekeeping
1028
+ * sources forever.
1029
+ *
1030
+ * That silence is load-bearing on Deno: the documented #47 N-API teardown race
1031
+ * is armed by the pump ENTERING the addon between test files (see pendingView
1032
+ * above), while stopping/unref'ing the pump at teardown does NOT avoid it.
1033
+ * Nothing is lost for a RUNNING program: any JS-observable progress path is
1034
+ * counted (a Gio completion is scope=async, a `GLib.timeout_add` is
1035
+ * scope=notified), and while one is outstanding the beat drains ALL ready
1036
+ * sources, C-armed ones included.
1037
+ */
1038
+ function pumpBeat() {
1039
+ if (pumpRefCount > pumpAutoRefs || pendingView[0] > 0) {
1040
+ drainReadySources();
1041
+ }
1042
+ syncPumpKeepAlive();
1043
+ }
1044
+
1045
+ /**
1046
+ * Keep-alive accounting — the portable twin of the uv auto-pump's ref/unref
1047
+ * policy (`src/loop.cc`): scheduled GLib work (an armed timeout/idle) or an
1048
+ * in-flight scope=async GI callback holds the runtime's event loop open;
1049
+ * anything else lets it drain.
1050
+ *
1051
+ * Both halves are load-bearing. Permanently UNREF'd (the pre-fix behaviour) a
1052
+ * program whose only pending work lives in GLib dies mid-flight — a bare
1053
+ * `await` on a GLib timeout never settles and the runtime exits 0 with the
1054
+ * program half-run. Permanently REF'd, a sync-only program would never exit.
1055
+ *
1056
+ * Consequence, identical to Node's auto-pump: a REPEATING GLib timeout has
1057
+ * `setInterval` semantics and keeps the process alive, where `gjs -m` exits once
1058
+ * the module settles. That is the one deliberate lifetime divergence from gjs,
1059
+ * and it is now the SAME on node, bun and deno rather than node-only.
1060
+ */
1061
+ function syncPumpKeepAlive() {
1062
+ if (pumpTimer === null) return;
1063
+ const hold = pendingView[0] > 0; // zero-N-API — see pendingView above
1064
+ if (hold === pumpHoldsRuntime) return;
1065
+ if (hold) pumpTimer.ref?.();
1066
+ else pumpTimer.unref?.();
1067
+ pumpHoldsRuntime = hold;
1068
+ }
980
1069
 
981
1070
  /**
982
1071
  * Start co-pumping the default GLib main context from a runtime timer (Bun/Deno).
983
1072
  * No-op on Node, where the libuv↔GLib bridge already co-pumps. Reference-counted;
984
1073
  * returns a disposer that decrements the count (clearing the timer at zero).
1074
+ *
1075
+ * `requireGi` arms this automatically on Bun/Deno (the Node twin of
1076
+ * `native.startMainLoop()`), so a plain `bun bundle.mjs` / `deno run bundle.mjs`
1077
+ * dispatches GLib sources with no explicit loop — calling it by hand is only
1078
+ * needed to hold the pump across a scope that must not depend on that.
985
1079
  * @returns {() => void}
986
1080
  */
987
1081
  export function startMainContextPump() {
@@ -991,18 +1085,17 @@ export function startMainContextPump() {
991
1085
  if (native.isNodeRuntime || native.RUNTIME === 'gjs') return () => {};
992
1086
  pumpRefCount++;
993
1087
  if (pumpTimer === null) {
994
- // ~4 ms cadence: low latency without busy-spinning. Each tick drains every
1088
+ if (pendingView === null) pendingView = native.makePumpPendingCount();
1089
+ // ~4 ms cadence: low latency without busy-spinning. Each beat drains every
995
1090
  // currently-ready source (iterateMainContext(false) returns false when none
996
- // remain, bounding the inner loop), then yields to the runtime loop.
997
- pumpTimer = setInterval(() => {
998
- let guard = 0;
999
- while (native.iterateMainContext(false) && guard++ < 100000) {
1000
- /* drain ready sources */
1001
- }
1002
- }, 4);
1003
- // Don't let the pump alone keep the process alive where the runtime allows it
1004
- // (Node/Bun expose Timeout.unref; Deno's numeric handle has none).
1005
- if (typeof pumpTimer.unref === 'function') pumpTimer.unref();
1091
+ // remain, bounding the inner loop) — gated on the program's own GLib work
1092
+ // for the auto-armed pump, see pumpBeat — then re-evaluates the hold.
1093
+ pumpTimer = setInterval(pumpBeat, 4);
1094
+ // Start UNREF'd: the pump alone must never keep a finished program alive.
1095
+ // syncPumpKeepAlive() refs it for exactly as long as GLib has work.
1096
+ pumpTimer.unref?.();
1097
+ pumpHoldsRuntime = false;
1098
+ syncPumpKeepAlive();
1006
1099
  }
1007
1100
  let disposed = false;
1008
1101
  return () => {
@@ -1019,6 +1112,7 @@ export function stopMainContextPump() {
1019
1112
  if (pumpRefCount === 0 && pumpTimer !== null) {
1020
1113
  clearInterval(pumpTimer);
1021
1114
  pumpTimer = null;
1115
+ pumpHoldsRuntime = false;
1022
1116
  }
1023
1117
  }
1024
1118
 
@@ -2309,7 +2403,27 @@ export function requireGi(namespace, version) {
2309
2403
  // 'beforeExit' fires exactly when the loop runs empty: drain what is
2310
2404
  // ready and re-arm — if GLib still has scheduled work, the armed (ref'd)
2311
2405
  // uv timer revives the loop; otherwise the process exits normally.
2312
- process.on('beforeExit', () => native.pumpKick());
2406
+ runtimeProcess.on('beforeExit', () => native.pumpKick());
2407
+ } else if (native.RUNTIME !== 'gjs') {
2408
+ // Bun/Deno: no libuv to hook, so the portable timer pump IS the auto-pump.
2409
+ // Armed HERE, not left to the caller, so that `bun bundle.mjs` /
2410
+ // `deno run bundle.mjs` behaves like `node bundle.mjs` and `gjs -m`:
2411
+ // GLib sources dispatch with no explicit loop anywhere, and the pump's
2412
+ // keep-alive accounting decides when the process may exit. Leaving it
2413
+ // opt-in is what made the same bundle exit 0 MID-suite on bun/deno while
2414
+ // node ran it to completion.
2415
+ startMainContextPump(); // permanent +1 — the auto-pump is never disposed
2416
+ // Tag that hold as the AUTO pump's: pumpBeat dispatches for it only while
2417
+ // a JS-armed GI callback is outstanding, so a finished program produces
2418
+ // no teardown churn (the deno #47 window) — explicit callers keep
2419
+ // unconditional dispatch.
2420
+ pumpAutoRefs++;
2421
+ // The portable analogue of the Node pump's beforeExit kick. The runtime's
2422
+ // loop just ran empty, which is exactly the moment an unref'd pump would
2423
+ // let the process die with GLib work still outstanding: run one beat —
2424
+ // if the program still owns GLib work it drains + re-refs (reviving the
2425
+ // loop); if not, the beat stays silent and the process exits.
2426
+ runtimeProcess.on('beforeExit', pumpBeat);
2313
2427
  }
2314
2428
  loopAttached = true;
2315
2429
  }
package/index.d.ts CHANGED
@@ -418,6 +418,27 @@ export function startMainLoop(): void;
418
418
  */
419
419
  export function iterateMainContext(mayBlock?: boolean): boolean;
420
420
 
421
+ /**
422
+ * Whether the default GLib main context holds work a *pumping* runtime must stay
423
+ * alive for: a scheduled source (an armed timeout/idle) or an in-flight
424
+ * scope=async GI callback. Pure GLib — the query half of the keep-alive contract
425
+ * the L1 `startMainContextPump` uses on Bun/Deno to decide whether its timer
426
+ * holds the runtime's event loop open. Node reads the same facts inline in the
427
+ * uv auto-pump.
428
+ */
429
+ export function mainContextHasPending(): boolean;
430
+
431
+ /**
432
+ * Create an `Int32Array(1)` view over the same JS-armed-work counter
433
+ * `mainContextHasPending()` reads (`view[0] > 0` is equivalent). Reading the
434
+ * view is a plain memory access — no call into the addon — which the Bun/Deno
435
+ * pump's idle beat depends on: on Deno, entering the addon from a timer tick
436
+ * during the between-test-files GC window reproduces the #47 boxed-handle
437
+ * teardown SIGSEGV. Lazy factory: the @gjsify/napi shim loud-stubs external
438
+ * arraybuffers and never arms the portable pump, so it must not run at Init.
439
+ */
440
+ export function makePumpPendingCount(): Int32Array;
441
+
421
442
  /**
422
443
  * Drain ready GLib sources + re-arm the auto-pump's libuv wake-ups now (no-op
423
444
  * unless {@link startMainLoop} armed the pump on this env). The L1 layer wires
@@ -548,6 +569,8 @@ declare const native: {
548
569
  isVariantHandle: typeof isVariantHandle;
549
570
  startMainLoop: typeof startMainLoop;
550
571
  iterateMainContext: typeof iterateMainContext;
572
+ mainContextHasPending: typeof mainContextHasPending;
573
+ makePumpPendingCount: typeof makePumpPendingCount;
551
574
  pumpKick: typeof pumpKick;
552
575
  setMicrotaskDrain: typeof setMicrotaskDrain;
553
576
  connectSignal: typeof connectSignal;
package/index.js CHANGED
@@ -661,6 +661,35 @@ export const startMainLoop = native.startMainLoop;
661
661
  */
662
662
  export const iterateMainContext = native.iterateMainContext;
663
663
 
664
+ /**
665
+ * Whether the default GLib main context holds work a *pumping* runtime must stay
666
+ * alive for: a scheduled source (an armed timeout/idle — the prepare/query
667
+ * timeout hint is >= 0) or an in-flight scope=async GI callback. Pure GLib —
668
+ * the companion of {@link iterateMainContext} and the query half of the
669
+ * keep-alive contract the L1 `startMainContextPump` applies on Bun/Deno (Node
670
+ * reads the same facts inline in the uv auto-pump). A `false` answer is what
671
+ * lets a sync-only program exit immediately.
672
+ * @returns {boolean}
673
+ */
674
+ export const mainContextHasPending = native.mainContextHasPending;
675
+
676
+ /**
677
+ * Create an `Int32Array(1)` view over the SAME JS-armed-work counter
678
+ * {@link mainContextHasPending} reads — `view[0] > 0` ⇔
679
+ * `mainContextHasPending()`. The zero-N-API spelling: reading the view is a
680
+ * plain V8 memory access, no call into the addon. Load-bearing for the Bun/Deno
681
+ * pump's IDLE beat — on Deno, merely ENTERING the addon from a timer tick
682
+ * during the between-test-files GC window reproduces the #47 boxed-handle
683
+ * teardown SIGSEGV (measured: a query-only tick crashed 3/3 where a tick that
684
+ * never touches the addon exited 0), so the beat must be able to answer "is
685
+ * there work?" without a single native call. A lazy FACTORY rather than an
686
+ * Init-time export: the @gjsify/napi shim (the gjs-napi conformance oracle)
687
+ * loud-stubs `napi_create_external_arraybuffer`, and the gjs host never arms
688
+ * the portable pump — called only on the first Bun/Deno pump arm.
689
+ * @returns {Int32Array}
690
+ */
691
+ export const makePumpPendingCount = native.makePumpPendingCount;
692
+
664
693
  /**
665
694
  * Drain ready GLib sources + re-arm the auto-pump's libuv wake-ups NOW (no-op
666
695
  * unless {@link startMainLoop} armed the pump, i.e. Node's main env). The L1
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gjsify/node-gi",
3
- "version": "0.25.1",
3
+ "version": "0.26.0",
4
4
  "description": "GObject-Introspection runtime for Node.js — load gi:// namespaces (GLib/GObject/Gio/…) with GJS-compatible semantics. Vendored + rewritten from node-gtk under MIT.",
5
5
  "type": "module",
6
6
  "license": "MIT",
Binary file
Binary file
package/src/addon.cc CHANGED
@@ -113,6 +113,8 @@ static Napi::Object Init(Napi::Env env, Napi::Object exports) {
113
113
  exports.Set("isVariantHandle", Napi::Function::New(env, IsVariantHandle));
114
114
  exports.Set("startMainLoop", Napi::Function::New(env, StartMainLoop));
115
115
  exports.Set("iterateMainContext", Napi::Function::New(env, IterateMainContext));
116
+ exports.Set("mainContextHasPending", Napi::Function::New(env, MainContextHasPending));
117
+ exports.Set("makePumpPendingCount", Napi::Function::New(env, MakePumpPendingCount));
116
118
  exports.Set("pumpKick", Napi::Function::New(env, PumpKick));
117
119
  exports.Set("setMicrotaskDrain", Napi::Function::New(env, SetMicrotaskDrain));
118
120
  exports.Set("connectSignal", Napi::Function::New(env, ConnectSignal));
package/src/calls.cc CHANGED
@@ -28,10 +28,23 @@ struct NodeGiCallback {
28
28
  ffi_closure* closure; // from gi_callable_info_create_closure
29
29
  gpointer native; // executable trampoline address
30
30
  GIScopeType scope;
31
- // scope=async only: counted in the auto-pump's in-flight keep-alive (the
32
- // pending completion keeps the Node process alive, like in-flight Node I/O).
33
- // Released exactly once — at the trampoline's single invocation, or at free
34
- // if the callback never fired (an error path).
31
+ // Counted in the auto-pump's in-flight keep-alive — the process stays alive
32
+ // while JS-armed GLib work is outstanding, like in-flight Node I/O.
33
+ // • scope=async — a one-shot completion (GAsyncReadyCallback), released
34
+ // at the trampoline's single invocation.
35
+ // • scope=notified — a callback GLib owns until its GDestroyNotify fires:
36
+ // `GLib.timeout_add` / `GLib.idle_add` and friends. Released by
37
+ // NodeGiCallbackFree, i.e. exactly when GLib drops the source (the
38
+ // callback returned SOURCE_REMOVE, or `GLib.Source.remove` killed it).
39
+ // Either way released exactly once, also at free if it never fired.
40
+ //
41
+ // Counting the JS-ARMED sources is the point: a source armed from JS is
42
+ // program work the process must survive for, while a source armed inside C —
43
+ // GDK's frame clock, GIO's portal/a11y retries — is library plumbing that
44
+ // `gjs -m` would never stay alive for either. Keying the keep-alive on "the
45
+ // context has ANY scheduled source" instead made a finished GTK program
46
+ // immortal: GDK leaves a ~1 s repeating timeout armed, so the context reports
47
+ // work forever while nothing is ever ready to dispatch.
35
48
  bool pumpCounted = false;
36
49
  };
37
50
 
@@ -301,10 +314,14 @@ static NodeGiCallback* CreateCallback(Napi::Env env, Napi::Function fn, GICallab
301
314
  cb->env = env;
302
315
  cb->info = reinterpret_cast<GICallableInfo*>(gi_base_info_ref(callbackInfo));
303
316
  cb->scope = scope;
304
- // An async-scope callback is a one-shot in-flight operation (GAsyncReadyCallback
305
- // et al): count it in the auto-pump keep-alive so the Node process stays alive
306
- // until the completion dispatches (no-op off-Node / on non-owner envs).
307
- if (scope == GI_SCOPE_TYPE_ASYNC) cb->pumpCounted = NodeGiPumpAsyncBegin(env);
317
+ // Count the JS-armed GLib work in the auto-pump keep-alive so the process
318
+ // stays alive until it completes: a one-shot in-flight operation
319
+ // (GAsyncReadyCallback et al, scope=async) or a source GLib owns until its
320
+ // destroy-notify (`GLib.timeout_add`/`idle_add`, scope=notified). See the
321
+ // `pumpCounted` note above for why JS-armed is the right unit.
322
+ if (scope == GI_SCOPE_TYPE_ASYNC || scope == GI_SCOPE_TYPE_NOTIFIED) {
323
+ cb->pumpCounted = NodeGiPumpAsyncBegin(env);
324
+ }
308
325
  napi_create_reference(env, fn, 1, &cb->jsFn);
309
326
  cb->closure = gi_callable_info_create_closure(callbackInfo, &cb->cif, NodeGiCallbackTrampoline, cb);
310
327
  cb->native = gi_callable_info_get_closure_native_address(callbackInfo, cb->closure);
package/src/common.h CHANGED
@@ -635,6 +635,12 @@ Napi::Value BindingGroupBindFull(const Napi::CallbackInfo& info);
635
635
  // loop.cc
636
636
  Napi::Value StartMainLoop(const Napi::CallbackInfo& info);
637
637
  Napi::Value IterateMainContext(const Napi::CallbackInfo& info);
638
+ Napi::Value MainContextHasPending(const Napi::CallbackInfo& info);
639
+ // Factory for an Int32Array(1) view over the JS-armed-work counter (see
640
+ // loop.cc) — lets the L1 pump answer "does the program own GLib work?" WITHOUT
641
+ // a napi call. Lazy (a factory, not an Init-time export): the @gjsify/napi shim
642
+ // loud-stubs napi_create_external_arraybuffer and never arms the pump.
643
+ Napi::Value MakePumpPendingCount(const Napi::CallbackInfo& info);
638
644
  Napi::Value PumpKick(const Napi::CallbackInfo& info);
639
645
  Napi::Value SetMicrotaskDrain(const Napi::CallbackInfo& info);
640
646
 
@@ -643,8 +649,10 @@ Napi::Value SetMicrotaskDrain(const Napi::CallbackInfo& info);
643
649
  // In-flight scope=async keep-alive: while a GI scope=async callback (e.g. a
644
650
  // GAsyncReadyCallback) is pending, the pump holds a libuv ref so a plain Node
645
651
  // script survives until the completion dispatches (the top-level-await case).
646
- // Begin returns whether the callback was counted (only on the pump-owning env,
647
- // i.e. Node's main env after startMainLoop); the caller must call End exactly
652
+ // The counter itself is runtime-independent (Bun/Deno's portable pump reads it
653
+ // via MainContextHasPending); only the libuv ref is Node-only. Begin returns
654
+ // whether the callback was counted (false only for a foreign env, i.e. a worker
655
+ // thread once Node's main env owns the pump); the caller must call End exactly
648
656
  // once for each counted callback. Main-thread only.
649
657
  bool NodeGiPumpAsyncBegin(napi_env env);
650
658
  void NodeGiPumpAsyncEnd();
package/src/loop.cc CHANGED
@@ -550,14 +550,31 @@ static void PumpArmWakeups() {
550
550
  if (g_pump_async_pending > 0 && (timeout < 0 || timeout > 32)) timeout = 32;
551
551
  }
552
552
 
553
+ // WAKE-UP and HOLD are separate decisions.
554
+ //
555
+ // Wake-up: whenever GLib has a deadline, arm the mirrored uv timer for it, so
556
+ // libuv's poll sleep ends exactly when GLib next has work.
557
+ //
558
+ // Hold (uv_ref): ONLY while JS-armed GLib work is outstanding — the
559
+ // scope=async/notified counter. Ref'ing whenever a deadline exists (what this
560
+ // used to do) makes any C-armed toolkit timer immortalize the process: GDK
561
+ // keeps a ~1 s repeating timeout armed for the process's whole life, so
562
+ // `node --test test/gtk-smoke.test.mjs` never exited once the GTK stack had
563
+ // been initialized. `gjs -m` exits when the module settles regardless of what
564
+ // GLib still has scheduled; keying the hold on the program's OWN outstanding
565
+ // GLib work is as close to that as a libuv-driven process can get, and it is
566
+ // what keeps a top-level `await` on a GLib timeout alive (the timeout's
567
+ // GSourceFunc is a scope=notified callback, counted until GLib drops it).
553
568
  if (timeout >= 0) {
554
569
  uv_timer_start(&g_pump_timer, PumpTimerCb, static_cast<uint64_t>(timeout), 0);
555
- if (!g_pump_timer_reffed) {
556
- uv_ref(reinterpret_cast<uv_handle_t*>(&g_pump_timer));
557
- g_pump_timer_reffed = TRUE;
558
- }
559
- } else if (g_pump_timer_reffed) {
570
+ } else {
560
571
  uv_timer_stop(&g_pump_timer);
572
+ }
573
+ const gboolean hold = g_pump_async_pending > 0 && timeout >= 0;
574
+ if (hold && !g_pump_timer_reffed) {
575
+ uv_ref(reinterpret_cast<uv_handle_t*>(&g_pump_timer));
576
+ g_pump_timer_reffed = TRUE;
577
+ } else if (!hold && g_pump_timer_reffed) {
561
578
  uv_unref(reinterpret_cast<uv_handle_t*>(&g_pump_timer));
562
579
  g_pump_timer_reffed = FALSE;
563
580
  }
@@ -632,15 +649,72 @@ static void PumpInit(uv_loop_t* loop) {
632
649
  g_pump_inited = TRUE;
633
650
  }
634
651
 
652
+ // mainContextHasPending() -> boolean
653
+ //
654
+ // Is there JS-armed GLib work a *pumping* runtime must stay alive for? True
655
+ // while a GI callback the program handed to GLib is outstanding — a scope=async
656
+ // completion (a GAsyncReadyCallback) or a scope=notified source
657
+ // (`GLib.timeout_add`/`idle_add`), counted in calls.cc and released when GLib
658
+ // drops it. The query half of the keep-alive contract for the Bun/Deno pump
659
+ // (L1 `startMainContextPump`); Node reads the same counter inline in
660
+ // PumpArmWakeups and never calls this.
661
+ //
662
+ // It deliberately does NOT ask whether the context has any scheduled source.
663
+ // That probe (`g_main_context_prepare` + `_query`, timeout >= 0) reports the
664
+ // timers a TOOLKIT arms in C as well, and GDK keeps a ~1 s repeating timeout
665
+ // armed for the process's whole life: a finished GTK program then answers
666
+ // "pending" forever while nothing is ever ready to dispatch, and never exits.
667
+ // `gjs -m` would not stay alive for those either — it exits once the module
668
+ // settles. A `false` answer is also what lets a sync-only program exit
669
+ // immediately: the pump alone must never keep a finished program alive.
670
+ Napi::Value MainContextHasPending(const Napi::CallbackInfo& info) {
671
+ Napi::Env env = info.Env();
672
+ return Napi::Boolean::New(env, g_pump_async_pending > 0);
673
+ }
674
+
675
+ // makePumpPendingCount() -> Int32Array(1) OVER g_pump_async_pending (external,
676
+ // zero copy). The JS pump's beat reads this view instead of calling
677
+ // MainContextHasPending(): on Deno, merely ENTERING the addon from the pump's
678
+ // timer tick during the between-test-files GC window reproduces the #47
679
+ // boxed-handle teardown SIGSEGV — measured on the gtk-smoke leg, a query-only
680
+ // tick (one napi call, no dispatch) crashed 3/3 while a tick that never touches
681
+ // the addon exited 0. A typed-array read is a plain JS memory access, so the
682
+ // beat's idle path stays native-silent. No atomics: the counter is written only
683
+ // from the JS thread (Begin/End are main-thread only) and read from JS.
684
+ //
685
+ // A FACTORY, deliberately not created at addon Init: the @gjsify/napi shim
686
+ // (the gjs-napi conformance oracle runs node-gi under it) loud-stubs
687
+ // napi_create_external_arraybuffer, and the gjs host never arms the portable
688
+ // pump — L1 calls this lazily on the first Bun/Deno pump arm, so the stub is
689
+ // never reached where the view is never needed.
690
+ Napi::Value MakePumpPendingCount(const Napi::CallbackInfo& info) {
691
+ Napi::Env env = info.Env();
692
+ static_assert(sizeof(g_pump_async_pending) == sizeof(int32_t),
693
+ "the JS Int32Array view requires a 32-bit counter");
694
+ // External buffer over static storage — no finalizer needed, the counter
695
+ // outlives every env.
696
+ Napi::ArrayBuffer buf =
697
+ Napi::ArrayBuffer::New(env, &g_pump_async_pending, sizeof(g_pump_async_pending));
698
+ return Napi::TypedArrayOf<int32_t>::New(env, 1, buf, 0, napi_int32_array);
699
+ }
700
+
635
701
  // In-flight scope=async keep-alive: while any GI scope=async callback (a
636
702
  // GAsyncReadyCallback) is pending, REF the (always-active) prepare handle so the
637
703
  // libuv loop stays alive until the completion arrives — the exact analogue of
638
704
  // Node's own in-flight I/O keeping the process alive, and the reason a plain
639
705
  // top-level `await` on a Gio async op settles instead of exiting with an
640
- // unsettled-TLA error. Main-thread only; counted only for the pump-owning env.
706
+ // unsettled-TLA error.
707
+ //
708
+ // The COUNTER is maintained on every runtime, not just Node: it is the
709
+ // "in-flight async work" half of the keep-alive contract, and Bun/Deno's
710
+ // portable pump reads it through MainContextHasPending() to decide whether its
711
+ // timer holds the runtime's event loop open. Only the uv ref/unref half is
712
+ // Node-only (it needs the uv handles the uv pump owns). Main-thread only —
713
+ // where a pump env is known (Node), a foreign env is not counted.
641
714
  bool NodeGiPumpAsyncBegin(napi_env env) {
642
- if (!g_pump_inited || env != g_loop_env) return false;
643
- if (++g_pump_async_pending == 1 && !g_pump_prepare_reffed) {
715
+ if (g_loop_env != nullptr && env != g_loop_env) return false;
716
+ ++g_pump_async_pending;
717
+ if (g_pump_inited && g_pump_async_pending == 1 && !g_pump_prepare_reffed) {
644
718
  uv_ref(reinterpret_cast<uv_handle_t*>(&g_pump_prepare));
645
719
  g_pump_prepare_reffed = TRUE;
646
720
  }
@@ -648,8 +722,9 @@ bool NodeGiPumpAsyncBegin(napi_env env) {
648
722
  }
649
723
 
650
724
  void NodeGiPumpAsyncEnd() {
651
- if (!g_pump_inited || g_pump_async_pending == 0) return;
652
- if (--g_pump_async_pending == 0 && g_pump_prepare_reffed) {
725
+ if (g_pump_async_pending == 0) return;
726
+ --g_pump_async_pending;
727
+ if (g_pump_inited && g_pump_async_pending == 0 && g_pump_prepare_reffed) {
653
728
  uv_unref(reinterpret_cast<uv_handle_t*>(&g_pump_prepare));
654
729
  g_pump_prepare_reffed = FALSE;
655
730
  }