@gjsify/node-gi 0.13.0 → 0.20.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/toggle.cc ADDED
@@ -0,0 +1,718 @@
1
+ // SPDX-License-Identifier: MIT
2
+ // Toggle-ref instance GC bridge (canonical GObject wrappers) + the TEST-ONLY cross-thread GC stress hooks.
3
+
4
+ #include "common.h"
5
+
6
+ namespace nodegi {
7
+
8
+ // ====================================================================
9
+ // ---- toggle-ref instance GC bridge ---------------------------------
10
+ // ====================================================================
11
+ //
12
+ // Derivation: refs/node-gtk src/gobject.cc (romgrk et al., MIT, the #446/#439
13
+ // ownership fixes) + refs/gjs gi/{object.cpp,toggle.{h,cpp}} (the thread-safe
14
+ // ToggleQueue gold standard), ported to N-API. See scratchpad design doc.
15
+ //
16
+ // One canonical, type-tagged External per live GObject, cached on the GObject as
17
+ // qdata. The binding owns a SINGLE g_object_add_toggle_ref; the toggle-notify
18
+ // flips the cached External's napi_ref between STRONG (C also holds the object →
19
+ // rooted, survives GC) and WEAK (JS is the sole owner → collectable). This gives:
20
+ // (a) wrapper IDENTITY — same GObject ⇒ same External ⇒ `===` + Map-key stable;
21
+ // (b) collectability of reference cycles;
22
+ // (c) resurrection — a GObject handed back from C re-wraps to a fresh wrapper
23
+ // after its old one was collected, without clobbering a pending teardown.
24
+ //
25
+ // Crash-mode discipline (the three the design calls out):
26
+ // 1. N-API finalizers run OUTSIDE GC, but g_object_remove_toggle_ref can drive
27
+ // dispose → synchronous signal emission → re-entrant JS, which is unsafe
28
+ // from a finalizer / non-main thread. So the finalizer ONLY schedules a
29
+ // g_idle_add teardown; ALL GObject teardown runs from that main-loop idle.
30
+ // 2. Toggle-up on a maybe-collected wrapper probes napi_get_reference_value;
31
+ // empty ⇒ skip (a later WrapGObject resurrects). No during-GC flag dance is
32
+ // needed — N-API finalizers are post-GC.
33
+ // 3. Cross-thread toggles + double-free: off-thread toggles are marshalled to
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.
38
+
39
+ static GQuark NodeGiWrapperQuark() {
40
+ static GQuark q = g_quark_from_static_string("node-gi::wrapper");
41
+ return q;
42
+ }
43
+
44
+ struct NodeGiInstance {
45
+ napi_env env;
46
+ GObject* gobject; // nulled by the weak-ref safety net if C finalizes it
47
+ napi_ref handle_ref; // ref to the canonical External; strong=rooted, weak=not
48
+ 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)
51
+ };
52
+
53
+ // The single N-API env that OWNS the toggle machinery (qdata cache + global drain
54
+ // queues + drain async). Claimed by the FIRST env that wraps a GObject. A second
55
+ // env (a worker_threads Worker on another thread) must NOT touch this env's
56
+ // napi_refs (cross-env = UAF) nor its qdata cache / queues, so its wraps take the
57
+ // plain strong-ref path (no identity / GC-bridge, but safe). Atomic: read lock-free
58
+ // in MakeGObjectHandle, claimed once via compare_exchange there.
59
+ std::atomic<napi_env> g_owner_env{nullptr};
60
+
61
+ // JS/main thread id of the owner env (captured at first wrap in EnsureDrainAsync).
62
+ // napi_reference_ref/unref are only valid there; off-thread toggles are queued +
63
+ // drained on the main context.
64
+ static std::thread::id g_main_thread_id;
65
+ static bool g_main_thread_id_set = false;
66
+ static bool OnMainThread() {
67
+ return g_main_thread_id_set && std::this_thread::get_id() == g_main_thread_id;
68
+ }
69
+
70
+ // Set at env cleanup: ToggleNotify early-returns so no toggle touches a
71
+ // torn-down env (GJS's gjs_object_shutdown_toggle_queue equivalent).
72
+ std::atomic<bool> g_toggle_shutdown{false};
73
+
74
+ // ---- env-teardown safety helpers --------------------------------------------
75
+
76
+ // See common.h. The probe is the load-bearing gate for the RunCleanup race: the
77
+ // drain TSFN can legally be dispatched by Environment::RunCleanup()'s FIRST
78
+ // CleanupHandles() (env.cc — it runs uv_run BEFORE cleanup_queue_.Drain(), i.e.
79
+ // BEFORE OnEnvShutdown flips g_toggle_shutdown), at a point where FreeEnvironment
80
+ // has already set can_call_into_js=false. The shutdown flag alone can therefore
81
+ // never close that window — only an env-liveness probe at dispatch time can.
82
+ bool NodeGiJsAvailable(napi_env env) {
83
+ if (env == nullptr) return false;
84
+ napi_value undef = nullptr;
85
+ if (napi_get_undefined(env, &undef) != napi_ok) return false;
86
+ bool eq = false;
87
+ return napi_strict_equals(env, undef, undef, &eq) == napi_ok;
88
+ }
89
+
90
+ bool NodeGiToggleDebugEnabled() {
91
+ static const bool enabled = [] {
92
+ const char* v = g_getenv("NODE_GI_TOGGLE_DEBUG");
93
+ return v != nullptr && *v != '\0' && g_strcmp0(v, "0") != 0;
94
+ }();
95
+ return enabled;
96
+ }
97
+
98
+ void NodeGiToggleDebugLog(const char* fmt, ...) {
99
+ if (!NodeGiToggleDebugEnabled()) return;
100
+ va_list args;
101
+ va_start(args, fmt);
102
+ gchar* msg = g_strdup_vprintf(fmt, args);
103
+ va_end(args);
104
+ g_printerr("(node-gi:toggle) [thread %p] %s\n", static_cast<void*>(g_thread_self()), msg);
105
+ g_free(msg);
106
+ }
107
+
108
+ // TEST-ONLY latency seam (env NODE_GI_TOGGLE_TEARDOWN_DELAY_MS, parsed once,
109
+ // clamped to 10s, zero cost when unset): the drain does not process a queued
110
+ // teardown younger than this, re-waking itself instead. That deterministically
111
+ // reproduces the shutdown race the probe above fixes — a teardown queued within
112
+ // the window before loop exit stays queued WITH a pending TSFN wake, so
113
+ // RunCleanup's CleanupHandles() dispatches the drain against the dying env.
114
+ // Regression tool for test/gc-cross-thread.test.mjs; never set in production.
115
+ static int TeardownDelayMs() {
116
+ static const int ms = [] {
117
+ const char* v = g_getenv("NODE_GI_TOGGLE_TEARDOWN_DELAY_MS");
118
+ if (v == nullptr || *v == '\0') return 0;
119
+ long n = strtol(v, nullptr, 10);
120
+ if (n < 0) n = 0;
121
+ if (n > 10000) n = 10000;
122
+ return static_cast<int>(n);
123
+ }();
124
+ return ms;
125
+ }
126
+
127
+ // Deferred-work queues, drained on the JS thread by a Node-API threadsafe function.
128
+ //
129
+ // Why a threadsafe function, not g_idle_add: a GLib idle only runs while the GLib
130
+ // default context is iterated, which in pure Node/Bun/Deno usage (a script that
131
+ // never runs a GLib loop) NEVER happens → idle teardowns pile up and the GObjects
132
+ // leak. napi_call_threadsafe_function schedules a drain on the JS event loop,
133
+ // which turns in ALL modes: plain Node/Bun/Deno (the runtime loop runs on its
134
+ // own) and, on Node, a blocking GLib loop (the uv_source bridge below pumps
135
+ // uv_run(NOWAIT), and Node implements the TSFN over a uv_async, so that pump
136
+ // dispatches the drain too). The TSFN is napi_unref'd so it never keeps the
137
+ // process alive on its own.
138
+ //
139
+ // Why NOT node-gtk's raw uv_async_t: uv_async_init / uv_async_send / uv_unref /
140
+ // uv_close are libuv-internal — Deno exports no libuv symbols (the addon dies with
141
+ // "undefined symbol: uv_unref") and Bun panics on uv_async_init (oven-sh/bun#18546).
142
+ // The threadsafe function is core Node-API, implemented by all three runtimes, so
143
+ // the GC bridge — the FIRST thing every GObject creation arms — is portable.
144
+ //
145
+ // Two queues share one mutex: TOGGLES (off-thread toggle-notifies marshalled to
146
+ // the JS thread — the rare GIO/GStreamer-worker path) and TEARDOWNS (wrappers
147
+ // whose External was finalized; the finalizer must not touch GObject directly).
148
+ struct ToggleItem {
149
+ NodeGiInstance* inst;
150
+ bool down; // true = toggle-down (→ weak), false = toggle-up (→ strong)
151
+ };
152
+ // RECURSIVE (defensive, mirrors GJS's recursive ToggleQueue lock). The lock is held
153
+ // only across SHORT critical sections — never across RunTeardown's dispose → JS (the
154
+ // drain pops one item under the lock, RELEASES it, then processes; see DrainTsfnCb).
155
+ // So a reentrant SettleCollectedInstance / NodeGiToggleNotify fired from a dispose
156
+ // re-acquires the lock as a FRESH (non-nested) acquire today. Keeping it recursive
157
+ // guarantees that even if a future change widens a critical section a same-thread
158
+ // re-acquire can never self-deadlock; it does not weaken the no-lock-across-dispose
159
+ // invariant the liveness test enforces.
160
+ std::recursive_mutex g_queue_mutex;
161
+ static std::deque<ToggleItem> g_toggle_queue;
162
+ // Teardowns carry their enqueue time so the TEST-ONLY latency seam
163
+ // (NODE_GI_TOGGLE_TEARDOWN_DELAY_MS, see TeardownDelayMs) can defer young ones.
164
+ struct TeardownItem {
165
+ NodeGiInstance* inst;
166
+ gint64 enqueued_us; // g_get_monotonic_time() at enqueue
167
+ };
168
+ static std::deque<TeardownItem> g_teardown_queue;
169
+ napi_threadsafe_function g_drain_tsfn = nullptr;
170
+ bool g_drain_async_inited = false;
171
+ static napi_env g_async_env = nullptr; // captured for the drain callback's shutdown checks
172
+
173
+ static void DrainTsfnCb(napi_env env, napi_value js_callback, void* context, void* data);
174
+ static void NodeGiToggleNotify(gpointer, GObject*, gboolean);
175
+ static void OnGObjectFinalized(gpointer, GObject*);
176
+
177
+ // Lazily init the drain threadsafe function on the JS thread (the only legal
178
+ // thread for napi_create_threadsafe_function). Called only by the OWNER env's
179
+ // MakeGObjectHandle (the multi-env gate runs first), so it runs serially on one
180
+ // thread — hence before any object exists, and before any toggle or teardown can
181
+ // be queued. The flag + g_drain_tsfn are read off-thread (WakeDrain), so they are
182
+ // written under g_queue_mutex.
183
+ static void EnsureDrainAsync(napi_env env) {
184
+ {
185
+ std::lock_guard<std::recursive_mutex> guard(g_queue_mutex);
186
+ if (g_drain_async_inited) return;
187
+ }
188
+ // A threadsafe function delivers the wake onto the JS thread. max_queue_size 1
189
+ // coalesces bursts (a second wake while one is pending returns napi_queue_full,
190
+ // which WakeDrain ignores — the pending drain empties the whole item queue
191
+ // anyway); initial_thread_count 1 keeps it alive for the env lifetime so any
192
+ // thread may call it without acquiring. js_func is null — DrainTsfnCb IS the
193
+ // callback, and the TSFN infra invokes it inside a handle + callback scope, so
194
+ // the JS re-entry during dispose (signal emission via napi_make_callback) runs
195
+ // as a proper N-API callback with no manually-managed napi_async_context.
196
+ napi_value name = nullptr;
197
+ napi_create_string_utf8(env, "node-gi:toggle-drain", NAPI_AUTO_LENGTH, &name);
198
+ napi_threadsafe_function tsfn = nullptr;
199
+ napi_status st = napi_create_threadsafe_function(
200
+ env, nullptr, nullptr, name, /*max_queue_size*/ 1, /*initial_thread_count*/ 1,
201
+ nullptr, nullptr, nullptr, DrainTsfnCb, &tsfn);
202
+ if (st != napi_ok || tsfn == nullptr) return;
203
+ // Don't keep the event loop alive just because the drain machinery exists
204
+ // (the uv_unref equivalent). Best-effort — harmless if a runtime no-ops it.
205
+ napi_unref_threadsafe_function(env, tsfn);
206
+ {
207
+ std::lock_guard<std::recursive_mutex> guard(g_queue_mutex);
208
+ g_async_env = env;
209
+ g_drain_tsfn = tsfn;
210
+ // This env owns the machinery; its thread is the JS/main thread for toggles.
211
+ g_main_thread_id = std::this_thread::get_id();
212
+ g_main_thread_id_set = true;
213
+ g_drain_async_inited = true;
214
+ }
215
+ if (NodeGiToggleDebugEnabled())
216
+ NodeGiToggleDebugLog("owner env %p claimed toggle machinery; drain TSFN %p created",
217
+ static_cast<void*>(static_cast<napi_env>(env)),
218
+ static_cast<void*>(tsfn));
219
+ }
220
+
221
+ // Wake the JS-thread drain. Holds g_queue_mutex and re-checks both the init flag
222
+ // and the shutdown flag immediately before the call, paired with OnEnvShutdown's
223
+ // locked flag-flip-before-release — so an off-thread toggle can never call a TSFN
224
+ // that is being / has been released (the shutdown TOCTOU that aborted libuv).
225
+ static void WakeDrain() {
226
+ std::lock_guard<std::recursive_mutex> guard(g_queue_mutex);
227
+ if (g_drain_async_inited && g_drain_tsfn != nullptr && !g_toggle_shutdown.load()) {
228
+ // nonblocking + max_queue_size 1 ⇒ coalesces; napi_queue_full is the
229
+ // "a drain is already pending" no-op (it will pick up this item too).
230
+ napi_call_threadsafe_function(g_drain_tsfn, nullptr, napi_tsfn_nonblocking);
231
+ } else if (NodeGiToggleDebugEnabled()) {
232
+ NodeGiToggleDebugLog("wake skipped: inited=%d tsfn=%p shutdown=%d",
233
+ g_drain_async_inited ? 1 : 0, static_cast<void*>(g_drain_tsfn),
234
+ g_toggle_shutdown.load() ? 1 : 0);
235
+ }
236
+ }
237
+
238
+ // Apply a toggle on the JS thread: flip the canonical napi_ref strong↔weak.
239
+ // Only flips on a real state transition (mirrors node-gtk's dying flag) and
240
+ // probes liveness before touching a possibly-collected handle (crash mode 2).
241
+ static void ApplyToggle(NodeGiInstance* inst, bool down) {
242
+ if (down) {
243
+ if (!inst->rooted) return; // already weak
244
+ napi_value v = nullptr;
245
+ if (napi_get_reference_value(inst->env, inst->handle_ref, &v) == napi_ok && v != nullptr) {
246
+ uint32_t r = 0;
247
+ napi_reference_unref(inst->env, inst->handle_ref, &r); // strong → weak
248
+ }
249
+ inst->rooted = false;
250
+ } else {
251
+ if (inst->rooted) return; // already strong
252
+ napi_value v = nullptr;
253
+ if (napi_get_reference_value(inst->env, inst->handle_ref, &v) == napi_ok && v != nullptr) {
254
+ uint32_t r = 0;
255
+ napi_reference_ref(inst->env, inst->handle_ref, &r); // weak → strong
256
+ inst->rooted = true;
257
+ }
258
+ // empty ⇒ the wrapper was already collected; a later WrapGObject resurrects.
259
+ }
260
+ }
261
+
262
+ // Run one wrapper's teardown on the JS thread: drop the toggle ref (may dispose →
263
+ // emit → re-enter JS — legal here, not in a finalizer/off-thread), resurrection-
264
+ // safely, then free the wrapper.
265
+ //
266
+ // ORDER MATTERS (node-gtk's GObjectTeardownIdle): remove_toggle_ref is LAST,
267
+ // because dropping the last ref can take refcount to 0 → dispose → finalize → the
268
+ // 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).
278
+ static void RunTeardown(NodeGiInstance* inst) {
279
+ GObject* obj = inst->gobject; // null if the weak-ref net already fired
280
+ {
281
+ 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;
287
+ }
288
+ }
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
+ }
293
+ if (inst->handle_ref != nullptr) napi_delete_reference(inst->env, inst->handle_ref);
294
+ delete inst;
295
+ }
296
+
297
+ // Drain both queues on the JS/main thread. Pop ONE item from the LIVE queue UNDER
298
+ // the lock, RELEASE the lock, then process it WITHOUT the lock; loop until empty.
299
+ //
300
+ // The lock must NOT be held across RunTeardown: it calls g_object_remove_toggle_ref
301
+ // → dispose → arbitrary JS (signal/vfunc). Holding a lock across reentrant user code
302
+ // (a) STALLS an off-thread NodeGiToggleNotify (which blocks acquiring g_queue_mutex)
303
+ // for the whole dispose — a priority inversion, not a "brief" block; and (b) risks an
304
+ // ABBA deadlock if a dispose vfunc synchronously waits on a worker thread that is
305
+ // itself blocked on g_queue_mutex. GJS likewise holds its ToggleQueue lock only
306
+ // across the rooting flip (== ApplyToggle), NEVER across remove_toggle_ref + dispose.
307
+ //
308
+ // The DEFECT-2 fix is preserved: the current item is removed from the LIVE queue
309
+ // UNDER the lock BEFORE release, so it is never double-processed; and a reentrant
310
+ // SettleCollectedInstance (same drain thread, fired from a dispose) re-acquires the
311
+ // lock and still sees + cancels the OTHER pending teardowns left in the live queue
312
+ // (a swap-then-process snapshot would hide them → double-free). Toggles drain first
313
+ // (FIFO); a toggle enqueued during a teardown's dispose is picked up on the next
314
+ // iteration. Terminates when both queues are empty.
315
+ static void DrainTsfnCb(napi_env raw_env, napi_value /*js_callback*/, void* /*context*/,
316
+ void* /*data*/) {
317
+ if (g_async_env == nullptr || g_toggle_shutdown.load()) return;
318
+ // ENV-TEARDOWN GATE (the RunCleanup race): Node legally dispatches a pending
319
+ // TSFN wake from Environment::RunCleanup()'s FIRST CleanupHandles() uv_run —
320
+ // AFTER FreeEnvironment/ExitEnv set can_call_into_js=false, but BEFORE the env
321
+ // cleanup hooks (OnEnvShutdown) flip g_toggle_shutdown. Processing a teardown
322
+ // then drops the last toggle ref -> dispose -> a JS vfunc_dispose / signal
323
+ // closure re-enters N-API on the dead env -> node-addon-api's noexcept throw
324
+ // path -> napi_fatal_error abort. Skip instead: queued items stay put and are
325
+ // dropped by the shutdown flag moments later (the documented leak-at-exit).
326
+ const bool jsAvailable = NodeGiJsAvailable(raw_env);
327
+ if (!jsAvailable) {
328
+ if (NodeGiToggleDebugEnabled()) {
329
+ std::lock_guard<std::recursive_mutex> guard(g_queue_mutex);
330
+ NodeGiToggleDebugLog("drain skipped: JS unavailable on env %p (teardown/terminate); "
331
+ "toggles=%zu teardowns=%zu left queued",
332
+ static_cast<void*>(raw_env), g_toggle_queue.size(),
333
+ g_teardown_queue.size());
334
+ }
335
+ return;
336
+ }
337
+ Napi::Env env(raw_env);
338
+ // The TSFN infra already invokes us on the JS thread inside a callback scope
339
+ // (its async context), so signal emission during dispose runs as a proper
340
+ // N-API callback with no manually-managed CallbackScope. A HandleScope is still
341
+ // opened defensively: ApplyToggle's napi_get_reference_value and the JS re-entry
342
+ // during teardown both create V8 handles.
343
+ Napi::HandleScope handleScope(env);
344
+
345
+ while (true) {
346
+ if (g_toggle_shutdown.load()) return;
347
+ ToggleItem toggle{nullptr, false};
348
+ NodeGiInstance* teardown = nullptr;
349
+ bool deferred = false;
350
+ {
351
+ std::lock_guard<std::recursive_mutex> guard(g_queue_mutex);
352
+ if (g_toggle_shutdown.load()) return;
353
+ if (!g_toggle_queue.empty()) { // toggles first (FIFO)
354
+ toggle = g_toggle_queue.front();
355
+ g_toggle_queue.pop_front();
356
+ } else if (!g_teardown_queue.empty()) {
357
+ const TeardownItem& front = g_teardown_queue.front();
358
+ const int delayMs = TeardownDelayMs();
359
+ // Latency seam: defer young teardowns ONLY while the env can still run
360
+ // JS. On a dying env there is no "later" — a deferral would just carry
361
+ // the teardown past OnEnvShutdown (suppressing, not widening, the race
362
+ // this seam exists to reproduce); the gate above normally returns first,
363
+ // so jsAvailable is only ever false here in a gate-disabled experiment.
364
+ if (delayMs > 0 && jsAvailable &&
365
+ g_get_monotonic_time() - front.enqueued_us < static_cast<gint64>(delayMs) * 1000) {
366
+ deferred = true; // latency seam: too young — re-wake and retry later
367
+ } else {
368
+ teardown = front.inst;
369
+ g_teardown_queue.pop_front(); // removed from the LIVE queue under the lock
370
+ }
371
+ } else {
372
+ return; // both queues drained
373
+ }
374
+ } // lock RELEASED before any processing — never held across dispose/JS
375
+ if (deferred) {
376
+ if (NodeGiToggleDebugEnabled())
377
+ NodeGiToggleDebugLog("teardown deferred by latency seam (%d ms)", TeardownDelayMs());
378
+ WakeDrain(); // outside the lock; respects the shutdown flag
379
+ return;
380
+ }
381
+ if (toggle.inst != nullptr) {
382
+ ApplyToggle(toggle.inst, toggle.down); // napi-only, main-thread state, no reentry
383
+ } else {
384
+ if (NodeGiToggleDebugEnabled())
385
+ NodeGiToggleDebugLog("drain: run teardown inst %p (env %p)",
386
+ static_cast<void*>(teardown), static_cast<void*>(teardown->env));
387
+ RunTeardown(teardown); // dispose → JS, NO lock held
388
+ }
389
+ }
390
+ }
391
+
392
+ // Queue helpers — caller MUST hold g_queue_mutex. Mirror GJS ToggleQueue::is_queued
393
+ // + ToggleQueue::enqueue (refs/gjs gi/toggle.cpp): a main-thread toggle may apply
394
+ // directly ONLY when nothing is queued for the inst; otherwise it is enqueued, and
395
+ // an OPPOSITE-direction queued toggle CANCELS with the new one (both removed). That
396
+ // cancellation is what fixes the cross-thread wrong-flip / lost-toggle bug — a
397
+ // stale queued flip can never be applied after its inverse already happened.
398
+ static bool IsQueuedLocked(NodeGiInstance* inst) {
399
+ for (const ToggleItem& item : g_toggle_queue)
400
+ if (item.inst == inst) return true;
401
+ return false;
402
+ }
403
+
404
+ // Returns true iff a toggle was actually left on the queue (→ caller wakes drain).
405
+ static bool EnqueueToggleLocked(NodeGiInstance* inst, bool down) {
406
+ for (auto it = g_toggle_queue.begin(); it != g_toggle_queue.end(); ++it) {
407
+ if (it->inst != inst) continue;
408
+ if (it->down != down) {
409
+ g_toggle_queue.erase(it); // opposite direction queued → the two cancel
410
+ return false;
411
+ }
412
+ return false; // same direction already queued → dedupe (ApplyToggle is idempotent)
413
+ }
414
+ g_toggle_queue.push_back({inst, down});
415
+ return true;
416
+ }
417
+
418
+ // The single toggle-notify for every node-gi GObject. Registered with data=NULL
419
+ // (fungible — node-gtk pattern): the live wrapper is found via qdata, so a stale
420
+ // teardown can remove ANY one toggle ref without orphaning a resurrected wrapper.
421
+ //
422
+ // ALL toggles route through the queue logic (GJS wrapped_gobj_toggle_notify): we
423
+ // apply directly ONLY on the main thread when NOTHING is queued for this inst;
424
+ // otherwise we enqueue (where opposite-direction toggles cancel). The qdata lookup
425
+ // + decision + direct-apply/enqueue are all done UNDER g_queue_mutex (GJS holds its
426
+ // ToggleQueue lock across the whole notify, including the direct apply) so an
427
+ // off-thread toggle cannot interleave between the is-queued check and the apply, and
428
+ // RunTeardown — which clears qdata under the SAME lock — can never be acted on after
429
+ // it has freed an inst.
430
+ static void NodeGiToggleNotify(gpointer /*data*/, GObject* obj, gboolean is_last_ref) {
431
+ if (g_toggle_shutdown.load()) {
432
+ // Post-shutdown toggles are dropped (GJS's enqueue-after-shutdown no-op). The
433
+ // per-toggle debug log stays OUT of the hot churn path — dropped toggles at
434
+ // teardown are the only interesting event and are rare.
435
+ if (NodeGiToggleDebugEnabled())
436
+ NodeGiToggleDebugLog("toggle %s DROPPED after shutdown (obj %p)",
437
+ is_last_ref != FALSE ? "DOWN" : "UP", static_cast<void*>(obj));
438
+ return;
439
+ }
440
+ bool down = is_last_ref != FALSE;
441
+ bool main_thread = OnMainThread();
442
+ bool enqueued = false;
443
+ {
444
+ std::lock_guard<std::recursive_mutex> guard(g_queue_mutex);
445
+ if (g_toggle_shutdown.load()) return; // re-check under the lock
446
+ NodeGiInstance* inst =
447
+ static_cast<NodeGiInstance*>(g_object_get_qdata(obj, NodeGiWrapperQuark()));
448
+ if (inst == nullptr) return;
449
+ if (main_thread && !IsQueuedLocked(inst)) {
450
+ ApplyToggle(inst, down); // direct apply, under the lock (as GJS does)
451
+ } else {
452
+ enqueued = EnqueueToggleLocked(inst, down);
453
+ }
454
+ }
455
+ if (enqueued) WakeDrain(); // outside the lock
456
+ }
457
+
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*/) {
462
+ NodeGiInstance* inst = static_cast<NodeGiInstance*>(data);
463
+ inst->gobject = nullptr;
464
+ }
465
+
466
+ // The canonical External's finalizer (napi_finalize, runs at a safe point post-GC
467
+ // but where re-entering GObject teardown is still unsafe). Do the MINIMUM: queue
468
+ // the teardown + wake the drain async (crash mode 1).
469
+ static void NodeGiInstanceFinalize(Napi::Env /*env*/, GObject* /*data*/, NodeGiInstance* inst) {
470
+ if (inst == nullptr || inst->teardown_queued) return;
471
+ inst->teardown_queued = true;
472
+ {
473
+ std::lock_guard<std::recursive_mutex> guard(g_queue_mutex);
474
+ // After shutdown nothing will drain, so don't grow the queue (the env is going
475
+ // away; the inst leaks with it — same as a dropped pending teardown).
476
+ if (g_toggle_shutdown.load()) {
477
+ if (NodeGiToggleDebugEnabled())
478
+ NodeGiToggleDebugLog("finalizer: teardown DROPPED after shutdown (inst %p env %p)",
479
+ static_cast<void*>(inst), static_cast<void*>(inst->env));
480
+ return;
481
+ }
482
+ g_teardown_queue.push_back({inst, g_get_monotonic_time()});
483
+ if (NodeGiToggleDebugEnabled())
484
+ NodeGiToggleDebugLog("finalizer: teardown queued (inst %p env %p depth %zu)",
485
+ static_cast<void*>(inst), static_cast<void*>(inst->env),
486
+ g_teardown_queue.size());
487
+ }
488
+ WakeDrain();
489
+ }
490
+
491
+ // Synchronously dispose a COLLECTED wrapper's binding state so a fresh wrapper can
492
+ // be built for the same GObject without ever installing a SECOND toggle ref on it.
493
+ // GLib suppresses toggle-notify while >=2 toggle refs exist, so a refcount change in
494
+ // the two-toggle-ref window would be LOST → the wrapper pinned/leaked (GJS keeps ONE
495
+ // toggle ref per GObject). The caller holds a construction ref on obj, so dropping
496
+ // the old toggle ref here cannot drive refcount to 0 / dispose. Main-thread only (==
497
+ // the drain thread), so the pending idle teardown cannot be running concurrently.
498
+ static void SettleCollectedInstance(GObject* obj, NodeGiInstance* old) {
499
+ {
500
+ std::lock_guard<std::recursive_mutex> guard(g_queue_mutex);
501
+ // Cancel old's pending teardown + any queued toggles that reference it, so the
502
+ // 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).
504
+ for (auto it = g_teardown_queue.begin(); it != g_teardown_queue.end();)
505
+ 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);
511
+ }
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);
517
+ }
518
+ if (old->handle_ref != nullptr) napi_delete_reference(old->env, old->handle_ref);
519
+ delete old;
520
+ }
521
+
522
+ // Cache-aware factory: the caller owns exactly ONE non-floating "construction"
523
+ // ref on obj. Returns the canonical External, establishing the toggle ref on a
524
+ // cache miss / resurrecting on a collected hit, or adopting + balancing the ref
525
+ // on a live hit.
526
+ Napi::Value MakeGObjectHandle(Napi::Env env, GObject* obj) {
527
+ // Multi-env (worker_threads) safety: claim / honour the toggle-machinery owner.
528
+ // The FIRST env to wrap a GObject wins (compare_exchange under a concurrent race);
529
+ // every later env that is NOT the owner takes the plain strong-ref path.
530
+ napi_env owner = g_owner_env.load();
531
+ if (owner == nullptr) {
532
+ napi_env expected = nullptr;
533
+ owner = g_owner_env.compare_exchange_strong(expected, static_cast<napi_env>(env))
534
+ ? static_cast<napi_env>(env)
535
+ : expected;
536
+ }
537
+ if (owner != static_cast<napi_env>(env)) {
538
+ // A non-owner env must not touch the owner's napi_refs / qdata cache / queues
539
+ // (cross-env napi = UAF). Give it a plain strong-ref handle: it adopts the
540
+ // single construction ref and drops it in its OWN finalizer (correct
541
+ // refcounting; any toggle the ref churn triggers is handled by the OWNER on the
542
+ // owner thread). Trade-off: no wrapper identity / GC-bridge for worker envs — a
543
+ // documented limitation, but no cross-env UAF.
544
+ Napi::External<GObject> plain = Napi::External<GObject>::New(
545
+ env, obj, [](Napi::Env, GObject* p) { g_object_unref(p); });
546
+ if (plain.IsEmpty()) {
547
+ // napi_create_external failed and the throw was swallowed (the env can no
548
+ // longer run JS — worker.terminate() mid-call — or an exception is
549
+ // pending). Chaining TypeTag onto the empty External would funnel into
550
+ // Error::New(nullptr)'s NAPI_FATAL_IF_FAILED sites and abort. The
551
+ // finalizer was never registered, so balance the construction ref here
552
+ // and bail with the empty value (the JS caller never observes it).
553
+ g_object_unref(obj);
554
+ return plain;
555
+ }
556
+ plain.TypeTag(&kGObjectHandleTag);
557
+ return plain;
558
+ }
559
+
560
+ EnsureDrainAsync(env); // wire the JS-thread drain before any toggle/teardown can queue
561
+ NodeGiInstance* existing =
562
+ static_cast<NodeGiInstance*>(g_object_get_qdata(obj, NodeGiWrapperQuark()));
563
+ if (existing != nullptr) {
564
+ napi_value cached = nullptr;
565
+ if (napi_get_reference_value(env, existing->handle_ref, &cached) == napi_ok &&
566
+ cached != nullptr) {
567
+ g_object_unref(obj); // drop the surplus construction ref; toggle ref owns obj
568
+ return Napi::Value(env, cached);
569
+ }
570
+ // Collected hit (wrapper GC'd, idle teardown still PENDING — the drain runs on
571
+ // THIS thread, so it cannot have run concurrently). Settle the dead wrapper
572
+ // synchronously first (drops its toggle ref while we hold the construction ref),
573
+ // then build a fresh wrapper below — never two toggle refs on obj at once.
574
+ SettleCollectedInstance(obj, existing);
575
+ }
576
+
577
+ NodeGiInstance* inst = new NodeGiInstance();
578
+ inst->env = env;
579
+ inst->gobject = obj;
580
+ inst->rooted = true;
581
+ inst->toggle_added = false;
582
+ inst->teardown_queued = false;
583
+
584
+ Napi::External<GObject> ext =
585
+ Napi::External<GObject>::New(env, obj, NodeGiInstanceFinalize, inst);
586
+ if (ext.IsEmpty()) {
587
+ // Same swallowed-failure degradation as the plain path above (the terminate-
588
+ // mid-call class: the hot loop is inside g_object_new when the env dies, and
589
+ // THIS is the next fallible napi call). Nothing was installed yet — no
590
+ // finalizer, no qdata, no weak ref, no toggle ref — so free the record,
591
+ // balance the construction ref, and return the empty value cleanly instead
592
+ // of cascading into TypeTag → Error::New(nullptr) → abort. If the unref
593
+ // disposes the object, the C→JS trampolines are entry-gated and no-op.
594
+ delete inst;
595
+ g_object_unref(obj);
596
+ return ext;
597
+ }
598
+ ext.TypeTag(&kGObjectHandleTag);
599
+ napi_create_reference(env, ext, 1, &inst->handle_ref); // start STRONG (node-gtk invariant)
600
+
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);
609
+ return ext;
610
+ }
611
+
612
+ // Wrap a borrowed/owned GObject pointer as the canonical node-gi handle. The
613
+ // cache lookup happens BEFORE any refcount change so a live hit causes no
614
+ // spurious toggle churn; only the miss/resurrect path takes a construction ref.
615
+ Napi::Value WrapGObject(Napi::Env env, GObject* obj, GITransfer transfer) {
616
+ if (obj == nullptr) return env.Null();
617
+
618
+ NodeGiInstance* inst =
619
+ static_cast<NodeGiInstance*>(g_object_get_qdata(obj, NodeGiWrapperQuark()));
620
+ // Multi-env (worker_threads) safety: the qdata cache is process-global, but
621
+ // inst->handle_ref is a napi_ref rooted in inst->env's isolate. ONLY that env may
622
+ // dereference it — napi_get_reference_value with a FOREIGN env is a cross-env UAF /
623
+ // V8 abort (e.g. a worker that obtains a process-shared singleton like
624
+ // Gio.Vfs.get_default() that the owner env already wrapped). A non-owner env skips
625
+ // the cache and takes the plain strong-ref path via MakeGObjectHandle (which gates
626
+ // the same way). inst->env is immutable, so this guard is race-free regardless of
627
+ // g_owner_env store ordering — MakeGObjectHandle's gate alone was NOT enough,
628
+ // because this cache-hit fast path runs BEFORE MakeGObjectHandle.
629
+ if (inst != nullptr && inst->env == static_cast<napi_env>(env)) {
630
+ napi_value cached = nullptr;
631
+ if (napi_get_reference_value(env, inst->handle_ref, &cached) == napi_ok && cached != nullptr) {
632
+ // Identity hit: return the canonical External, balancing the transfer (we
633
+ // already own obj via the toggle ref, so any ref the caller hands us is
634
+ // surplus and must be dropped).
635
+ if (g_object_is_floating(obj)) {
636
+ g_object_ref_sink(obj);
637
+ g_object_unref(obj);
638
+ } else if (transfer == GI_TRANSFER_EVERYTHING) {
639
+ g_object_unref(obj);
640
+ }
641
+ return Napi::Value(env, cached);
642
+ }
643
+ // collected hit (our env) → fall through and (re)build a fresh wrapper (resurrection).
644
+ }
645
+
646
+ // Miss / resurrect / cross-env share: own exactly one non-floating construction ref.
647
+ if (g_object_is_floating(obj)) {
648
+ g_object_ref_sink(obj);
649
+ } else if (transfer == GI_TRANSFER_NOTHING) {
650
+ g_object_ref(obj);
651
+ } // GI_TRANSFER_EVERYTHING (non-floating): the caller already gave us the ref.
652
+ return MakeGObjectHandle(env, obj);
653
+ }
654
+
655
+ // ============================ TEST-ONLY ===============================
656
+ // __stressRefUnrefOffThread(handle, iterations) — the GLib-thread vehicle the
657
+ // cross-thread GC stress test needs (there is no headless JS way to make another
658
+ // OS thread ref/unref a wrapped GObject — GIO local-file async keeps its refcount
659
+ // ops on the main thread). A background GThread does g_object_ref/g_object_unref on
660
+ // the wrapped GObject `iterations` times, crossing the toggle 1<->2 boundary from a
661
+ // NON-main thread → driving NodeGiToggleNotify's OFF-THREAD branch + the
662
+ // opposite-direction enqueue cancel + WakeDrain + the JS-thread drain. The CALLER
663
+ // MUST keep the handle reachable (and take NO extra ref) for the churn's lifetime:
664
+ // the toggle ref + the JS-reachable wrapper keep the GObject alive at refcount 1
665
+ // between churn pairs, so the worker's 1<->2 crossings are real toggles, never a
666
+ // use-after-free. Poll __stressRefUnrefRunning() for completion. Not part of the
667
+ // public API surface — prefixed __ and only used by test/gc-cross-thread.test.mjs.
668
+ static std::atomic<int> g_stress_running{0};
669
+ // Monotonic count of completed off-thread ref/unref iterations — lets a test prove
670
+ // the off-thread churn makes progress WHILE a main-thread dispose vfunc is running
671
+ // (i.e. the drain lock is NOT held across dispose). If the lock were held across the
672
+ // dispose, the off-thread NodeGiToggleNotify would block on g_queue_mutex and this
673
+ // counter would freeze for the dispose's whole duration.
674
+ static std::atomic<long> g_stress_progress{0};
675
+ // Cooperative stop flag so a test can halt a long-running churn after asserting.
676
+ static std::atomic<bool> g_stress_stop{false};
677
+ struct StressArgs {
678
+ GObject* obj;
679
+ long iterations;
680
+ };
681
+ static gpointer StressThreadFunc(gpointer data) {
682
+ StressArgs* a = static_cast<StressArgs*>(data);
683
+ for (long i = 0; i < a->iterations; i++) {
684
+ if (g_stress_stop.load()) break;
685
+ g_object_ref(a->obj); // 1 -> 2 : toggle UP (off-thread)
686
+ g_object_unref(a->obj); // 2 -> 1 : toggle DOWN (off-thread)
687
+ g_stress_progress.fetch_add(1);
688
+ if ((i & 0x3f) == 0) g_thread_yield(); // give the main thread the lock + loop
689
+ }
690
+ g_stress_running.fetch_sub(1);
691
+ delete a;
692
+ return nullptr;
693
+ }
694
+ Napi::Value StressRefUnrefOffThread(const Napi::CallbackInfo& info) {
695
+ Napi::Env env = info.Env();
696
+ GObject* obj = UnwrapGObject(env, info[0]);
697
+ if (obj == nullptr) return env.Undefined();
698
+ long iters = info.Length() >= 2 ? static_cast<long>(info[1].As<Napi::Number>().Int64Value()) : 10000;
699
+ g_stress_stop.store(false); // a fresh churn is not pre-stopped
700
+ StressArgs* a = new StressArgs{obj, iters};
701
+ g_stress_running.fetch_add(1);
702
+ GThread* t = g_thread_new("node-gi-stress", StressThreadFunc, a);
703
+ g_thread_unref(t); // detached — completion is signalled via g_stress_running
704
+ return env.Undefined();
705
+ }
706
+ Napi::Value StressRefUnrefRunning(const Napi::CallbackInfo& info) {
707
+ return Napi::Boolean::New(info.Env(), g_stress_running.load() > 0);
708
+ }
709
+ Napi::Value StressRefUnrefProgress(const Napi::CallbackInfo& info) {
710
+ return Napi::Number::New(info.Env(), static_cast<double>(g_stress_progress.load()));
711
+ }
712
+ Napi::Value StressRefUnrefStop(const Napi::CallbackInfo& info) {
713
+ g_stress_stop.store(true);
714
+ return info.Env().Undefined();
715
+ }
716
+ // ========================== END TEST-ONLY ============================
717
+
718
+ } // namespace nodegi