@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/README.md +23 -9
- package/gi.js +53 -2
- package/gtk-runtime.js +14 -0
- package/index.d.ts +15 -1
- package/index.js +16 -0
- package/native-prebuilds.js +510 -0
- package/package.json +2 -1
- package/prebuilds/darwin-arm64/node_gi.node +0 -0
- package/prebuilds/darwin-x64/node_gi.node +0 -0
- package/prebuilds/linux-arm64/node_gi.node +0 -0
- package/prebuilds/linux-x64/node_gi.node +0 -0
- package/prebuilds/win32-x64/node_gi.node +0 -0
- package/src/addon.cc +1 -0
- package/src/calls.cc +102 -26
- package/src/class.cc +22 -9
- package/src/common.h +70 -20
- package/src/marshal.cc +561 -28
- package/src/object.cc +26 -0
- package/src/repo.cc +15 -4
- package/src/toggle.cc +208 -53
- package/system-gi.d.ts +4 -0
- package/system-gi.js +65 -11
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
|
|
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
|
|
264
|
-
//
|
|
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)")
|
|
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
|
|
36
|
-
//
|
|
37
|
-
// wrapper
|
|
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; //
|
|
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
|
|
50
|
-
bool teardown_queued; //
|
|
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:
|
|
270
|
-
//
|
|
271
|
-
//
|
|
272
|
-
//
|
|
273
|
-
//
|
|
274
|
-
//
|
|
275
|
-
//
|
|
276
|
-
// (
|
|
277
|
-
//
|
|
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 =
|
|
311
|
+
GObject* obj = nullptr;
|
|
312
|
+
bool remove_toggle = false;
|
|
280
313
|
{
|
|
281
314
|
std::lock_guard<std::recursive_mutex> guard(g_queue_mutex);
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
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
|
|
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
|
|
459
|
-
//
|
|
460
|
-
//
|
|
461
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
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
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
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 (
|
|
519
|
-
|
|
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
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
//
|
|
606
|
-
// holds
|
|
607
|
-
//
|
|
608
|
-
|
|
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
|
|
54
|
-
//
|
|
55
|
-
//
|
|
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
|
|
91
|
-
*
|
|
92
|
-
*
|
|
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
|
|
160
|
-
* alone: an explicit statement is
|
|
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
|
|
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
|
|
207
|
-
|
|
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.
|