@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/README.md +818 -5
- package/binding.gyp +55 -19
- package/cairo.d.ts +187 -0
- package/cairo.js +570 -0
- package/gettext.d.ts +65 -0
- package/gettext.js +128 -0
- package/gi.d.ts +178 -0
- package/gi.js +2018 -23
- package/globals.d.ts +7 -33
- package/globals.js +40 -51
- package/gtk-runtime.d.ts +17 -0
- package/gtk-runtime.js +245 -0
- package/index.d.ts +306 -12
- package/index.js +447 -14
- package/overrides/_signals.js +167 -0
- package/overrides/gio-dbus.js +749 -0
- package/overrides/mainloop.js +60 -0
- package/package.json +45 -4
- package/src/addon.cc +98 -2074
- package/src/cairo.cc +926 -0
- package/src/calls.cc +1425 -0
- package/src/class.cc +1170 -0
- package/src/common.h +624 -0
- package/src/loop.cc +766 -0
- package/src/marshal.cc +1738 -0
- package/src/object.cc +814 -0
- package/src/private.cc +351 -0
- package/src/repo.cc +297 -0
- package/src/signals.cc +535 -0
- package/src/template.cc +116 -0
- package/src/toggle.cc +718 -0
- package/src/variant.cc +587 -0
- package/system.d.ts +49 -0
- package/system.js +116 -0
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
|