@gjsify/node-gi 0.45.0 → 0.46.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/object.cc CHANGED
@@ -783,6 +783,31 @@ Napi::Value ClassInfoForTypeName(const Napi::CallbackInfo& info) {
783
783
  // union/enum/flags), as a node-gi GType handle. The L1 layer surfaces it as a
784
784
  // lazy `Ns.Type.$gtype` getter (so `Adw.Clamp.$gtype` / `GObject.type_ensure(...)`
785
785
  // work). Returns null for an unknown or unregistered name.
786
+ //
787
+ // It also REALISES a classed type, because gjs's whole type system rests on the
788
+ // invariant its own gi/function.cpp states — "the GType class is referenced at least
789
+ // once when the JS constructor is initialized". Signals and properties are installed
790
+ // in `class_init`, which GLib defers until something takes a `g_type_class_ref`, so a
791
+ // class NOTHING in the process has referenced reports none of its own: measured,
792
+ // `GObject.signal_lookup('popped', Adw.NavigationView.$gtype)` answered 84 under gjs
793
+ // and 0 here, and `signal_lookup('activate', Gio.Application.$gtype)` 28 against 0 —
794
+ // a wrong answer with nothing thrown, which a caller reads as "this class has no such
795
+ // signal" (#1438). `$gtype` is the one read every such lookup goes through and gi.js
796
+ // memoises it, so this costs exactly one `g_type_class_ref` per GType.
797
+ //
798
+ // The ref is KEPT, for the reason calls.cc's ClassStructInstanceForGType keeps its
799
+ // own: it establishes the invariant once, a static type's class is never really freed
800
+ // (measured on glib 2.88.3 — `g_type_class_peek` still answers the same pointer after
801
+ // an unref), and pairing could only invalidate what a caller is still holding. The
802
+ // `g_type_class_peek` guard bounds it at ONE ref per GType for the process, whatever
803
+ // order the two ref sites are reached in.
804
+ //
805
+ // Guarded on G_TYPE_IS_CLASSED because the UNCLASSED fundamentals — boxed/struct and
806
+ // interface — have no class: `g_type_class_ref` answers nullptr there after a
807
+ // `GLib-GObject-CRITICAL`, so without the guard every `GLib.Bytes.$gtype` read would
808
+ // print one. ENUM and FLAGS are classed (GEnumClass/GFlagsClass) and deliberately go
809
+ // through, as they do in gjs. Nothing THROWS on the unguarded path, so the witness is
810
+ // `class-realization.test.mjs`'s child-process stderr case, not an in-process read.
786
811
  Napi::Value GetGType(const Napi::CallbackInfo& info) {
787
812
  Napi::Env env = info.Env();
788
813
  if (info.Length() < 2 || !info[0].IsString() || !info[1].IsString()) {
@@ -800,6 +825,7 @@ Napi::Value GetGType(const Napi::CallbackInfo& info) {
800
825
  if (base != nullptr) gi_base_info_unref(base);
801
826
  g_object_unref(repo);
802
827
  if (gt == G_TYPE_INVALID || gt == G_TYPE_NONE) return env.Null();
828
+ if (G_TYPE_IS_CLASSED(gt) && g_type_class_peek(gt) == nullptr) g_type_class_ref(gt);
803
829
  return MakeGTypeHandle(env, gt);
804
830
  }
805
831
 
package/src/repo.cc CHANGED
@@ -13,6 +13,7 @@ void NodeGiEnvDataFinalize(napi_env env, void* data, void* /*hint*/) {
13
13
  NodeGiEnvData* d = static_cast<NodeGiEnvData*>(data);
14
14
  if (d == nullptr) return;
15
15
  if (d->errorBuilder != nullptr) napi_delete_reference(env, d->errorBuilder);
16
+ if (d->errorClass != nullptr) napi_delete_reference(env, d->errorClass);
16
17
  if (d->templateCallbackResolver != nullptr)
17
18
  napi_delete_reference(env, d->templateCallbackResolver);
18
19
  if (d->cairoWrappers != nullptr) napi_delete_reference(env, d->cairoWrappers);
@@ -258,14 +259,18 @@ Napi::Value GetErrorDomain(const Napi::CallbackInfo& info) {
258
259
  return result;
259
260
  }
260
261
 
261
- // setErrorBuilder(builder: (domainName, domainQuark, code, message) => Error) -> void
262
+ // setErrorBuilder(builder: (domainName, domainQuark, code, message) => Error,
263
+ // errorClass?: typeof GLib.Error) -> void
262
264
  // Register the L1 GLib.Error factory the engine calls when a GI invoke fails, so a
263
- // failed sync call throws a real GLib.Error. Stored per-env (a napi_ref is env-
264
- // specific; the throw path is gated on the same env).
265
+ // failed sync call throws a real GLib.Error, together with the class it builds —
266
+ // which the GError IN-arg path needs to recognise one coming back. Both stored
267
+ // per-env (a napi_ref is env-specific; both paths are gated on the same env), in
268
+ // ONE call so a registration cannot supply the builder and forget the class.
265
269
  Napi::Value SetErrorBuilder(const Napi::CallbackInfo& info) {
266
270
  Napi::Env env = info.Env();
267
271
  if (info.Length() < 1 || !info[0].IsFunction()) {
268
- Napi::TypeError::New(env, "setErrorBuilder(builder: function)").ThrowAsJavaScriptException();
272
+ Napi::TypeError::New(env, "setErrorBuilder(builder: function, errorClass?: function)")
273
+ .ThrowAsJavaScriptException();
269
274
  return env.Undefined();
270
275
  }
271
276
  NodeGiEnvData* d = EnvData(env);
@@ -275,6 +280,12 @@ Napi::Value SetErrorBuilder(const Napi::CallbackInfo& info) {
275
280
  d->errorBuilder = nullptr;
276
281
  }
277
282
  napi_create_reference(env, info[0], 1, &d->errorBuilder);
283
+ if (d->errorClass != nullptr) {
284
+ napi_delete_reference(env, d->errorClass);
285
+ d->errorClass = nullptr;
286
+ }
287
+ if (info.Length() >= 2 && info[1].IsFunction())
288
+ napi_create_reference(env, info[1], 1, &d->errorClass);
278
289
  return env.Undefined();
279
290
  }
280
291
 
package/src/toggle.cc CHANGED
@@ -32,22 +32,35 @@ namespace nodegi {
32
32
  // needed — N-API finalizers are post-GC.
33
33
  // 3. Cross-thread toggles + double-free: off-thread toggles are marshalled to
34
34
  // the JS thread via a mutex-guarded queue drained on the GLib main context;
35
- // a g_object_weak_ref safety net nulls the cached pointer if C finalizes the
36
- // object under us; the idle clears qdata only if it still points at THIS
37
- // wrapper (resurrection-safe); a shutdown flag disables toggles at teardown.
35
+ // a g_object_weak_ref net DETACHES the record from the object the moment glib
36
+ // says we may not touch it again (clearing qdata only if it still points at
37
+ // THIS wrapper — resurrection-safe); a shutdown flag disables toggles at
38
+ // teardown. The detach happens IN the weak notify, not in the teardown that
39
+ // follows it — see OnGObjectFinalized for why, and for what it costs.
38
40
 
39
41
  static GQuark NodeGiWrapperQuark() {
40
42
  static GQuark q = g_quark_from_static_string("node-gi::wrapper");
41
43
  return q;
42
44
  }
43
45
 
46
+ // WHICH FIELDS g_queue_mutex PROTECTS, AND WHO TOUCHES THEM.
47
+ // `env` is written once at construction and never again — read from any thread.
48
+ // `handle_ref`, `rooted` and `teardown_queued`/`settled` are JS-THREAD ONLY by
49
+ // construction: only ApplyToggle (always on the drain/JS thread), the External's
50
+ // finalizer and the resurrection path write them.
51
+ // `gobject` and `toggle_added` are the CROSS-THREAD pair and are guarded by
52
+ // g_queue_mutex: OnGObjectFinalized clears them from whatever thread drops the
53
+ // object's last ref (or calls g_object_run_dispose), while RunTeardown and
54
+ // SettleCollectedInstance read them on the JS thread. The lock does more than
55
+ // order those writes — see OnGObjectFinalized for why an atomic would not do.
44
56
  struct NodeGiInstance {
45
57
  napi_env env;
46
- GObject* gobject; // nulled by the weak-ref safety net if C finalizes it
58
+ GObject* gobject; // g_queue_mutex; cleared by the weak-ref net (see below)
47
59
  napi_ref handle_ref; // ref to the canonical External; strong=rooted, weak=not
48
60
  bool rooted; // true ⇒ handle_ref currently strong (mirrors node-gtk !dying)
49
- bool toggle_added; // a toggle ref is currently installed
50
- bool teardown_queued; // a finalizer already scheduled the idle teardown (dedupe)
61
+ bool toggle_added; // g_queue_mutex; a REMOVABLE toggle ref is installed
62
+ bool teardown_queued; // the External's finalizer HAS RUN (and queued the teardown)
63
+ bool settled; // resurrection detached this record; the pending finalizer frees it
51
64
  };
52
65
 
53
66
  // The single N-API env that OWNS the toggle machinery (qdata cache + global drain
@@ -259,6 +272,23 @@ static void ApplyToggle(NodeGiInstance* inst, bool down) {
259
272
  }
260
273
  }
261
274
 
275
+ // Sever every link between a GObject and a wrapper record: after this, nothing can
276
+ // reach `inst` from `obj` on any thread. Clearing qdata is what keeps a LIVE GObject
277
+ // from outliving its record with a dangling pointer in it (see OnGObjectFinalized).
278
+ //
279
+ // CALLER MUST HOLD g_queue_mutex, and `obj` must be valid FOR THE DURATION OF THE
280
+ // CALL. Each caller has its own warrant for that: the weak-ref net is handed the
281
+ // object while it is still addressable, RunTeardown reads inst->gobject under this
282
+ // lock, and SettleCollectedInstance is passed the caller's construction ref.
283
+ static void DetachInstanceLocked(NodeGiInstance* inst, GObject* obj) {
284
+ if (obj != nullptr && g_object_get_qdata(obj, NodeGiWrapperQuark()) == inst) {
285
+ g_object_set_qdata(obj, NodeGiWrapperQuark(), nullptr);
286
+ }
287
+ for (auto it = g_toggle_queue.begin(); it != g_toggle_queue.end();) {
288
+ it = (it->inst == inst) ? g_toggle_queue.erase(it) : it + 1;
289
+ }
290
+ }
291
+
262
292
  // Run one wrapper's teardown on the JS thread: drop the toggle ref (may dispose →
263
293
  // emit → re-enter JS — legal here, not in a finalizer/off-thread), resurrection-
264
294
  // safely, then free the wrapper.
@@ -266,30 +296,32 @@ static void ApplyToggle(NodeGiInstance* inst, bool down) {
266
296
  // ORDER MATTERS (node-gtk's GObjectTeardownIdle): remove_toggle_ref is LAST,
267
297
  // because dropping the last ref can take refcount to 0 → dispose → finalize → the
268
298
  // GObject is freed; any qdata/weak op after that would touch freed memory. So:
269
- // (1) under the queue lock: detach qdata (only if it still points at US —
270
- // resurrection-safe) AND cancel any queued off-thread toggles for this inst.
271
- // Both under the lock — paired with the off-thread enqueue path, which
272
- // re-reads qdata under the SAME lock — so a racing toggle either enqueues
273
- // then gets cancelled here, or sees the cleared qdata and never enqueues;
274
- // after this no NodeGiToggleNotify can find this inst.
275
- // (2) g_object_weak_unref (drop the safety net before the unref below).
276
- // (3) g_object_remove_toggle_ref LAST (may dispose → emit → re-enter JS, legal
277
- // here on the JS thread; the object may be freed afterwards).
299
+ // (1) under the queue lock: READ inst->gobject, DetachInstanceLocked, drop the
300
+ // weak-ref net. The same lock the off-thread enqueue path and
301
+ // OnGObjectFinalized take, so a racing toggle either enqueues and is
302
+ // cancelled here, or sees the cleared qdata and never enqueues. The READ
303
+ // belongs inside it: it used to sit outside, paired with an unlocked write in
304
+ // OnGObjectFinalized, so a stale non-null pointer was reachable here BY
305
+ // CONSTRUCTION and `obj` could name freed memory.
306
+ // (2) g_object_remove_toggle_ref LAST and OUTSIDE the lock (it may dispose →
307
+ // emit → re-enter arbitrary JS, which must never run under this lock; legal
308
+ // here on the JS thread. The object may be freed afterwards). `obj` is still
309
+ // ours to touch there: the toggle ref we are about to remove IS a reference.
278
310
  static void RunTeardown(NodeGiInstance* inst) {
279
- GObject* obj = inst->gobject; // null if the weak-ref net already fired
311
+ GObject* obj = nullptr;
312
+ bool remove_toggle = false;
280
313
  {
281
314
  std::lock_guard<std::recursive_mutex> guard(g_queue_mutex);
282
- if (obj != nullptr && g_object_get_qdata(obj, NodeGiWrapperQuark()) == inst) {
283
- g_object_set_qdata(obj, NodeGiWrapperQuark(), nullptr);
284
- }
285
- for (auto it = g_toggle_queue.begin(); it != g_toggle_queue.end();) {
286
- it = (it->inst == inst) ? g_toggle_queue.erase(it) : it + 1;
315
+ obj = inst->gobject; // null once the weak-ref net has detached this record
316
+ DetachInstanceLocked(inst, obj);
317
+ if (obj != nullptr) {
318
+ g_object_weak_unref(obj, OnGObjectFinalized, inst);
319
+ remove_toggle = inst->toggle_added;
320
+ inst->toggle_added = false;
321
+ inst->gobject = nullptr;
287
322
  }
288
323
  }
289
- if (obj != nullptr) {
290
- g_object_weak_unref(obj, OnGObjectFinalized, inst);
291
- if (inst->toggle_added) g_object_remove_toggle_ref(obj, NodeGiToggleNotify, nullptr);
292
- }
324
+ if (remove_toggle) g_object_remove_toggle_ref(obj, NodeGiToggleNotify, nullptr);
293
325
  if (inst->handle_ref != nullptr) napi_delete_reference(inst->env, inst->handle_ref);
294
326
  delete inst;
295
327
  }
@@ -455,19 +487,93 @@ static void NodeGiToggleNotify(gpointer /*data*/, GObject* obj, gboolean is_last
455
487
  if (enqueued) WakeDrain(); // outside the lock
456
488
  }
457
489
 
458
- // Weak-ref safety net: if C finalizes the GObject while we still hold the toggle
459
- // ref (defensive — a correct toggle ref prevents this), null the cached pointer
460
- // so teardown never touches freed memory.
461
- static void OnGObjectFinalized(gpointer data, GObject* /*where_the_object_was*/) {
490
+ // Weak-ref net: glib is telling us this record may never touch the object again.
491
+ // It DETACHES the record here, because this callback is the LAST moment the object
492
+ // pointer is guaranteed addressable — RunTeardown, which used to do the detaching,
493
+ // runs later and by then has only the nulled field.
494
+ //
495
+ // "The object was finalized" is NOT what this callback means, and reading it that
496
+ // way is what left a live GObject holding a freed record. `g_object_run_dispose`
497
+ // notifies every GWeakNotify (and clears every GWeakRef) on an object that stays
498
+ // ALIVE — measured on glib 2.88.3. Every `run_dispose()` a JS program makes lands
499
+ // here, and so does `gtk_native_dialog_destroy()`, whose own docs promise it keeps
500
+ // the object's references (measured, GTK 4.22.4: notify fires, dialog alive at
501
+ // rc=1). NOT `gtk_window_destroy()`, measured on the same GTK: it unrealizes and
502
+ // unrefs, it does not run_dispose — that was GTK3's `gtk_widget_destroy`. The old
503
+ // callback only nulled `inst->gobject`, so the teardown then skipped its
504
+ // `g_object_set_qdata(obj, quark, nullptr)` — and the surviving GObject kept a
505
+ // qdata pointer to the record the teardown went on to `delete`. The next thing to
506
+ // hand that GObject back to JS read the freed record (`napi_get_reference_value`
507
+ // on its `handle_ref`) or fed it to the drain, where `g_object_get_qdata` on the
508
+ // recycled record's `gobject` field is the documented SIGSEGV. Regression:
509
+ // gc-identity "run_dispose: a surviving object keeps no freed record in its qdata".
510
+ //
511
+ // WHY THE LOCK AND NOT AN ATOMIC. An atomic exchange on `gobject` would remove the
512
+ // data race and keep the bug: the reader would still be free to act on a pointer
513
+ // that this callback is about to invalidate. g_queue_mutex gives the property that
514
+ // is actually needed — the detach and the teardown's read-then-use of the same
515
+ // pointer are mutually exclusive, so the two can never be interleaved. It costs no
516
+ // deadlock risk: this callback runs with no glib lock held (a `g_object_weak_unref`
517
+ // from inside a weak notify returns — measured), it calls no user code, and the
518
+ // mutex is recursive, so a notify raised by a dispose already under the lock on
519
+ // this thread re-acquires it.
520
+ //
521
+ // THE PRICE when the object OUTLIVES the notify: its toggle ref stays installed —
522
+ // it cannot be removed from here (a dispose→JS re-entry inside a weak notify is
523
+ // exactly what the teardown queue exists to avoid) and glib offers no way to learn
524
+ // later that the object survived. Measured, glib 2.88.3: wrapper identity does not
525
+ // survive a `run_dispose` (C hands back a NEW wrapper), that re-wrap adds a SECOND
526
+ // toggle ref, and glib notifies only while there is exactly one — so the next 1↔2
527
+ // crossing prints `Unexpected number of toggle-refs` instead, the new wrapper never
528
+ // goes weak, and the GObject is immortal. The old code leaked the same toggle ref
529
+ // and took a use-after-free with it. Pinned by gc-identity "run_dispose costs
530
+ // wrapper identity"; the tombstone design that would retire this, and the
531
+ // ref_count discriminator rejected for it, are in status/open-todos.md.
532
+ static void OnGObjectFinalized(gpointer data, GObject* where_the_object_was) {
462
533
  NodeGiInstance* inst = static_cast<NodeGiInstance*>(data);
463
- inst->gobject = nullptr;
534
+ bool enqueued = false;
535
+ {
536
+ std::lock_guard<std::recursive_mutex> guard(g_queue_mutex);
537
+ DetachInstanceLocked(inst, where_the_object_was);
538
+ inst->gobject = nullptr;
539
+ inst->toggle_added = false; // no removable toggle ref left: the object is out of reach
540
+ // Let the husk record be COLLECTED. Detaching clears the qdata slot, so the
541
+ // toggle-down that `g_object_run_dispose` fires from its own closing unref no
542
+ // longer finds this record and can never flip it back to weak — a rooted
543
+ // wrapper that nothing will ever root again is a leak of the wrapper AND the
544
+ // object. Flip it here instead. napi_reference_unref is JS-thread-only, so an
545
+ // off-thread notify queues it; DetachInstanceLocked just emptied the queue of
546
+ // this inst, so the enqueue always lands (no opposite-direction cancel), and
547
+ // RunTeardown/SettleCollectedInstance cancel it again if they get there first.
548
+ //
549
+ // Gated on the shutdown flag exactly as NodeGiToggleNotify's direct apply is:
550
+ // glib notifies from whatever thread drops the ref, which can be long after the
551
+ // owner env is gone, and a napi call there is the abort that flag exists to
552
+ // stop. The DETACH above is pure glib and must run either way.
553
+ if (!g_toggle_shutdown.load()) {
554
+ if (OnMainThread()) ApplyToggle(inst, /*down=*/true);
555
+ else enqueued = EnqueueToggleLocked(inst, /*down=*/true);
556
+ }
557
+ }
558
+ if (enqueued) WakeDrain(); // outside the lock
464
559
  }
465
560
 
466
561
  // The canonical External's finalizer (napi_finalize, runs at a safe point post-GC
467
562
  // but where re-entering GObject teardown is still unsafe). Do the MINIMUM: queue
468
563
  // the teardown + wake the drain async (crash mode 1).
469
564
  static void NodeGiInstanceFinalize(Napi::Env /*env*/, GObject* /*data*/, NodeGiInstance* inst) {
470
- if (inst == nullptr || inst->teardown_queued) return;
565
+ if (inst == nullptr) return;
566
+ if (inst->settled) {
567
+ // Resurrection got here first: SettleCollectedInstance already detached and
568
+ // disarmed this record and deliberately left it allocated for US to free
569
+ // (see the ownership note there). Nothing left to tear down.
570
+ if (NodeGiToggleDebugEnabled())
571
+ NodeGiToggleDebugLog("finalizer: freeing settled inst %p (resurrected earlier)",
572
+ static_cast<void*>(inst));
573
+ delete inst;
574
+ return;
575
+ }
576
+ if (inst->teardown_queued) return;
471
577
  inst->teardown_queued = true;
472
578
  {
473
579
  std::lock_guard<std::recursive_mutex> guard(g_queue_mutex);
@@ -495,28 +601,64 @@ static void NodeGiInstanceFinalize(Napi::Env /*env*/, GObject* /*data*/, NodeGiI
495
601
  // toggle ref per GObject). The caller holds a construction ref on obj, so dropping
496
602
  // the old toggle ref here cannot drive refcount to 0 / dispose. Main-thread only (==
497
603
  // the drain thread), so the pending idle teardown cannot be running concurrently.
604
+ //
605
+ // WHO FREES `old` (#1475): an EMPTY napi_ref proves the wrapper was COLLECTED, not
606
+ // that its finalizer has RUN. V8 resets the weak persistent inside the first-pass
607
+ // weak callback, during GC; Node then defers NodeGiInstanceFinalize to a SetImmediate
608
+ // (node_napi_env__::EnqueueFinalizer). Freeing `old` inside that window is a
609
+ // use-after-free with a reliable second act: the allocator hands the very same block
610
+ // straight back to the fresh `new NodeGiInstance()` below (measured: identical
611
+ // address every run), so the stale finalizer then reads the LIVE wrapper's record,
612
+ // sees teardown_queued == false, and queues a teardown that removes the LIVE toggle
613
+ // ref — dropping the GObject's last ref while JS still holds a wrapper for it. The
614
+ // wreckage surfaces one drain later as `g_object_weak_unref: couldn't find weak ref`
615
+ // plus a double napi_delete_reference / double free (STATUS_HEAP_CORRUPTION on
616
+ // Windows, SIGSEGV on Linux). So ownership of the free follows the finalizer:
617
+ // teardown_queued == true means it already ran and nobody else will free the record;
618
+ // otherwise it is still pending and IT frees the record, guided by `settled`.
498
619
  static void SettleCollectedInstance(GObject* obj, NodeGiInstance* old) {
620
+ bool finalizer_pending = false;
621
+ bool remove_toggle = false;
499
622
  {
500
623
  std::lock_guard<std::recursive_mutex> guard(g_queue_mutex);
501
- // Cancel old's pending teardown + any queued toggles that reference it, so the
624
+ // Cancel old's pending teardown, then DetachInstanceLocked for the rest, so the
502
625
  // drain never touches a freed inst (paired with the off-thread enqueue, which
503
- // re-reads qdata under this SAME lock — and we clear qdata below).
626
+ // re-reads qdata under this SAME lock).
504
627
  for (auto it = g_teardown_queue.begin(); it != g_teardown_queue.end();)
505
628
  it = (it->inst == old) ? g_teardown_queue.erase(it) : it + 1;
506
- for (auto it = g_toggle_queue.begin(); it != g_toggle_queue.end();)
507
- it = (it->inst == old) ? g_toggle_queue.erase(it) : it + 1;
508
- // Detach qdata only if it still points at old (resurrection-safe).
509
- if (g_object_get_qdata(obj, NodeGiWrapperQuark()) == old)
510
- g_object_set_qdata(obj, NodeGiWrapperQuark(), nullptr);
629
+ DetachInstanceLocked(old, obj);
630
+ finalizer_pending = !old->teardown_queued;
631
+ old->settled = true;
632
+ // `old->gobject` and `old->toggle_added` are read here, under the lock the
633
+ // weak-ref net clears them with — a null pointer means the net already ran and
634
+ // the object is out of reach, exactly as in RunTeardown.
635
+ if (old->gobject != nullptr) {
636
+ g_object_weak_unref(obj, OnGObjectFinalized, old);
637
+ remove_toggle = old->toggle_added;
638
+ }
639
+ // Disarm every handle the record still carries, so a pending finalizer that
640
+ // reaches it finds nothing to act on even if `settled` were ever missed.
641
+ old->gobject = nullptr;
642
+ old->toggle_added = false;
511
643
  }
512
- if (old->gobject != nullptr) {
513
- g_object_weak_unref(obj, OnGObjectFinalized, old);
514
- // Remove old's toggle ref BEFORE the fresh one is added below — never two at
515
- // once. We hold a construction ref, so this cannot dispose obj.
516
- if (old->toggle_added) g_object_remove_toggle_ref(obj, NodeGiToggleNotify, nullptr);
644
+ // Remove old's toggle ref BEFORE the fresh one is added by the caller — never two
645
+ // at once. The caller holds a construction ref, so this cannot dispose obj; it is
646
+ // still done outside the lock, because remove_toggle_ref is the one call in this
647
+ // file that may re-enter JS and the lock is never held across that.
648
+ if (remove_toggle) g_object_remove_toggle_ref(obj, NodeGiToggleNotify, nullptr);
649
+ if (old->handle_ref != nullptr) {
650
+ napi_delete_reference(old->env, old->handle_ref);
651
+ old->handle_ref = nullptr;
517
652
  }
518
- if (old->handle_ref != nullptr) napi_delete_reference(old->env, old->handle_ref);
519
- delete old;
653
+ if (NodeGiToggleDebugEnabled())
654
+ NodeGiToggleDebugLog("settle: detached inst %p (obj %p); %s", static_cast<void*>(old),
655
+ static_cast<void*>(obj),
656
+ finalizer_pending ? "finalizer pending, it frees the record"
657
+ : "finalizer already ran, freeing here");
658
+ // Only free when the finalizer has already run; otherwise it owns the free. If a
659
+ // process dies before draining its finalizer queue the record is not freed — the
660
+ // same bounded leak-at-exit the shutdown-dropped teardowns already accept.
661
+ if (!finalizer_pending) delete old;
520
662
  }
521
663
 
522
664
  // Cache-aware factory: the caller owns exactly ONE non-floating "construction"
@@ -580,6 +722,7 @@ Napi::Value MakeGObjectHandle(Napi::Env env, GObject* obj) {
580
722
  inst->rooted = true;
581
723
  inst->toggle_added = false;
582
724
  inst->teardown_queued = false;
725
+ inst->settled = false;
583
726
 
584
727
  Napi::External<GObject> ext =
585
728
  Napi::External<GObject>::New(env, obj, NodeGiInstanceFinalize, inst);
@@ -598,14 +741,26 @@ Napi::Value MakeGObjectHandle(Napi::Env env, GObject* obj) {
598
741
  ext.TypeTag(&kGObjectHandleTag);
599
742
  napi_create_reference(env, ext, 1, &inst->handle_ref); // start STRONG (node-gtk invariant)
600
743
 
601
- g_object_set_qdata(obj, NodeGiWrapperQuark(), inst); // overwrite (resurrection-safe)
602
- g_object_weak_ref(obj, OnGObjectFinalized, inst); // safety net
603
- g_object_add_toggle_ref(obj, NodeGiToggleNotify, nullptr);
604
- inst->toggle_added = true;
605
- // Drop the construction ref → only the toggle ref remains. If nothing else
606
- // holds obj (refcount 2→1) this fires toggle-down synchronously, flipping the
607
- // fresh wrapper to weak; if C holds another ref it stays strong (rooted).
608
- g_object_unref(obj);
744
+ // ARMING is done under the queue lock, so the record is never half-installed as
745
+ // far as another thread is concerned: publishing it in qdata, arming the weak-ref
746
+ // net and claiming `toggle_added` become one step. A toggle-notify raised from
747
+ // INSIDE this block runs on this thread and re-acquires the recursive lock; the
748
+ // notify does napi work only, never JS, so the no-lock-across-dispose invariant
749
+ // still holds. Without it, a foreign-thread g_object_run_dispose landing between
750
+ // g_object_weak_ref and `toggle_added = true` would have its detach overwritten.
751
+ // The unref below cannot reach dispose either — add_toggle_ref took a ref of its
752
+ // own, so the count is >= 2 going in — which is what makes it safe under a lock.
753
+ {
754
+ std::lock_guard<std::recursive_mutex> guard(g_queue_mutex);
755
+ g_object_set_qdata(obj, NodeGiWrapperQuark(), inst); // overwrite (resurrection-safe)
756
+ g_object_weak_ref(obj, OnGObjectFinalized, inst); // safety net
757
+ g_object_add_toggle_ref(obj, NodeGiToggleNotify, nullptr);
758
+ inst->toggle_added = true;
759
+ // Drop the construction ref → only the toggle ref remains. If nothing else
760
+ // holds obj (refcount 2→1) this fires toggle-down synchronously, flipping the
761
+ // fresh wrapper to weak; if C holds another ref it stays strong (rooted).
762
+ g_object_unref(obj);
763
+ }
609
764
  return ext;
610
765
  }
611
766
 
package/system-gi.d.ts CHANGED
@@ -10,6 +10,10 @@ export interface SystemGiOptions {
10
10
  /** `pkg-config`'s `.pc` search directories; defaults to {@link pkgConfigSearchDirs}. */
11
11
  searchDirs?: (env: NodeJS.ProcessEnv) => string[];
12
12
  }
13
+ /** The subdir GI's own install layout puts typelibs in — the marker that names that layout. */
14
+ export const TYPELIB_SUBDIR: string;
15
+ /** Library dirs a typelib dir implies, most specific first — install layout and staged layout, unverified. */
16
+ export function giLibraryDirsForTypelibDir(typelibDir: string): string[];
13
17
  /** Split an OS search-path variable, dropping empty segments. */
14
18
  export function splitSearchPath(value: string | undefined, separator?: string): string[];
15
19
  /** `$PKG_CONFIG_PATH` plus `pkg-config`'s own `pc_path`; memoized, absent pkg-config yields just the env. */
package/system-gi.js CHANGED
@@ -38,6 +38,9 @@
38
38
  // `/opt/homebrew/lib` (Homebrew arm64), `/opt/local/lib` (MacPorts) or a custom
39
39
  // prefix without needing to know that any of them exist.
40
40
  //
41
+ // THAT IS ONE LAYOUT, NOT THE LAYOUT — {@link giLibraryDirsForTypelibDir} holds the
42
+ // second one and the measurement that found it.
43
+ //
41
44
  // PURE FUNCTION of injected host facts (platform / env / fs / pkg-config), like
42
45
  // `hostTarget()` beside it and the CLI's `resolvePrebuildDirName()` — so the
43
46
  // darwin branch is unit-testable from a Linux host and the host values are read
@@ -50,9 +53,10 @@
50
53
  // the child. The CLI cannot IMPORT this module: ADR 0005 Decision 2 forbids a
51
54
  // Tier-1 package taking a dependency edge on `@gjsify/node-gi`. So the port is
52
55
  // pinned by an agreement test — `packages/infra/cli/src/utils/system-gi.spec.ts`
53
- // imports THIS file by relative path and asserts both implementations return
54
- // identical arrays for a table of injected inputs. Change one, change both; the
55
- // eventual lift to a shared package is tracked in `status/open-todos.md`.
56
+ // imports THIS file by relative path and asserts identical arrays for a table of
57
+ // injected inputs. It pins a THIRD copy the same way, `@gjsify/utils/core`'s
58
+ // `system-gi-dirs.ts`. Change one, change all three; the lift that deletes two of
59
+ // them is tracked in `status/open-todos.md`.
56
60
  import { statSync } from 'node:fs';
57
61
  import { createRequire } from 'node:module';
58
62
  // POSIX path semantics, not the HOST's. This function answers a question about
@@ -87,12 +91,60 @@ const PROBED_GI_LIBDIRS = {
87
91
  /**
88
92
  * The subdir GI installs typelibs into — historically `1.0` even for the girepository-2.0 API.
89
93
  *
90
- * Exported so `gi-typelib.ts` shares it rather than keeping a third copy of the
91
- * one string this whole layout rule turns on. Both mirrors export it, so the
92
- * agreement suite still compares two files that differ only in language.
94
+ * Exported because the whole layout rule turns on this one string and all three
95
+ * copies of the rule must turn on the SAME one; the agreement suite compares it
96
+ * across them rather than trusting three literals to stay equal.
93
97
  */
94
98
  export const TYPELIB_SUBDIR = 'girepository-1.0';
95
99
 
100
+ /**
101
+ * The library directories a typelib directory implies, most specific first.
102
+ *
103
+ * TWO LAYOUTS put a typelib and the library it names in different places, and a
104
+ * path on its own cannot always tell which one it is looking at:
105
+ *
106
+ * * GI's INSTALL layout — `<libdir>/girepository-1.0/Gtk-4.0.typelib` beside
107
+ * `<libdir>/libgtk-4.1.dylib`. The libdir is the PARENT.
108
+ * * A STAGED layout — the pair sits FLAT in one directory. That is what an
109
+ * ADR 0017 prebuild is (`<pkg>/prebuilds/darwin-x64/WebKit-6.0.typelib` beside
110
+ * `libgjsifywebkit.dylib`) and what `gjsify.ship.bundledTypelibs` stages. The
111
+ * libdir is the DIRECTORY ITSELF.
112
+ *
113
+ * {@link TYPELIB_SUBDIR} as the basename is a POSITIVE signal for the first: a
114
+ * directory GI itself named is GI's own layout, and its parent is the answer.
115
+ * Absent that name the layout is UNKNOWN — a relocated stack's typelib directory
116
+ * carries no required name — so both readings are offered and `existsDir` keeps
117
+ * whichever is real.
118
+ *
119
+ * OFFERING BOTH IS CHEAP AND ORDER IS NOT. The caller prepends the survivors to a
120
+ * loader search path, so a directory that names nothing costs one `stat` per miss —
121
+ * but whichever comes FIRST is where a bare leaf resolves from, and only the staged
122
+ * reading can hold a library at all. Hence staged-then-parent, and hence the marker
123
+ * DECIDING for the install layout rather than a probe offering both everywhere:
124
+ * `<libdir>/girepository-1.0/` is the one directory guaranteed to hold no library,
125
+ * and the canonical case must gain no noise.
126
+ *
127
+ * MEASURED, and the parent-only derivation is what it cost. macOS 15.7.9 / Node
128
+ * 24.18.1 / `@gjsify/webkit-native` 0.45.0, with
129
+ * `GI_TYPELIB_PATH=<…>/webkit-native-darwin-x64/prebuilds/darwin-x64` and nothing
130
+ * else: GI found the typelib and never its backer.
131
+ *
132
+ * GLib-GIRepository-WARNING: Failed to load shared library
133
+ * 'libgjsifywebkit.dylib' referenced by the typelib
134
+ * TypeError: WebKit.WebView is not a constructible GObject type
135
+ *
136
+ * dyld's own trace named `…/webkit-native-darwin-x64/prebuilds/libgjsifywebkit.dylib`
137
+ * — the PARENT of the directory the dylib is in, which is this function's previous
138
+ * answer written out. On darwin that is not a degraded search but a dead one:
139
+ * `@gjsify/webkit-native` is the only WebKit any darwin host has (ADR 0022).
140
+ * @param {string} typelibDir one entry of a `GI_TYPELIB_PATH`
141
+ * @returns {string[]} candidate library directories, unverified
142
+ */
143
+ export function giLibraryDirsForTypelibDir(typelibDir) {
144
+ const parent = dirname(typelibDir);
145
+ return basename(typelibDir) === TYPELIB_SUBDIR ? [parent] : [typelibDir, parent];
146
+ }
147
+
96
148
  /** Memoized `pkg-config` answer: the spawn happens at most once per process. */
97
149
  let pkgConfigDirsCache = null;
98
150
 
@@ -156,8 +208,9 @@ export function pkgConfigSearchDirs(env) {
156
208
  *
157
209
  * Three sources, in precedence order:
158
210
  * 1. `GI_TYPELIB_PATH` — the host stating outright where typelibs live; the
159
- * libraries are the sibling `dirname()`. Accepted on directory existence
160
- * alone: an explicit statement is not second-guessed.
211
+ * libraries are wherever {@link giLibraryDirsForTypelibDir} says the layout
212
+ * puts them. Accepted on directory existence alone: an explicit statement is
213
+ * not second-guessed.
161
214
  * 2. `pkg-config`'s `.pc` search path → each `<dir>/pkgconfig`'s parent. The
162
215
  * general mechanism (any prefix, including a custom one).
163
216
  * 3. {@link PROBED_GI_LIBDIRS} — the standard macOS prefixes, for the common
@@ -201,10 +254,11 @@ export function systemGiLibraryDirs({
201
254
  if (dir && dir !== '/' && !out.includes(dir)) out.push(dir);
202
255
  };
203
256
 
204
- // 1. Explicit: the typelib dir's sibling libdir.
257
+ // 1. Explicit: the libdirs each typelib dir implies, both layouts.
205
258
  for (const typelibDir of splitSearchPath(env.GI_TYPELIB_PATH)) {
206
- const libDir = dirname(typelibDir);
207
- if (existsDir(libDir)) add(libDir);
259
+ for (const libDir of giLibraryDirsForTypelibDir(typelibDir)) {
260
+ if (existsDir(libDir)) add(libDir);
261
+ }
208
262
  }
209
263
 
210
264
  // 2 + 3. Candidate prefixes, believed only on the girepository-1.0 marker.