@gjsify/node-gi 0.24.2 → 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
@@ -179,11 +179,32 @@ if (!isNodeRuntime && RUNTIME !== 'gjs') {
179
179
  // (Bun's own processTicksAndRejections calls it from JS).
180
180
  ({ drainMicrotasks: drain } = require('bun:jsc'));
181
181
  } else if (typeof globalThis.Deno?.internal === 'symbol') {
182
- // V8: core.runMicrotasks → op_run_microtasks →
183
- // Isolate::PerformMicrotaskCheckpoint (a reentrant op, explicitly
184
- // safe with JS frames on the stack).
182
+ // V8: the checkpoint needs BOTH queues. core.runMicrotasks is only
183
+ // Isolate::PerformMicrotaskCheckpoint; Deno's node-compat
184
+ // process.nextTick queue is a SEPARATE deno_core queue
185
+ // (ext:deno_node/_next_tick.ts → core.queueNextTick) that only the
186
+ // runtime's own event loop drains — and that loop is paused for the
187
+ // whole lifetime of a blocking run(). node:stream delivers stream
188
+ // 'end' via process.nextTick (endReadableNT), so with a
189
+ // microtask-only drain any body consumption over a Readable
190
+ // (@gjsify/fetch consumeBody → XHR arrayBuffer/text) got its chunks
191
+ // (microtask-delivered) but never the end: every asset load hung at
192
+ // readyState 3 forever on Deno while the SAME bundle settled on Bun,
193
+ // whose nextTick rides JSC's microtask queue (bun:jsc
194
+ // drainMicrotasks covers it). core.runNextTicks mirrors node's
195
+ // task_queues.js runNextTicks: microtask checkpoint when no ticks
196
+ // are scheduled, full processTicksAndRejections (ticks + microtasks
197
+ // interleaved) when there are — one call, both queues. Regression:
198
+ // test/blocking-run-checkpoint.test.mjs.
185
199
  const core = globalThis.Deno[globalThis.Deno.internal]?.core;
186
- if (typeof core?.runMicrotasks === 'function') drain = () => core.runMicrotasks();
200
+ if (typeof core?.runNextTicks === 'function') {
201
+ drain = () => core.runNextTicks();
202
+ } else if (typeof core?.runMicrotasks === 'function') {
203
+ // Older deno_core without runNextTicks: microtask-only drain
204
+ // (pre-fix behaviour — promise continuations settle, nextTick
205
+ // consumers like node:stream 'end' still starve).
206
+ drain = () => core.runMicrotasks();
207
+ }
187
208
  }
188
209
  if (typeof drain === 'function') native.setMicrotaskDrain(drain);
189
210
  } catch {
@@ -640,6 +661,35 @@ export const startMainLoop = native.startMainLoop;
640
661
  */
641
662
  export const iterateMainContext = native.iterateMainContext;
642
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
+
643
693
  /**
644
694
  * Drain ready GLib sources + re-arm the auto-pump's libuv wake-ups NOW (no-op
645
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.24.2",
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);
@@ -545,6 +562,7 @@ static Napi::Value InvokeFunctionInfo(Napi::Env env, GIFunctionInfo* func, gpoin
545
562
  // the invoke (the callee ref'd the GBytes if it kept it), transfer-full refs
546
563
  // belong to the callee (dropped only when the callee never ran).
547
564
  CreatedBytes createdBytes;
565
+ CreatedValues createdValues;
548
566
  bool ok = true;
549
567
  size_t jsCursor = 0;
550
568
  for (unsigned int i = 0; i < n_args && ok; i++) {
@@ -800,7 +818,7 @@ static Napi::Value InvokeFunctionInfo(Napi::Env env, GIFunctionInfo* func, gpoin
800
818
  } else {
801
819
  GITransfer tr = gi_arg_info_get_ownership_transfer(ai);
802
820
  ok = JsToGIArgument(env, v, ti, &in_args[inPos[i]], &held[i], tr, &ownedInStrings,
803
- &createdClosures, &createdBytes);
821
+ &createdClosures, &createdBytes, &createdValues);
804
822
  }
805
823
  }
806
824
 
@@ -818,6 +836,9 @@ static Napi::Value InvokeFunctionInfo(Napi::Env env, GIFunctionInfo* func, gpoin
818
836
  // Likewise every fresh GBytes built from a JS typed array — never adopted.
819
837
  for (GBytes* b : createdBytes.transferNone) g_bytes_unref(b);
820
838
  for (GBytes* b : createdBytes.transferFull) g_bytes_unref(b);
839
+ // Likewise every GValue boxed from a plain JS value — never adopted.
840
+ for (GValue* gv : createdValues.transferNone) NodeGiFreeBoxedGValue(gv);
841
+ for (GValue* gv : createdValues.transferFull) NodeGiFreeBoxedGValue(gv);
821
842
  // Likewise the transfer-full-instance ref: the callee never consumed it.
822
843
  if (instanceRefTaken) g_object_unref(static_cast<GObject*>(instance));
823
844
  for (gpointer s : ownedInStrings) g_free(s); // never reached the callee (#658)
@@ -857,6 +878,11 @@ static Napi::Value InvokeFunctionInfo(Napi::Env env, GIFunctionInfo* func, gpoin
857
878
  // paths). A callee that kept the bytes (GdkPixbuf.Pixbuf.new_from_bytes) holds
858
879
  // its own ref. Transfer-full GBytes were adopted by the callee — no release.
859
880
  for (GBytes* b : createdBytes.transferNone) g_bytes_unref(b);
881
+ // Transfer-none GValue IN-args boxed from plain JS values: the callee copied
882
+ // whatever it needed (g_object_set_property copies into the property), so the
883
+ // box is ours to free — success AND error paths, mirroring the GBytes rule.
884
+ // Transfer-full GValues were adopted by the callee — no release.
885
+ for (GValue* gv : createdValues.transferNone) NodeGiFreeBoxedGValue(gv);
860
886
  if (!success) {
861
887
  for (const InContainer& c : inContainers) FreeInContainer(c);
862
888
  // A failed invoke did not adopt the transfer-full IN/INOUT strings we g_strdup'd
package/src/common.h CHANGED
@@ -295,6 +295,33 @@ struct CreatedBytes {
295
295
  std::vector<GBytes*> transferFull; // callee adopts; unref only when the callee never ran
296
296
  };
297
297
 
298
+ // A plain JS value handed to a `GObject.Value` IN-arg is BOXED into a fresh
299
+ // GValue whose GType is guessed from the JS value — exactly what GJS does
300
+ // (refs/gjs/gi/arg-cache.cpp GValueIn::in → gjs_value_to_g_value, whose
301
+ // gjs_value_guess_g_type supplies the type for an uninitialized GValue). Without
302
+ // it, the single most common GObject call in existence —
303
+ // `obj.set_property('name', 0.5)`, whose second GI argument is a GValue — fails
304
+ // with "Unsupported interface IN argument", because a JS number is not a boxed
305
+ // handle. An existing GObject.Value boxed handle still routes through the
306
+ // boxed-handle path and is passed through untouched.
307
+ //
308
+ // Lifetime mirrors CreatedBytes: the box is freed after the invoke for
309
+ // transfer-none (the callee copied whatever it needed — g_object_set_property
310
+ // copies into the property), and left with the callee for transfer-full.
311
+ struct CreatedValues {
312
+ std::vector<GValue*> transferNone; // free after the invoke (callee borrowed)
313
+ std::vector<GValue*> transferFull; // callee adopts; free only when it never ran
314
+ };
315
+
316
+ // Release a GValue this layer boxed: unset the held value (dropping the ref an
317
+ // object/boxed/string GValue took) and free the struct. A plain `g_free` would
318
+ // leak whatever the GValue owns.
319
+ inline void NodeGiFreeBoxedGValue(GValue* gv) {
320
+ if (gv == nullptr) return;
321
+ if (G_IS_VALUE(gv)) g_value_unset(gv);
322
+ g_free(gv);
323
+ }
324
+
298
325
  // Build the (floating) marshaled GClosure for a JS function (signals.cc — shares
299
326
  // the battle-tested JsClosure marshal/finalize machinery with `.connect()`).
300
327
  GClosure* NodeGiMakeGenericJsClosure(Napi::Env env, Napi::Value fn);
@@ -309,12 +336,16 @@ GClosure* NodeGiMakeGenericJsClosure(Napi::Env env, Napi::Value fn);
309
336
  // `bytes` (optional): enables the JS-bytes→GBytes IN-arg marshalling above;
310
337
  // nullptr (the default) keeps the previous behaviour (typed array → TypeError) on
311
338
  // paths that cannot release a created GBytes.
339
+ // `values` (optional): enables the JS-value→GValue IN-arg boxing above; nullptr
340
+ // (the default) keeps the previous behaviour (plain value → TypeError) on paths
341
+ // that cannot release a created GValue.
312
342
  bool JsToGIArgument(Napi::Env env, Napi::Value v, GITypeInfo* type, GIArgument* out,
313
343
  std::string* heldString,
314
344
  GITransfer transfer = GI_TRANSFER_NOTHING,
315
345
  std::vector<gpointer>* ownedStrings = nullptr,
316
346
  CreatedClosures* closures = nullptr,
317
- CreatedBytes* bytes = nullptr);
347
+ CreatedBytes* bytes = nullptr,
348
+ CreatedValues* values = nullptr);
318
349
 
319
350
  // ---- IN container building -----------------------------------------
320
351
  //
@@ -604,6 +635,12 @@ Napi::Value BindingGroupBindFull(const Napi::CallbackInfo& info);
604
635
  // loop.cc
605
636
  Napi::Value StartMainLoop(const Napi::CallbackInfo& info);
606
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);
607
644
  Napi::Value PumpKick(const Napi::CallbackInfo& info);
608
645
  Napi::Value SetMicrotaskDrain(const Napi::CallbackInfo& info);
609
646
 
@@ -612,8 +649,10 @@ Napi::Value SetMicrotaskDrain(const Napi::CallbackInfo& info);
612
649
  // In-flight scope=async keep-alive: while a GI scope=async callback (e.g. a
613
650
  // GAsyncReadyCallback) is pending, the pump holds a libuv ref so a plain Node
614
651
  // script survives until the completion dispatches (the top-level-await case).
615
- // Begin returns whether the callback was counted (only on the pump-owning env,
616
- // 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
617
656
  // once for each counted callback. Main-thread only.
618
657
  bool NodeGiPumpAsyncBegin(napi_env env);
619
658
  void NodeGiPumpAsyncEnd();
package/src/loop.cc CHANGED
@@ -108,8 +108,14 @@ static void DrainMicrotasks() {
108
108
  //
109
109
  // Portable fix: N-API exposes no engine microtask checkpoint, but both runtimes
110
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
111
+ // JSC's VM drain, callable mid-stack by design, and Bun's process.nextTick
112
+ // rides that same queue; Deno: core.runNextTicks — node's task_queues.js
113
+ // runNextTicks shape, draining the node-compat process.nextTick queue AND the
114
+ // V8 microtask checkpoint. core.runMicrotasks alone is NOT enough on Deno:
115
+ // its nextTick queue is separate from V8's microtasks, and node:stream's
116
+ // 'end' is nextTick-delivered (endReadableNT), so a microtask-only drain hung
117
+ // every fetch/XHR body consumption at readyState 3 during a blocking run —
118
+ // see test/blocking-run-checkpoint.test.mjs). index.js registers
113
119
  // it here on non-Node runtimes only; the loop-dispatched trampolines
114
120
  // (signals.cc / calls.cc / class.cc) invoke NodeGiMaybeDrainMicrotasks at their
115
121
  // OUTERMOST boundary (g_loopDispatchDepth == 0). Node never registers — its
@@ -544,14 +550,31 @@ static void PumpArmWakeups() {
544
550
  if (g_pump_async_pending > 0 && (timeout < 0 || timeout > 32)) timeout = 32;
545
551
  }
546
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).
547
568
  if (timeout >= 0) {
548
569
  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) {
570
+ } else {
554
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) {
555
578
  uv_unref(reinterpret_cast<uv_handle_t*>(&g_pump_timer));
556
579
  g_pump_timer_reffed = FALSE;
557
580
  }
@@ -626,15 +649,72 @@ static void PumpInit(uv_loop_t* loop) {
626
649
  g_pump_inited = TRUE;
627
650
  }
628
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
+
629
701
  // In-flight scope=async keep-alive: while any GI scope=async callback (a
630
702
  // GAsyncReadyCallback) is pending, REF the (always-active) prepare handle so the
631
703
  // libuv loop stays alive until the completion arrives — the exact analogue of
632
704
  // Node's own in-flight I/O keeping the process alive, and the reason a plain
633
705
  // 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.
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.
635
714
  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) {
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) {
638
718
  uv_ref(reinterpret_cast<uv_handle_t*>(&g_pump_prepare));
639
719
  g_pump_prepare_reffed = TRUE;
640
720
  }
@@ -642,8 +722,9 @@ bool NodeGiPumpAsyncBegin(napi_env env) {
642
722
  }
643
723
 
644
724
  void NodeGiPumpAsyncEnd() {
645
- if (!g_pump_inited || g_pump_async_pending == 0) return;
646
- 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) {
647
728
  uv_unref(reinterpret_cast<uv_handle_t*>(&g_pump_prepare));
648
729
  g_pump_prepare_reffed = FALSE;
649
730
  }
package/src/marshal.cc CHANGED
@@ -3,6 +3,7 @@
3
3
 
4
4
  #include "common.h"
5
5
 
6
+ #include <cmath>
6
7
  #include <unordered_map>
7
8
  #include <utility>
8
9
 
@@ -190,6 +191,101 @@ gpointer TransferBoxedIn(BoxedHandle* h, GITransfer transfer) {
190
191
  return h->ptr;
191
192
  }
192
193
 
194
+ // Whether `v` is a boxed handle that ALREADY holds a GValue — the one shape a
195
+ // `GObject.Value` IN-arg takes as-is (gjs GValueIn::in passes it through when the
196
+ // JS object's own gtype is G_TYPE_VALUE, and boxes EVERYTHING else, including
197
+ // other boxed handles: a GVariant handed to `set_property('state', …)` becomes a
198
+ // G_TYPE_VARIANT GValue, it does not become the GValue).
199
+ static bool NodeGiIsGValueHandle(Napi::Value v) {
200
+ BoxedHandle* h = TryGetBoxedHandle(v);
201
+ return h != nullptr && h->gtype != G_TYPE_INVALID && g_type_is_a(h->gtype, G_TYPE_VALUE);
202
+ }
203
+
204
+ // Initialise `out` from a plain JS value, guessing the GType the way gjs does
205
+ // for an uninitialized GValue (refs/gjs/gi/value.cpp gjs_value_guess_g_type +
206
+ // gjs_value_to_g_value_internal). Returns false with a pending JS exception when
207
+ // the value has no sensible GValue representation.
208
+ //
209
+ // The int32-vs-double split matches gjs exactly (`value.isInt32()` →
210
+ // `G_TYPE_INT`, `value.isDouble()` → `G_TYPE_DOUBLE`); JS has no such tag at the
211
+ // N-API boundary, so an integral number in int32 range stands in for isInt32.
212
+ // That distinction is not load-bearing for correctness — `g_object_set_property`
213
+ // transforms between the numeric fundamentals — but staying bit-for-bit with gjs
214
+ // keeps a property that reads the GValue's own type behaving identically.
215
+ static bool JsToFreshGValue(Napi::Env env, Napi::Value v, GValue* out) {
216
+ if (v.IsNull() || v.IsUndefined()) {
217
+ // gjs guesses G_TYPE_POINTER for null. A real GValue holding NULL is also
218
+ // the only thing a callee can safely dereference — passing a NULL GValue*
219
+ // (the plain boxed-null path) would crash g_object_set_property.
220
+ g_value_init(out, G_TYPE_POINTER);
221
+ g_value_set_pointer(out, nullptr);
222
+ return true;
223
+ }
224
+ if (v.IsBoolean()) {
225
+ g_value_init(out, G_TYPE_BOOLEAN);
226
+ g_value_set_boolean(out, v.As<Napi::Boolean>().Value() ? TRUE : FALSE);
227
+ return true;
228
+ }
229
+ if (v.IsString()) {
230
+ g_value_init(out, G_TYPE_STRING);
231
+ g_value_set_string(out, v.As<Napi::String>().Utf8Value().c_str());
232
+ return true;
233
+ }
234
+ if (v.IsBigInt()) {
235
+ bool lossless = false;
236
+ int64_t i64 = v.As<Napi::BigInt>().Int64Value(&lossless);
237
+ if (lossless || i64 < 0) {
238
+ g_value_init(out, G_TYPE_INT64);
239
+ g_value_set_int64(out, i64);
240
+ } else {
241
+ bool ulossless = false;
242
+ g_value_init(out, G_TYPE_UINT64);
243
+ g_value_set_uint64(out, v.As<Napi::BigInt>().Uint64Value(&ulossless));
244
+ }
245
+ return true;
246
+ }
247
+ if (v.IsNumber()) {
248
+ double d = v.As<Napi::Number>().DoubleValue();
249
+ if (d == std::trunc(d) && d >= static_cast<double>(G_MININT32) &&
250
+ d <= static_cast<double>(G_MAXINT32)) {
251
+ g_value_init(out, G_TYPE_INT);
252
+ g_value_set_int(out, static_cast<gint>(d));
253
+ } else {
254
+ g_value_init(out, G_TYPE_DOUBLE);
255
+ g_value_set_double(out, d);
256
+ }
257
+ return true;
258
+ }
259
+ if (v.IsExternal()) {
260
+ // A GObject handle → a G_TYPE_OBJECT GValue carrying its own GType, so a
261
+ // property typed against a subclass still accepts it.
262
+ Napi::External<GObject> objExt = v.As<Napi::External<GObject>>();
263
+ if (objExt.CheckTypeTag(&kGObjectHandleTag)) {
264
+ GObject* obj = objExt.Data();
265
+ GType gt = obj != nullptr ? G_OBJECT_TYPE(obj) : G_TYPE_OBJECT;
266
+ g_value_init(out, gt);
267
+ g_value_set_object(out, obj);
268
+ return true;
269
+ }
270
+ // A boxed handle → its own registered GType (GVariant has its own
271
+ // fundamental; a non-registered C struct has no GType to name and is
272
+ // rejected rather than guessed at).
273
+ BoxedHandle* h = TryGetBoxedHandle(v);
274
+ if (h != nullptr && h->gtype != G_TYPE_INVALID) {
275
+ g_value_init(out, h->gtype);
276
+ if (h->gtype == G_TYPE_VARIANT) g_value_set_variant(out, static_cast<GVariant*>(h->ptr));
277
+ else if (G_TYPE_IS_BOXED(h->gtype)) g_value_set_boxed(out, h->ptr);
278
+ else g_value_set_pointer(out, h->ptr);
279
+ return true;
280
+ }
281
+ }
282
+ Napi::TypeError::New(
283
+ env, "Cannot convert this value to a GObject.Value (expected a boolean, number, bigint, "
284
+ "string, null, or a GObject/boxed handle)")
285
+ .ThrowAsJavaScriptException();
286
+ return false;
287
+ }
288
+
193
289
  // Read a boxed handle's pointer if `v` is one (tag-checked; no deref of ptr).
194
290
  bool TryGetBoxedPtr(Napi::Value v, gpointer* out) {
195
291
  if (!v.IsExternal()) return false;
@@ -337,7 +433,8 @@ bool JsToGIArgument(Napi::Env env, Napi::Value v, GITypeInfo* type, GIArgument*
337
433
  GITransfer transfer,
338
434
  std::vector<gpointer>* ownedStrings,
339
435
  CreatedClosures* closures,
340
- CreatedBytes* bytes) {
436
+ CreatedBytes* bytes,
437
+ CreatedValues* values) {
341
438
  if (v.IsEmpty()) {
342
439
  // Residue of a swallowed napi failure (a fallible Get()/coercion upstream
343
440
  // failed on a terminating env, or a throwing getter left the exception
@@ -399,7 +496,31 @@ bool JsToGIArgument(Napi::Env env, Napi::Value v, GITypeInfo* type, GIArgument*
399
496
  out->v_pointer = nullptr;
400
497
  handled = true;
401
498
  } else if (v.IsExternal()) {
402
- out->v_pointer = v.As<Napi::External<GObject>>().Data();
499
+ GObject* obj = v.As<Napi::External<GObject>>().Data();
500
+ // A (transfer full) GObject IN arg CONSUMES a reference: the callee
501
+ // takes over the one it is handed. Passing the handle's own ref
502
+ // makes the callee and the JS wrapper share a single ref that BOTH
503
+ // release — the wrapper's finalize then frees an object the callee
504
+ // still owns, and the callee is left dereferencing freed memory.
505
+ //
506
+ // `gtk_widget_add_controller()` is the canonical case and the one
507
+ // that surfaced it: GTK keeps the controller in the widget's list,
508
+ // the JS wrapper for it goes out of scope, and the next pointer
509
+ // motion over the widget hits
510
+ //
511
+ // Gtk-CRITICAL **: gtk_event_controller_handle_crossing:
512
+ // assertion 'GTK_IS_EVENT_CONTROLLER (controller)' failed
513
+ //
514
+ // NONDETERMINISTICALLY — it needs a GC between the add and the
515
+ // event, which is why it looked unrelated to anything nearby.
516
+ //
517
+ // gjs takes the ref here (wrapperutils.h
518
+ // `transfer_to_gi_argument`: `GI_DIRECTION_IN && transfer !=
519
+ // GI_TRANSFER_NOTHING` → `copy_ptr` → `g_object_ref`), and this
520
+ // file already does the equivalent for BOXED args via
521
+ // `TransferBoxedIn`. Objects were simply never given the same rule.
522
+ if (transfer != GI_TRANSFER_NOTHING && obj != nullptr) g_object_ref(obj);
523
+ out->v_pointer = obj;
403
524
  handled = true;
404
525
  }
405
526
  } else if (GI_IS_ENUM_INFO(iface) || GI_IS_FLAGS_INFO(iface)) {
@@ -454,6 +575,29 @@ bool JsToGIArgument(Napi::Env env, Napi::Value v, GITypeInfo* type, GIArgument*
454
575
  if (transfer == GI_TRANSFER_NOTHING) bytes->transferNone.push_back(b);
455
576
  else bytes->transferFull.push_back(b);
456
577
  handled = true;
578
+ } else if (values != nullptr && !NodeGiIsGValueHandle(v) &&
579
+ gi_registered_type_info_get_g_type(
580
+ reinterpret_cast<GIRegisteredTypeInfo*>(iface)) == G_TYPE_VALUE) {
581
+ // A plain JS value for a `GObject.Value` IN-arg → a fresh GValue whose
582
+ // GType is guessed from the value, exactly as gjs (refs/gjs
583
+ // gi/arg-cache.cpp GValueIn::in → gjs_value_to_g_value). This is what
584
+ // makes `obj.set_property('volume', 0.5)` — whose GI signature takes a
585
+ // GValue — work at all; a JS number is not a boxed handle, so it used
586
+ // to fall through to the "Unsupported interface IN argument" throw.
587
+ //
588
+ // An EXISTING GObject.Value handle is excluded here and routes through
589
+ // the boxed-handle branch below untouched, matching gjs's pass-through
590
+ // for an already-typed GValue.
591
+ GValue* boxed = g_new0(GValue, 1);
592
+ if (!JsToFreshGValue(env, v, boxed)) {
593
+ g_free(boxed);
594
+ gi_base_info_unref(iface);
595
+ return false; // JsToFreshGValue already threw
596
+ }
597
+ out->v_pointer = boxed;
598
+ if (transfer == GI_TRANSFER_NOTHING) values->transferNone.push_back(boxed);
599
+ else values->transferFull.push_back(boxed);
600
+ handled = true;
457
601
  } else if (v.IsNull() || v.IsUndefined()) {
458
602
  // Boxed/struct IN args arrive as boxed handles; null/undefined maps to
459
603
  // a NULL pointer (e.g. GLib.MainLoop.new(null, false)).