@gjsify/node-gi 0.24.1 → 0.25.1

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/globals.d.ts CHANGED
@@ -32,17 +32,11 @@ export interface GjsImports {
32
32
  }
33
33
 
34
34
  declare global {
35
- // eslint-disable-next-line no-var
36
35
  var print: (...args: unknown[]) => void;
37
- // eslint-disable-next-line no-var
38
36
  var printerr: (...args: unknown[]) => void;
39
- // eslint-disable-next-line no-var
40
37
  var log: (...args: unknown[]) => void;
41
- // eslint-disable-next-line no-var
42
38
  var logError: (error: unknown, prefix?: string) => void;
43
- // eslint-disable-next-line no-var
44
39
  var ARGV: string[];
45
- // eslint-disable-next-line no-var
46
40
  var imports: GjsImports;
47
41
  }
48
42
 
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 {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gjsify/node-gi",
3
- "version": "0.24.1",
3
+ "version": "0.25.1",
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/calls.cc CHANGED
@@ -545,6 +545,7 @@ static Napi::Value InvokeFunctionInfo(Napi::Env env, GIFunctionInfo* func, gpoin
545
545
  // the invoke (the callee ref'd the GBytes if it kept it), transfer-full refs
546
546
  // belong to the callee (dropped only when the callee never ran).
547
547
  CreatedBytes createdBytes;
548
+ CreatedValues createdValues;
548
549
  bool ok = true;
549
550
  size_t jsCursor = 0;
550
551
  for (unsigned int i = 0; i < n_args && ok; i++) {
@@ -800,7 +801,7 @@ static Napi::Value InvokeFunctionInfo(Napi::Env env, GIFunctionInfo* func, gpoin
800
801
  } else {
801
802
  GITransfer tr = gi_arg_info_get_ownership_transfer(ai);
802
803
  ok = JsToGIArgument(env, v, ti, &in_args[inPos[i]], &held[i], tr, &ownedInStrings,
803
- &createdClosures, &createdBytes);
804
+ &createdClosures, &createdBytes, &createdValues);
804
805
  }
805
806
  }
806
807
 
@@ -818,6 +819,9 @@ static Napi::Value InvokeFunctionInfo(Napi::Env env, GIFunctionInfo* func, gpoin
818
819
  // Likewise every fresh GBytes built from a JS typed array — never adopted.
819
820
  for (GBytes* b : createdBytes.transferNone) g_bytes_unref(b);
820
821
  for (GBytes* b : createdBytes.transferFull) g_bytes_unref(b);
822
+ // Likewise every GValue boxed from a plain JS value — never adopted.
823
+ for (GValue* gv : createdValues.transferNone) NodeGiFreeBoxedGValue(gv);
824
+ for (GValue* gv : createdValues.transferFull) NodeGiFreeBoxedGValue(gv);
821
825
  // Likewise the transfer-full-instance ref: the callee never consumed it.
822
826
  if (instanceRefTaken) g_object_unref(static_cast<GObject*>(instance));
823
827
  for (gpointer s : ownedInStrings) g_free(s); // never reached the callee (#658)
@@ -857,6 +861,11 @@ static Napi::Value InvokeFunctionInfo(Napi::Env env, GIFunctionInfo* func, gpoin
857
861
  // paths). A callee that kept the bytes (GdkPixbuf.Pixbuf.new_from_bytes) holds
858
862
  // its own ref. Transfer-full GBytes were adopted by the callee — no release.
859
863
  for (GBytes* b : createdBytes.transferNone) g_bytes_unref(b);
864
+ // Transfer-none GValue IN-args boxed from plain JS values: the callee copied
865
+ // whatever it needed (g_object_set_property copies into the property), so the
866
+ // box is ours to free — success AND error paths, mirroring the GBytes rule.
867
+ // Transfer-full GValues were adopted by the callee — no release.
868
+ for (GValue* gv : createdValues.transferNone) NodeGiFreeBoxedGValue(gv);
860
869
  if (!success) {
861
870
  for (const InContainer& c : inContainers) FreeInContainer(c);
862
871
  // 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
  //
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
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)).