@gjsify/napi 0.22.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/cc/tsfn.cc ADDED
@@ -0,0 +1,602 @@
1
+ // SPDX-License-Identifier: MIT
2
+ // @gjsify/napi — threadsafe functions + napi_make_callback (Phase 1).
3
+ //
4
+ // Reference: refs/node/src/node_api.cc v8impl::ThreadSafeFunction (Node.js
5
+ // contributors, MIT) — Push/Release/DispatchOne/CloseHandlesAndMaybeDelete/
6
+ // EmptyQueueAndDelete are the normative semantics mirrored here. Node realizes
7
+ // the cross-thread wake as a uv_async on the event loop; this host realizes it
8
+ // as an idle GSource attached to the GJS main context.
9
+ //
10
+ // THE CRUX — foreign thread → single-threaded JSContext:
11
+ // napi_call_threadsafe_function is documented callable from ANY thread
12
+ // (GIO/GStreamer workers in node-gi's case), but the JSContext is owned by the
13
+ // GJS main thread and must never be entered from another thread. The safe
14
+ // bridge is GSource attachment:
15
+ // - `g_source_attach` is explicitly thread-safe (it takes the GMainContext
16
+ // lock and wakes the context's eventfd), and it runs NO JSAPI — so a
17
+ // foreign thread may schedule a dispatch even while the main thread is
18
+ // mid-GC or mid-dispatch; the source only ever DISPATCHES on the thread
19
+ // iterating the context (the GJS main/JS thread).
20
+ // - `g_main_context_invoke_full` is deliberately NOT used: when the calling
21
+ // thread owns the context it invokes the callback SYNCHRONOUSLY — a
22
+ // same-thread napi_call_threadsafe_function (e.g. from a napi finalizer
23
+ // during the §5c drain) would re-enter JS in place instead of deferring
24
+ // to a loop turn, breaking the tsfn's async contract (and node-gi's
25
+ // "the finalizer only schedules" crash-mode-1 discipline).
26
+ // - the tsfn's own GMutex guards all mutable state; it is NEVER held across
27
+ // call_js (the callback may run arbitrary JS → GObject dispose → foreign-
28
+ // thread toggles that need this same mutex — the node-gi drain-lock
29
+ // lesson, toggle.cc DrainTsfnCb).
30
+ //
31
+ // Lifetime: the struct is freed on the JS thread only, and NEVER while a
32
+ // foreign thread still holds a claim — the dispatch finalizes only once
33
+ // thread_count has reached 0 (a natural close, OR an abort whose remaining
34
+ // claimants have each drained their claim via a claim-consuming napi_closing
35
+ // push/release, every one of which re-arms the dispatch as it drops the last
36
+ // claim). This is the tsfn analog of async_work's join-before-free
37
+ // (g_thread_pool_free wait=TRUE): thread_count == 0 under the mutex means every
38
+ // claimant's last struct access happened-before this dispatch re-acquired the
39
+ // lock, so no foreign thread can touch freed tsfn memory. The alternative — the
40
+ // old "free the instant closing is observed" abort path — freed the struct
41
+ // while claimants were still about to lock the (now cleared) mutex: a
42
+ // use-after-free Linux/glibc tolerates (destroy is a no-op, freed bytes survive)
43
+ // but macOS's pthread + allocator + exit-time dylib unload turn into a crash.
44
+ // The remaining fallback is env teardown (finalize_env_tsfns: a release(abort)
45
+ // issued by an env cleanup hook schedules a dispatch that would never run, the
46
+ // main context stops iterating once GjsContext dispose began). ABI contract, as
47
+ // in Node: a thread must hold a claim (initial_thread_count / napi_acquire)
48
+ // while calling, and must make no call after its release — a claimless call
49
+ // racing the final free is UB in every host.
50
+
51
+ #include "common.h"
52
+
53
+ #include <deque>
54
+
55
+ // ---- the tsfn object ----
56
+ //
57
+ // Global scope: the struct tag is fixed by the vendored
58
+ // `typedef struct napi_threadsafe_function__* napi_threadsafe_function`.
59
+ struct napi_threadsafe_function__ {
60
+ // Immutable after creation (JS thread):
61
+ napi_env env = nullptr;
62
+ GMainContext* main_context = nullptr; // ref held; released in finalize
63
+ void* context = nullptr;
64
+ napi_threadsafe_function_call_js call_js = nullptr; // never null
65
+ napi_ref func_ref = nullptr; // strong ref on the JS function (nullable)
66
+ napi_finalize finalize_cb = nullptr;
67
+ void* finalize_data = nullptr;
68
+ size_t max_queue_size = 0; // 0 = unbounded
69
+
70
+ // Mutable state — guarded by mutex (any thread):
71
+ GMutex mutex;
72
+ GCond cond; // blocking-push space + waiter drain
73
+ std::deque<void*> queue;
74
+ size_t thread_count = 0;
75
+ size_t waiters = 0; // threads inside the blocking-push wait
76
+ // No new pushes accepted: release(abort), the dispatch's natural-close
77
+ // decision, or env teardown. Node's `is_closing`.
78
+ bool closing = false;
79
+ // A dispatch idle is attached and pending (owned ref); the coalescing
80
+ // guard — at most ONE pending wake, exactly uv_async semantics.
81
+ GSource* dispatch_source = nullptr;
82
+ bool referenced = true; // napi_unref bookkeeping (see note below)
83
+
84
+ napi_threadsafe_function__() {
85
+ g_mutex_init(&mutex);
86
+ g_cond_init(&cond);
87
+ }
88
+ ~napi_threadsafe_function__() {
89
+ g_cond_clear(&cond);
90
+ g_mutex_clear(&mutex);
91
+ if (main_context != nullptr) {
92
+ g_main_context_unref(main_context);
93
+ }
94
+ }
95
+ };
96
+
97
+ namespace gjsify_napi {
98
+ namespace {
99
+
100
+ // Node's Dispatch() iteration cap (event-loop starvation guard).
101
+ constexpr unsigned kMaxDispatchIterations = 1000;
102
+
103
+ gboolean dispatch_cb(gpointer data);
104
+
105
+ // Arm the JS-thread dispatch. Caller holds tsfn->mutex. Safe from any thread
106
+ // and from within GC sweeps: pure GLib, no JSAPI (see file header).
107
+ void schedule_dispatch_locked(napi_threadsafe_function tsfn) {
108
+ if (tsfn->dispatch_source != nullptr) {
109
+ return; // a wake is already pending — coalesce
110
+ }
111
+ GSource* src = g_idle_source_new();
112
+ // uv_async parity: process the wake promptly (an async is checked every
113
+ // loop iteration in Node), not at idle-starvation priority.
114
+ g_source_set_priority(src, G_PRIORITY_DEFAULT);
115
+ g_source_set_name(src, "gjsify-napi tsfn dispatch");
116
+ g_source_set_callback(src, dispatch_cb, tsfn, nullptr);
117
+ tsfn->dispatch_source = src; // keep our creation ref until dispatch/teardown
118
+ g_source_attach(src, tsfn->main_context);
119
+ }
120
+
121
+ // Node CallJs (node_api.cc): the default call_js when the creator passed none.
122
+ void default_call_js(napi_env env, napi_value cb, void* /* context */,
123
+ void* /* data */) {
124
+ if (env == nullptr || cb == nullptr) {
125
+ return;
126
+ }
127
+ napi_value recv = nullptr;
128
+ if (napi_get_undefined(env, &recv) != napi_ok) {
129
+ return;
130
+ }
131
+ napi_call_function(env, recv, cb, 0, nullptr, nullptr);
132
+ }
133
+
134
+ // Run call_js for one queue item on the JS thread. NO tsfn lock held. A fresh
135
+ // handle scope wraps the call (Node invokes call_js inside a HandleScope +
136
+ // CallbackScope); a pending exception left by the callback is logged and
137
+ // cleared — there is no JS frame below a loop-dispatched invocation, and a
138
+ // lingering pending exception would poison unrelated later JSAPI calls (the
139
+ // finalizer-pipeline posture, ref.cc).
140
+ void invoke_call_js(napi_threadsafe_function tsfn, void* item) {
141
+ napi_env env = tsfn->env;
142
+ const bool can_call =
143
+ env != nullptr && env->can_call_into_js && !env->torn_down;
144
+ if (!can_call) {
145
+ // Node EmptyQueueAndDelete posture: env == NULL tells the callback
146
+ // "free `data`, run no JS".
147
+ tsfn->call_js(nullptr, nullptr, tsfn->context, item);
148
+ return;
149
+ }
150
+ napi_handle_scope scope = nullptr;
151
+ napi_open_handle_scope(env, &scope);
152
+ napi_value js_cb = nullptr;
153
+ if (tsfn->func_ref != nullptr) {
154
+ napi_get_reference_value(env, tsfn->func_ref, &js_cb);
155
+ }
156
+ tsfn->call_js(env, js_cb, tsfn->context, item);
157
+ log_and_clear_pending_exception(env, "threadsafe-function callback");
158
+ napi_close_handle_scope(env, scope);
159
+ }
160
+
161
+ // JS-thread finalization (Node CloseHandlesAndMaybeDelete → Finalize →
162
+ // EmptyQueueAndDelete): run the thread finalizer with JS available, drain
163
+ // leftover queue items with env == NULL so their data can be freed, drop the
164
+ // function ref, unregister from the env, free the struct. Caller must have
165
+ // set `closing` and destroyed any pending dispatch source under the lock.
166
+ void finalize_tsfn(napi_threadsafe_function tsfn) {
167
+ napi_env env = tsfn->env;
168
+ const bool can_call =
169
+ env != nullptr && env->can_call_into_js && !env->torn_down;
170
+
171
+ // Wait out blocking pushers still inside g_cond_wait: they hold no claim
172
+ // on the memory beyond the wait itself, and freeing the cond under a
173
+ // waiter is UB. `closing` is already set, so awakened waiters exit fast.
174
+ g_mutex_lock(&tsfn->mutex);
175
+ g_cond_broadcast(&tsfn->cond);
176
+ while (tsfn->waiters > 0) {
177
+ g_cond_wait(&tsfn->cond, &tsfn->mutex);
178
+ }
179
+ g_mutex_unlock(&tsfn->mutex);
180
+
181
+ if (tsfn->finalize_cb != nullptr && can_call) {
182
+ napi_handle_scope scope = nullptr;
183
+ napi_open_handle_scope(env, &scope);
184
+ tsfn->finalize_cb(env, tsfn->finalize_data, tsfn->context);
185
+ log_and_clear_pending_exception(env, "threadsafe-function finalizer");
186
+ napi_close_handle_scope(env, scope);
187
+ }
188
+ // Leftover items (abort path): give the callback its free-data-only call.
189
+ while (!tsfn->queue.empty()) {
190
+ void* item = tsfn->queue.front();
191
+ tsfn->queue.pop_front();
192
+ tsfn->call_js(nullptr, nullptr, tsfn->context, item);
193
+ }
194
+ if (tsfn->func_ref != nullptr && env != nullptr && !env->torn_down) {
195
+ napi_delete_reference(env, tsfn->func_ref);
196
+ tsfn->func_ref = nullptr;
197
+ }
198
+ if (env != nullptr) {
199
+ auto& list = env->tsfns;
200
+ for (auto it = list.begin(); it != list.end(); ++it) {
201
+ if (*it == tsfn) {
202
+ list.erase(it);
203
+ break;
204
+ }
205
+ }
206
+ }
207
+ delete tsfn;
208
+ }
209
+
210
+ // The JS-thread dispatch (Node AsyncCb → Dispatch → DispatchOne loop). Pops
211
+ // ONE item under the lock, RELEASES the lock, invokes call_js, re-checks —
212
+ // the lock is never held across JS (see file header). Decides + performs
213
+ // finalization ONLY at thread_count == 0 — a natural close (last claim gone +
214
+ // queue drained) or an abort whose remaining claimants have all drained. While
215
+ // an abort is closing but claims remain, it defers (join-before-free); the
216
+ // claim-consuming pushes/releases re-arm it when the last claim drops.
217
+ gboolean dispatch_cb(gpointer data) {
218
+ auto tsfn = static_cast<napi_threadsafe_function>(data);
219
+ g_mutex_lock(&tsfn->mutex);
220
+ if (tsfn->dispatch_source != nullptr) {
221
+ // Drop our creation ref; the context's ref dies with SOURCE_REMOVE.
222
+ g_source_unref(tsfn->dispatch_source);
223
+ tsfn->dispatch_source = nullptr;
224
+ }
225
+ unsigned iterations_left = kMaxDispatchIterations;
226
+ for (;;) {
227
+ // Finalize (free) ONLY once thread_count == 0 — no foreign thread can
228
+ // still be inside, or about to enter, napi_call_threadsafe_function
229
+ // holding a claim (§5e join-before-free; see the file header). A natural
230
+ // close needs the queue drained too; an abort (closing) then drops any
231
+ // leftover items via finalize_tsfn's env==NULL drain. Freeing while a
232
+ // claim is outstanding is the abort-path UAF — the closing pushes/
233
+ // releases below re-arm this dispatch when the LAST claim drops.
234
+ if (tsfn->thread_count == 0 &&
235
+ (tsfn->closing || tsfn->queue.empty())) {
236
+ tsfn->closing = true;
237
+ // A same-thread release() from inside call_js may have re-armed a
238
+ // wake meanwhile — destroy it, or it would fire into freed memory.
239
+ if (tsfn->dispatch_source != nullptr) {
240
+ g_source_destroy(tsfn->dispatch_source);
241
+ g_source_unref(tsfn->dispatch_source);
242
+ tsfn->dispatch_source = nullptr;
243
+ }
244
+ g_mutex_unlock(&tsfn->mutex);
245
+ finalize_tsfn(tsfn);
246
+ return G_SOURCE_REMOVE;
247
+ }
248
+ // Closing (abort) but claims are still outstanding: stop delivering and
249
+ // yield. Each claimant's claim-consuming napi_closing push/release
250
+ // re-arms this dispatch when it drops the last claim (thread_count → 0),
251
+ // and only THEN is the struct freed — never under a live claim.
252
+ if (tsfn->closing) {
253
+ break;
254
+ }
255
+ if (tsfn->queue.empty()) {
256
+ break; // idle: claims alive, nothing queued
257
+ }
258
+ if (iterations_left-- == 0) {
259
+ // Starvation guard (Node kMaxIterationCount): yield to the loop,
260
+ // re-arm for the remainder.
261
+ schedule_dispatch_locked(tsfn);
262
+ break;
263
+ }
264
+ void* item = tsfn->queue.front();
265
+ tsfn->queue.pop_front();
266
+ g_cond_broadcast(&tsfn->cond); // space freed for blocking pushers
267
+ g_mutex_unlock(&tsfn->mutex);
268
+ invoke_call_js(tsfn, item); // arbitrary JS — no lock held
269
+ g_mutex_lock(&tsfn->mutex);
270
+ }
271
+ g_mutex_unlock(&tsfn->mutex);
272
+ return G_SOURCE_REMOVE;
273
+ }
274
+
275
+ } // namespace
276
+
277
+ // Env teardown step 1b (env.cc): finalize every tsfn still registered. The
278
+ // release(abort) node-gi issues from its env cleanup hook schedules a
279
+ // dispatch that can never run — GjsContext dispose has begun, the main
280
+ // context will not be iterated again — so the deferred finalize is executed
281
+ // HERE, synchronously, while JS is still fully callable (§5e steps 1-4
282
+ // discipline). A tsfn whose claims were never released (the shutdown
283
+ // conformance test's 32 producers loop napi_call_threadsafe_function until
284
+ // napi_closing, then exit — no release ever) is JOINED before it is freed:
285
+ // set `closing`, then wait for thread_count to reach 0 — every claimant drains
286
+ // its claim (its next call returns napi_closing) before the free, so no foreign
287
+ // thread touches freed memory. This is the teardown-path arm of the same
288
+ // thread_count==0 join-before-free the dispatch uses (async_work realizes it
289
+ // with g_thread_pool_free wait=TRUE; tsfn cannot join threads it does not own,
290
+ // so it waits for the voluntary drain). Without it, a producer's next
291
+ // napi_closing push would lock a g_mutex_clear'd mutex and re-arm a dispatch on
292
+ // a freed GMainContext — the abort-path UAF, reached here. A deadline caps a
293
+ // claimant that never drains (a claim-contract violation — UB in Node too),
294
+ // falling back to the pre-fix force-free rather than hanging teardown.
295
+ void finalize_env_tsfns(napi_env env) {
296
+ while (!env->tsfns.empty()) {
297
+ napi_threadsafe_function tsfn = env->tsfns.back();
298
+ g_mutex_lock(&tsfn->mutex);
299
+ // Stop accepting pushes + wake blocking pushers so they observe it.
300
+ tsfn->closing = true;
301
+ g_cond_broadcast(&tsfn->cond);
302
+ // Join: wait for every outstanding claim to drain (each claim-consuming
303
+ // napi_closing push/release drops thread_count and broadcasts at 0).
304
+ const gint64 deadline =
305
+ g_get_monotonic_time() + 2 * G_TIME_SPAN_SECOND;
306
+ bool joined = true;
307
+ while (tsfn->thread_count > 0) {
308
+ if (!g_cond_wait_until(&tsfn->cond, &tsfn->mutex, deadline)) {
309
+ joined = false;
310
+ break;
311
+ }
312
+ }
313
+ if (!joined) {
314
+ g_warning("gjsify-napi: threadsafe-function teardown join timed out "
315
+ "with %zu claim(s) still outstanding; freeing anyway (a "
316
+ "foreign thread holds a tsfn claim without draining it)",
317
+ tsfn->thread_count);
318
+ }
319
+ // A claimant that reached 0 may have armed a dispatch that can no longer
320
+ // run (dispose has begun) — destroy it before the free.
321
+ if (tsfn->dispatch_source != nullptr) {
322
+ g_source_destroy(tsfn->dispatch_source);
323
+ g_source_unref(tsfn->dispatch_source);
324
+ tsfn->dispatch_source = nullptr;
325
+ }
326
+ g_mutex_unlock(&tsfn->mutex);
327
+ finalize_tsfn(tsfn); // removes it from env->tsfns
328
+ }
329
+ }
330
+
331
+ } // namespace gjsify_napi
332
+
333
+ // ---- ABI ----
334
+
335
+ napi_status NAPI_CDECL napi_create_threadsafe_function(
336
+ napi_env env, napi_value func, napi_value async_resource,
337
+ napi_value async_resource_name, size_t max_queue_size,
338
+ size_t initial_thread_count, void* thread_finalize_data,
339
+ napi_finalize thread_finalize_cb, void* context,
340
+ napi_threadsafe_function_call_js call_js_cb,
341
+ napi_threadsafe_function* result) {
342
+ (void)async_resource; // no async-hooks under GJS
343
+ GJSIFY_NAPI_CHECK_ENV(env);
344
+ GJSIFY_NAPI_CHECK_ARG(env, async_resource_name);
345
+ GJSIFY_NAPI_RETURN_STATUS_IF_FALSE(env, initial_thread_count > 0,
346
+ napi_invalid_arg);
347
+ GJSIFY_NAPI_CHECK_ARG(env, result);
348
+
349
+ napi_ref func_ref = nullptr;
350
+ if (func == nullptr) {
351
+ // js_callback-less tsfn: call_js_cb IS the callback (node_api.cc).
352
+ GJSIFY_NAPI_CHECK_ARG(env, call_js_cb);
353
+ } else {
354
+ JS::Value fn_v = gjsify_napi::napi_value_to_js(func);
355
+ GJSIFY_NAPI_RETURN_STATUS_IF_FALSE(
356
+ env, fn_v.isObject() && JS::IsCallable(&fn_v.toObject()),
357
+ napi_function_expected);
358
+ napi_status st = napi_create_reference(env, func, 1, &func_ref);
359
+ if (st != napi_ok) {
360
+ return st;
361
+ }
362
+ }
363
+
364
+ auto tsfn = new napi_threadsafe_function__();
365
+ tsfn->env = env;
366
+ // The GJS host loop iterates the GLOBAL default main context (the same
367
+ // context the §5c finalizer drain targets via g_idle_add) — that context
368
+ // is the JS thread's dispatch surface.
369
+ tsfn->main_context = g_main_context_ref(g_main_context_default());
370
+ tsfn->context = context;
371
+ tsfn->call_js = call_js_cb != nullptr ? call_js_cb
372
+ : gjsify_napi::default_call_js;
373
+ tsfn->func_ref = func_ref;
374
+ tsfn->finalize_cb = thread_finalize_cb;
375
+ tsfn->finalize_data = thread_finalize_data;
376
+ tsfn->max_queue_size = max_queue_size;
377
+ tsfn->thread_count = initial_thread_count;
378
+ env->tsfns.push_back(tsfn);
379
+
380
+ *result = tsfn;
381
+ return gjsify_napi::clear_last_error(env);
382
+ }
383
+
384
+ // Callable from ANY thread — env-less, touches no last_error. Mirrors Node's
385
+ // ThreadSafeFunction::Push byte-for-byte, including the auto-release on a
386
+ // closing push (a napi_closing return CONSUMES the caller's claim).
387
+ napi_status NAPI_CDECL napi_call_threadsafe_function(
388
+ napi_threadsafe_function func, void* data,
389
+ napi_threadsafe_function_call_mode is_blocking) {
390
+ if (func == nullptr) {
391
+ return napi_invalid_arg;
392
+ }
393
+ g_mutex_lock(&func->mutex);
394
+ while (func->max_queue_size > 0 &&
395
+ func->queue.size() >= func->max_queue_size && !func->closing) {
396
+ if (is_blocking == napi_tsfn_nonblocking) {
397
+ g_mutex_unlock(&func->mutex);
398
+ return napi_queue_full;
399
+ }
400
+ func->waiters++;
401
+ g_cond_wait(&func->cond, &func->mutex);
402
+ func->waiters--;
403
+ if (func->waiters == 0) {
404
+ // A finalizer may be waiting for the waiter set to drain.
405
+ g_cond_broadcast(&func->cond);
406
+ }
407
+ }
408
+ if (func->closing) {
409
+ if (func->thread_count == 0) {
410
+ g_mutex_unlock(&func->mutex);
411
+ return napi_invalid_arg;
412
+ }
413
+ func->thread_count--;
414
+ // This closing push just consumed the caller's claim; if it was the
415
+ // LAST one, arm the JS-thread dispatch so the deferred finalize runs now
416
+ // that no thread can re-enter (the free half of the abort-path fix —
417
+ // dispatch_cb frees only at thread_count == 0; coalesces if pending) AND
418
+ // wake a teardown JOIN (finalize_env_tsfns) waiting on the last claim.
419
+ if (func->thread_count == 0) {
420
+ gjsify_napi::schedule_dispatch_locked(func);
421
+ g_cond_broadcast(&func->cond);
422
+ }
423
+ g_mutex_unlock(&func->mutex);
424
+ return napi_closing;
425
+ }
426
+ func->queue.push_back(data);
427
+ gjsify_napi::schedule_dispatch_locked(func);
428
+ g_mutex_unlock(&func->mutex);
429
+ return napi_ok;
430
+ }
431
+
432
+ // Callable from ANY thread (Node Release): drop one claim; abort flips
433
+ // `closing` (no further pushes; queued items get the env-NULL drain). The
434
+ // finalize itself always runs deferred on the JS thread via the dispatch.
435
+ napi_status NAPI_CDECL napi_release_threadsafe_function(
436
+ napi_threadsafe_function func, napi_threadsafe_function_release_mode mode) {
437
+ if (func == nullptr) {
438
+ return napi_invalid_arg;
439
+ }
440
+ g_mutex_lock(&func->mutex);
441
+ if (func->thread_count == 0) {
442
+ g_mutex_unlock(&func->mutex);
443
+ return napi_invalid_arg;
444
+ }
445
+ func->thread_count--;
446
+ if (mode == napi_tsfn_abort && !func->closing) {
447
+ func->closing = true;
448
+ g_cond_broadcast(&func->cond); // wake blocking pushers
449
+ }
450
+ // Arm the JS-thread dispatch when this drops the last claim (natural close,
451
+ // OR the deferred abort-path finalize once every claimant has drained) or
452
+ // when an abort just closed the tsfn (so a still-populated queue reaches its
453
+ // env==NULL drain + finalize). Not gated on !closing: a last-claim release
454
+ // AFTER a prior abort must still arm the finalize. Coalesces if pending.
455
+ if (func->thread_count == 0 || mode == napi_tsfn_abort) {
456
+ gjsify_napi::schedule_dispatch_locked(func);
457
+ }
458
+ if (func->thread_count == 0) {
459
+ // Wake a teardown JOIN (finalize_env_tsfns) waiting on the last claim.
460
+ g_cond_broadcast(&func->cond);
461
+ }
462
+ g_mutex_unlock(&func->mutex);
463
+ return napi_ok;
464
+ }
465
+
466
+ // Keep-event-loop-alive bookkeeping. Under the GJS host there is nothing to
467
+ // unref: an attached idle GSource does not keep gjs alive (process lifetime
468
+ // is the module evaluation + whatever main loop the app itself runs), which
469
+ // is already the semantic an UNREF'D Node tsfn has — so this records the
470
+ // flag and succeeds. JS-thread-only API, like Node.
471
+ napi_status NAPI_CDECL napi_unref_threadsafe_function(
472
+ node_api_basic_env basic_env, napi_threadsafe_function func) {
473
+ napi_env env = const_cast<napi_env>(basic_env);
474
+ GJSIFY_NAPI_CHECK_ENV(env);
475
+ GJSIFY_NAPI_CHECK_ARG(env, func);
476
+ g_mutex_lock(&func->mutex);
477
+ func->referenced = false;
478
+ g_mutex_unlock(&func->mutex);
479
+ return gjsify_napi::clear_last_error(env);
480
+ }
481
+
482
+ // The async-context parameter is an async-hooks concept with no GJS
483
+ // equivalent; the call itself is a plain call. Microtask parity holds the
484
+ // GJS way: promise jobs queued by the callback drain from GJS's own
485
+ // promise-job source as soon as this stack unwinds to the main loop — the
486
+ // same boundary semantics GJS gives its own C→JS closures (and the posture
487
+ // node-gi already codes against, src/loop.cc "cross-runtime microtask
488
+ // checkpoint": SpiderMonkey/GJS drains when the last JS frame exits).
489
+ napi_status NAPI_CDECL napi_make_callback(napi_env env,
490
+ napi_async_context async_context,
491
+ napi_value recv, napi_value func,
492
+ size_t argc, const napi_value* argv,
493
+ napi_value* result) {
494
+ (void)async_context;
495
+ GJSIFY_NAPI_PREAMBLE(env);
496
+ GJSIFY_NAPI_CHECK_ARG(env, recv);
497
+ // Node coerces the receiver via CHECK_TO_OBJECT → napi_object_expected.
498
+ GJSIFY_NAPI_RETURN_STATUS_IF_FALSE(
499
+ env, gjsify_napi::napi_value_to_js(recv).isObject(),
500
+ napi_object_expected);
501
+ return napi_call_function(env, recv, func, argc, argv, result);
502
+ }
503
+
504
+ // ---- threadsafe-function accessors (were loud stubs; the struct already
505
+ // tracks context + thread_count + referenced, so these are exact) ----
506
+
507
+ // Env-less, touches no last_error — mirrors Node's GetContext.
508
+ napi_status NAPI_CDECL napi_get_threadsafe_function_context(
509
+ napi_threadsafe_function func, void** result) {
510
+ if (func == nullptr || result == nullptr) {
511
+ return napi_invalid_arg;
512
+ }
513
+ *result = func->context;
514
+ return napi_ok;
515
+ }
516
+
517
+ // Callable from ANY thread (Node Acquire): a closing tsfn refuses new claims;
518
+ // otherwise take one. Env-less, no last_error, like call/release.
519
+ napi_status NAPI_CDECL napi_acquire_threadsafe_function(
520
+ napi_threadsafe_function func) {
521
+ if (func == nullptr) {
522
+ return napi_invalid_arg;
523
+ }
524
+ g_mutex_lock(&func->mutex);
525
+ if (func->closing) {
526
+ g_mutex_unlock(&func->mutex);
527
+ return napi_closing;
528
+ }
529
+ func->thread_count++;
530
+ g_mutex_unlock(&func->mutex);
531
+ return napi_ok;
532
+ }
533
+
534
+ // The ref counterpart of napi_unref_threadsafe_function. As with unref, there
535
+ // is nothing to keep alive under the GJS host (an idle GSource does not keep
536
+ // gjs running), so this records the flag and succeeds. JS-thread-only.
537
+ napi_status NAPI_CDECL napi_ref_threadsafe_function(
538
+ node_api_basic_env basic_env, napi_threadsafe_function func) {
539
+ napi_env env = const_cast<napi_env>(basic_env);
540
+ GJSIFY_NAPI_CHECK_ENV(env);
541
+ GJSIFY_NAPI_CHECK_ARG(env, func);
542
+ g_mutex_lock(&func->mutex);
543
+ func->referenced = true;
544
+ g_mutex_unlock(&func->mutex);
545
+ return gjsify_napi::clear_last_error(env);
546
+ }
547
+
548
+ // ---- async context + callback scope (node_api.h) ----
549
+ //
550
+ // These are async-hooks bookkeeping. GJS has no async_hooks, so an async
551
+ // context is a benign opaque token and a callback scope is a no-op push/pop —
552
+ // the callback still runs and SpiderMonkey drains its microtasks when the JS
553
+ // stack unwinds (see napi_make_callback's note). This is the correct minimal
554
+ // implementation for a host without async_hooks/uv, NOT a stub: node's
555
+ // make_callback / callback_scope tests need napi_async_init to SUCCEED and
556
+ // return a usable context, and open/close_callback_scope to bracket a call.
557
+
558
+ struct napi_async_context__ {
559
+ napi_env env;
560
+ };
561
+ struct napi_callback_scope__ {
562
+ napi_env env;
563
+ };
564
+
565
+ napi_status NAPI_CDECL napi_async_init(napi_env env, napi_value async_resource,
566
+ napi_value async_resource_name,
567
+ napi_async_context* result) {
568
+ GJSIFY_NAPI_CHECK_ENV(env);
569
+ GJSIFY_NAPI_CHECK_ARG(env, async_resource_name);
570
+ GJSIFY_NAPI_CHECK_ARG(env, result);
571
+ (void)async_resource; // resource object is an async-hooks concept
572
+ *result = new napi_async_context__{env};
573
+ return gjsify_napi::clear_last_error(env);
574
+ }
575
+
576
+ napi_status NAPI_CDECL napi_async_destroy(napi_env env,
577
+ napi_async_context async_context) {
578
+ GJSIFY_NAPI_CHECK_ENV(env);
579
+ GJSIFY_NAPI_CHECK_ARG(env, async_context);
580
+ delete async_context;
581
+ return gjsify_napi::clear_last_error(env);
582
+ }
583
+
584
+ napi_status NAPI_CDECL napi_open_callback_scope(napi_env env,
585
+ napi_value resource_object,
586
+ napi_async_context context,
587
+ napi_callback_scope* result) {
588
+ GJSIFY_NAPI_CHECK_ENV(env);
589
+ GJSIFY_NAPI_CHECK_ARG(env, result);
590
+ (void)resource_object;
591
+ (void)context;
592
+ *result = new napi_callback_scope__{env};
593
+ return gjsify_napi::clear_last_error(env);
594
+ }
595
+
596
+ napi_status NAPI_CDECL napi_close_callback_scope(napi_env env,
597
+ napi_callback_scope scope) {
598
+ GJSIFY_NAPI_CHECK_ENV(env);
599
+ GJSIFY_NAPI_CHECK_ARG(env, scope);
600
+ delete scope;
601
+ return gjsify_napi::clear_last_error(env);
602
+ }