@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.
@@ -0,0 +1,439 @@
1
+ // SPDX-License-Identifier: MIT
2
+ // @gjsify/napi — asynchronous work items (napi_create/queue/cancel/delete_
3
+ // async_work). This is what unblocks the whole node-addon-api async ecosystem
4
+ // (node-sqlite3, bcrypt, …): every Napi::AsyncWorker is these four functions.
5
+ //
6
+ // Reference: refs/node/src/node_api.cc uvimpl::Work (Node.js contributors,
7
+ // MIT) — the normative execute-on-worker / complete-on-loop semantics mirrored
8
+ // here. Node realizes the two phases as node::ThreadPoolWork on libuv: a WORKER
9
+ // thread runs `execute(env, data)` (NO JS access — the ABI contract), then the
10
+ // event loop runs `complete(env, status, data)` on the main/JS thread under a
11
+ // HandleScope + CallbackScope.
12
+ //
13
+ // THE HOST REALIZATION — GThreadPool worker + GMainContext dispatch:
14
+ // GJS owns exactly one JSContext on one thread, but a napi worker phase enters
15
+ // no JS, so it can run on a real pooled thread. This shim gives each env a
16
+ // GThreadPool (libuv-thread-pool analog, sized after UV_THREADPOOL_SIZE):
17
+ // - `napi_queue_async_work` pushes the work onto the pool and RETURNS (true
18
+ // concurrency — the loop no longer blocks during `execute`, the one
19
+ // divergence the earlier inline model documented). A pooled WORKER thread
20
+ // runs `execute(env, data)`; it enters no JSAPI and touches no GObject (the
21
+ // ABI contract), so it races nothing on the JS thread.
22
+ // - on completion the worker marshals `complete` back to the JS thread via a
23
+ // g_idle GSource attached to the env's GMainContext — EXACTLY the machinery
24
+ // tsfn.cc's cross-thread dispatch uses. `g_source_attach` is thread-safe
25
+ // (takes the context lock, wakes its eventfd, runs no JSAPI), and the source
26
+ // only ever DISPATCHES on the thread iterating the context (the JS thread).
27
+ // `complete` runs there under a fresh handle scope, guarded by
28
+ // can_call_into_js, pending exception logged-and-cleared — the "no JS frame
29
+ // below a loop-dispatched callback" posture shared with the finalizer
30
+ // pipeline and tsfn. The completion fires ASYNCHRONOUSLY, a loop turn after
31
+ // queue returns, exactly as callers depend on.
32
+ //
33
+ // THE MUTEX: `state` + `complete_source` + `complete_status` are touched by the
34
+ // JS thread (create/queue/cancel/dispatch/teardown) AND a pool worker, so a
35
+ // per-work GMutex serializes them. It is NEVER held across `execute` (long,
36
+ // unbounded) nor across `complete`/any JS call (may run arbitrary JS →
37
+ // self-delete) — the tsfn "lock never spans call_js" discipline. The
38
+ // JS↔worker race that decides cancel lives entirely inside that lock: whoever
39
+ // claims the work first (worker → kExecuting, cancel → kCancelled) wins, so
40
+ // cancel is EBUSY iff the worker already started (Node's uv_cancel semantics).
41
+ //
42
+ // An AsyncWork holds NO JS values (execute/complete/data are plain C pointers;
43
+ // any napi_ref the addon needs lives on the env's ref list, traced there), so —
44
+ // unlike tsfn/Reference — it needs no GC root and no weak-sweep interaction.
45
+ // Its only lifetime hazards are the pending GSource and the worker; both are
46
+ // handled the tsfn way (source owned until dispatch/teardown; worker joined at
47
+ // teardown before any root drops).
48
+ //
49
+ // Lifetime / ownership (Node parity — addons commonly delete the work INSIDE
50
+ // complete, js_native_api "Don't access work after complete" rule):
51
+ // - create registers the work on env->async_works (teardown reaches it);
52
+ // - the complete dispatch marks the work kComplete, drops the source, then
53
+ // runs complete — which MAY call napi_delete_async_work(work) (self-delete,
54
+ // unlinks + frees) so the struct is NEVER touched afterwards;
55
+ // - a work the addon never deletes stays linked and is freed at env
56
+ // destruction (destroy_env_async_works) — Node leaks that same struct (its
57
+ // Work is addon-owned), so the mem leg runs leak-check=no.
58
+ //
59
+ // Teardown (§5e crash-class boundary): at env teardown the pool is drained
60
+ // deterministically — g_thread_pool_free(pool, immediate=FALSE, wait=TRUE) lets
61
+ // every in-flight/queued execute finish and JOINS every worker (a happens-before
62
+ // barrier: the worker's writes are then visible on the JS thread, and no worker
63
+ // can touch a work or the env again). Only THEN are the still-pending completions
64
+ // run synchronously, JS still callable (drain_env_async_works) — the same
65
+ // discipline finalize_env_tsfns uses, because the deferred g_idle can no longer
66
+ // fire once GjsContext dispose began. An async_work must not outlive the env; a
67
+ // worker must not touch a torn-down env.
68
+
69
+ #include "common.h"
70
+
71
+ #include <algorithm>
72
+
73
+ // Global scope: the struct tag is fixed by the vendored
74
+ // `typedef struct napi_async_work__* napi_async_work`.
75
+ struct napi_async_work__ {
76
+ // Lifecycle state. Transitions cross the JS thread ↔ pool worker boundary,
77
+ // so all reads/writes are under `mutex` (except at teardown, after the pool
78
+ // join has re-established single-threadedness).
79
+ enum class State : uint8_t {
80
+ kCreated, // created, not yet queued (no uv request → cancel EINVAL)
81
+ kQueued, // pushed to the pool; no worker has started execute
82
+ kExecuting, // a worker is running execute (cancel now → EBUSY)
83
+ kCancelled, // cancelled while queued; the worker will skip execute
84
+ kCompletePending, // execute ran (or was skipped); a complete dispatch is armed
85
+ kComplete, // complete has been dispatched (may already be freed)
86
+ };
87
+
88
+ napi_env env = nullptr; // owning env (outlives the work)
89
+ napi_async_execute_callback execute = nullptr; // worker-phase, NO JS
90
+ napi_async_complete_callback complete = nullptr; // main-phase, may run JS
91
+ void* data = nullptr; // addon-owned payload
92
+
93
+ // Guards state + complete_source + complete_status across JS thread/worker.
94
+ GMutex mutex;
95
+ State state = State::kCreated;
96
+ // Set by the worker (napi_ok) or by the worker for a cancelled item
97
+ // (napi_cancelled); read by the JS-thread dispatch. Guarded by mutex.
98
+ napi_status complete_status = napi_ok;
99
+ // The env's dispatch surface (default GMainContext, ref held) — captured on
100
+ // the JS thread at create so the worker never calls g_main_context_default().
101
+ GMainContext* main_context = nullptr;
102
+ // The pending main-loop complete dispatch (creation ref held until the
103
+ // dispatch runs or teardown/free destroys it — tsfn dispatch_source
104
+ // discipline). Armed by the worker, dropped on the JS thread.
105
+ GSource* complete_source = nullptr;
106
+
107
+ napi_async_work__() { g_mutex_init(&mutex); }
108
+
109
+ ~napi_async_work__() {
110
+ // Defensive: a live source here would fire into freed memory. In every
111
+ // normal path complete_source is already null (the dispatch, free or
112
+ // teardown cleared it) — this only bites a pathological free. By the
113
+ // time a work is freed, its worker is done (joined at teardown, or the
114
+ // work reached kComplete), so no thread races this.
115
+ if (complete_source != nullptr) {
116
+ g_source_destroy(complete_source);
117
+ g_source_unref(complete_source);
118
+ complete_source = nullptr;
119
+ }
120
+ if (main_context != nullptr) {
121
+ g_main_context_unref(main_context);
122
+ }
123
+ g_mutex_clear(&mutex);
124
+ }
125
+ };
126
+
127
+ namespace gjsify_napi {
128
+ namespace {
129
+
130
+ gboolean complete_dispatch_cb(gpointer data);
131
+
132
+ // libuv's UV_THREADPOOL_SIZE (default 4, clamped [1, 1024]). TestCancel relies
133
+ // on the pool being SMALLER than MAX_CANCEL_THREADS (6): it saturates the pool
134
+ // with sleeping works so the cancel target stays queued. Reading the same env
135
+ // var libuv reads keeps the Node reference and the shim in lockstep.
136
+ int async_pool_max_threads() {
137
+ int n = 4;
138
+ const char* size = g_getenv("UV_THREADPOOL_SIZE");
139
+ if (size != nullptr) {
140
+ const gint64 parsed = g_ascii_strtoll(size, nullptr, 10);
141
+ if (parsed > 0) {
142
+ n = static_cast<int>(parsed);
143
+ }
144
+ }
145
+ if (n < 1) n = 1;
146
+ if (n > 1024) n = 1024;
147
+ return n;
148
+ }
149
+
150
+ // Arm the JS-thread complete dispatch. Caller holds work->mutex. Safe from the
151
+ // worker thread: pure GLib, no JSAPI (see file header) — g_source_attach takes
152
+ // the context lock and wakes it; the source dispatches only on the JS thread.
153
+ void schedule_complete_locked(napi_async_work work) {
154
+ if (work->complete_source != nullptr) {
155
+ return; // already armed — never double-schedule a single work
156
+ }
157
+ GSource* src = g_idle_source_new();
158
+ // uv_async parity: the loop checks its event queue every turn, so run the
159
+ // completion promptly rather than at idle-starvation priority.
160
+ g_source_set_priority(src, G_PRIORITY_DEFAULT);
161
+ g_source_set_name(src, "gjsify-napi async_work complete");
162
+ g_source_set_callback(src, complete_dispatch_cb, work, nullptr);
163
+ work->complete_source = src; // keep the creation ref until dispatch/teardown
164
+ g_source_attach(src, work->main_context);
165
+ }
166
+
167
+ // The pool worker phase (Node's uvimpl::Work::ExecuteCallback on a libuv worker
168
+ // thread). Runs OFF the JS thread; enters no JSAPI, touches no GObject. Claims
169
+ // the work under the lock (kQueued → kExecuting, so a concurrent cancel now
170
+ // returns EBUSY) or, if cancel won the race (kCancelled), skips execute. The
171
+ // lock is released across `execute` (unbounded) and re-taken only to record the
172
+ // status + arm the JS-thread completion.
173
+ void pool_worker(gpointer data, gpointer /* user_data */) {
174
+ auto work = static_cast<napi_async_work>(data);
175
+ napi_env env = work->env;
176
+
177
+ g_mutex_lock(&work->mutex);
178
+ if (work->state == napi_async_work__::State::kCancelled) {
179
+ // Cancel won the race before any worker started this item (Node's
180
+ // uv_cancel-on-a-queued-work → the after-cb runs with UV_ECANCELED).
181
+ work->complete_status = napi_cancelled;
182
+ work->state = napi_async_work__::State::kCompletePending;
183
+ schedule_complete_locked(work);
184
+ g_mutex_unlock(&work->mutex);
185
+ return;
186
+ }
187
+ work->state = napi_async_work__::State::kExecuting;
188
+ napi_async_execute_callback execute = work->execute;
189
+ void* payload = work->data;
190
+ g_mutex_unlock(&work->mutex); // execute must run with NO lock held
191
+
192
+ if (execute != nullptr) {
193
+ execute(env, payload); // worker phase — NO JS, NO GObject (ABI contract)
194
+ }
195
+
196
+ g_mutex_lock(&work->mutex);
197
+ work->complete_status = napi_ok;
198
+ work->state = napi_async_work__::State::kCompletePending;
199
+ schedule_complete_locked(work);
200
+ g_mutex_unlock(&work->mutex);
201
+ // Do NOT touch `work` after unlock: the JS-thread dispatch may already have
202
+ // run complete and self-deleted it.
203
+ }
204
+
205
+ // Run the completion callback with JS callable (Node AfterThreadPoolWork:
206
+ // HandleScope + CallbackScope + CallbackIntoModule<true>). NO lock held. A null
207
+ // complete is a no-op (Node returns early). The env==torn-down path still calls
208
+ // complete so the addon can free `data`, but opens no handle scope and enters no
209
+ // JS — the EmptyQueueAndDelete posture shared with tsfn. WARNING: complete may
210
+ // delete the work; the caller must not touch it afterwards.
211
+ void run_complete(napi_async_work work, napi_status status) {
212
+ napi_env env = work->env;
213
+ napi_async_complete_callback complete = work->complete;
214
+ void* payload = work->data;
215
+ if (complete == nullptr) {
216
+ return;
217
+ }
218
+ const bool can_call =
219
+ env != nullptr && env->can_call_into_js && !env->torn_down;
220
+ if (!can_call) {
221
+ complete(env, status, payload);
222
+ return;
223
+ }
224
+ napi_handle_scope scope = nullptr;
225
+ napi_open_handle_scope(env, &scope);
226
+ complete(env, status, payload);
227
+ log_and_clear_pending_exception(env, "async-work complete callback");
228
+ napi_close_handle_scope(env, scope);
229
+ }
230
+
231
+ // The JS-thread complete dispatch (Node's AfterThreadPoolWork on the loop).
232
+ // Under the lock: drop the source, snapshot the status, mark kComplete. Then
233
+ // RELEASE the lock and run the callback exactly once — the lock is never held
234
+ // across JS (self-delete would clear a held mutex), and after complete the work
235
+ // may be gone, so it is never touched again.
236
+ gboolean complete_dispatch_cb(gpointer data) {
237
+ auto work = static_cast<napi_async_work>(data);
238
+ g_mutex_lock(&work->mutex);
239
+ // Drop our creation ref; the context's ref dies with SOURCE_REMOVE. Null it
240
+ // first so a self-delete inside complete does not re-free the source.
241
+ if (work->complete_source != nullptr) {
242
+ g_source_unref(work->complete_source);
243
+ work->complete_source = nullptr;
244
+ }
245
+ const napi_status status = work->complete_status;
246
+ work->state = napi_async_work__::State::kComplete;
247
+ g_mutex_unlock(&work->mutex);
248
+ run_complete(work, status); // may delete `work` — do not touch it after
249
+ return G_SOURCE_REMOVE;
250
+ }
251
+
252
+ // Unlink a work from its env's registry (no-op if absent).
253
+ void unregister_async_work(napi_env env, napi_async_work work) {
254
+ auto& list = env->async_works;
255
+ list.erase(std::remove(list.begin(), list.end(), work), list.end());
256
+ }
257
+
258
+ // Lazily create the env's worker pool. Refuses once teardown has begun so an
259
+ // async_work can never outlive the env (a pool recreated after the teardown
260
+ // drain would run a worker into a disposing context). Exclusive pool: threads
261
+ // are dedicated + created eagerly, so g_thread_pool_free joins them
262
+ // deterministically (clean valgrind, no lingering shared idle threads).
263
+ GThreadPool* ensure_env_pool(napi_env env) {
264
+ if (env->tearing_down || env->torn_down) {
265
+ return nullptr;
266
+ }
267
+ if (env->async_pool == nullptr) {
268
+ env->async_pool = g_thread_pool_new(pool_worker, nullptr,
269
+ async_pool_max_threads(),
270
+ /* exclusive = */ TRUE, nullptr);
271
+ }
272
+ return env->async_pool;
273
+ }
274
+
275
+ } // namespace
276
+
277
+ // Env teardown step 1c (env.cc), the §5e crash-class boundary. FIRST join the
278
+ // pool: g_thread_pool_free(immediate=FALSE, wait=TRUE) runs every queued/in-
279
+ // flight execute to completion and joins all workers — after it returns the
280
+ // process is single-threaded again, every worker's writes are visible here, and
281
+ // nothing off-thread can touch a work or the (tearing-down) env. THEN run each
282
+ // still-pending completion synchronously, JS callable, because the deferred
283
+ // g_idle can no longer fire once GjsContext dispose began (mirror
284
+ // finalize_env_tsfns). No lock is needed below the join.
285
+ void drain_env_async_works(napi_env env) {
286
+ if (env->async_pool != nullptr) {
287
+ // wait=TRUE: block here until in-flight execute finishes + workers join.
288
+ // This is what keeps a worker from touching a torn-down env.
289
+ g_thread_pool_free(env->async_pool, /* immediate = */ FALSE,
290
+ /* wait = */ TRUE);
291
+ env->async_pool = nullptr;
292
+ }
293
+ for (;;) {
294
+ napi_async_work work = nullptr;
295
+ for (napi_async_work w : env->async_works) {
296
+ // Post-join: workers are all gone, so plain reads are safe. Every
297
+ // work is now kCompletePending, kComplete (dispatch already ran) or
298
+ // kCreated (never queued); only the first still needs completing.
299
+ if (w->state == napi_async_work__::State::kCompletePending) {
300
+ work = w;
301
+ break;
302
+ }
303
+ }
304
+ if (work == nullptr) {
305
+ break;
306
+ }
307
+ const napi_status status = work->complete_status;
308
+ if (work->complete_source != nullptr) {
309
+ g_source_destroy(work->complete_source);
310
+ g_source_unref(work->complete_source);
311
+ work->complete_source = nullptr;
312
+ }
313
+ // Mark BEFORE running: complete may self-delete (unlinks + frees), and a
314
+ // work it does NOT delete must not be revisited by the next scan.
315
+ work->state = napi_async_work__::State::kComplete;
316
+ run_complete(work, status); // may delete `work` — do not touch after
317
+ }
318
+ }
319
+
320
+ // Env destruction (~napi_env__, after teardown, no JS runs anymore): free every
321
+ // remaining work. Self-deleted works were already unlinked, so nothing here can
322
+ // double-free; a work the addon never deleted is freed now (Node leaks its
323
+ // equivalent — the struct is addon-owned there). The pool is already gone
324
+ // (drained at teardown); the guard is defensive for a partial-init env.
325
+ void destroy_env_async_works(napi_env env) {
326
+ if (env->async_pool != nullptr) {
327
+ g_thread_pool_free(env->async_pool, /* immediate = */ TRUE,
328
+ /* wait = */ TRUE);
329
+ env->async_pool = nullptr;
330
+ }
331
+ for (napi_async_work work : env->async_works) {
332
+ delete work;
333
+ }
334
+ env->async_works.clear();
335
+ }
336
+
337
+ } // namespace gjsify_napi
338
+
339
+ // ---- ABI ----
340
+
341
+ // Node napi_create_async_work: allocate the work, capture the callbacks + data,
342
+ // register it on the env. async_resource / async_resource_name are async-hooks
343
+ // naming with no GJS equivalent (as with tsfn's async_resource) — validated,
344
+ // then ignored.
345
+ napi_status NAPI_CDECL napi_create_async_work(
346
+ napi_env env, napi_value async_resource, napi_value async_resource_name,
347
+ napi_async_execute_callback execute, napi_async_complete_callback complete,
348
+ void* data, napi_async_work* result) {
349
+ (void)async_resource;
350
+ GJSIFY_NAPI_CHECK_ENV(env);
351
+ GJSIFY_NAPI_CHECK_ARG(env, async_resource_name);
352
+ GJSIFY_NAPI_CHECK_ARG(env, execute);
353
+ GJSIFY_NAPI_CHECK_ARG(env, result);
354
+
355
+ auto work = new napi_async_work__();
356
+ work->env = env;
357
+ work->execute = execute;
358
+ work->complete = complete;
359
+ work->data = data;
360
+ // The GJS host loop iterates the GLOBAL default main context (the same
361
+ // surface the §5c finalizer drain + tsfn dispatch target). Ref it on the JS
362
+ // thread so the worker reads a stable, owned pointer.
363
+ work->main_context = g_main_context_ref(g_main_context_default());
364
+ env->async_works.push_back(work);
365
+
366
+ *result = work;
367
+ return gjsify_napi::clear_last_error(env);
368
+ }
369
+
370
+ // Node napi_delete_async_work: free the work. Node parity — addons delete inside
371
+ // complete, so this must tolerate a still-registered OR already-unlinked work.
372
+ // Unregister (no-op if the complete dispatch already removed it) then free.
373
+ napi_status NAPI_CDECL napi_delete_async_work(napi_env env,
374
+ napi_async_work work) {
375
+ GJSIFY_NAPI_CHECK_ENV(env);
376
+ GJSIFY_NAPI_CHECK_ARG(env, work);
377
+ gjsify_napi::unregister_async_work(env, work);
378
+ delete work; // ~napi_async_work__ destroys any lingering source
379
+ return gjsify_napi::clear_last_error(env);
380
+ }
381
+
382
+ // Node napi_queue_async_work: hand the work to the env's worker pool. A pooled
383
+ // thread runs `execute` (NO JS — the ABI contract), then marshals `complete`
384
+ // back to the JS thread on the next loop turn (see file header). Re-queuing a
385
+ // work, or queuing after teardown began, is a caller error → generic_failure.
386
+ napi_status NAPI_CDECL napi_queue_async_work(node_api_basic_env basic_env,
387
+ napi_async_work work) {
388
+ napi_env env = const_cast<napi_env>(basic_env);
389
+ GJSIFY_NAPI_CHECK_ENV(env);
390
+ GJSIFY_NAPI_CHECK_ARG(env, work);
391
+
392
+ GThreadPool* pool = gjsify_napi::ensure_env_pool(env);
393
+ if (pool == nullptr) {
394
+ // Teardown began (or the pool failed to allocate) — cannot accept work.
395
+ return gjsify_napi::set_last_error(env, napi_generic_failure);
396
+ }
397
+
398
+ g_mutex_lock(&work->mutex);
399
+ if (work->state != napi_async_work__::State::kCreated) {
400
+ // Already queued / completed — re-queuing a work is a caller error.
401
+ g_mutex_unlock(&work->mutex);
402
+ return gjsify_napi::set_last_error(env, napi_generic_failure);
403
+ }
404
+ work->state = napi_async_work__::State::kQueued;
405
+ g_mutex_unlock(&work->mutex);
406
+
407
+ // Enqueue onto the pool. For an exclusive pool the worker threads already
408
+ // exist, so this only appends to the async queue (never spawns) — the GError
409
+ // out-param would only report a thread-create failure, which cannot occur
410
+ // here; the host owns thread provisioning (as Node owns libuv's).
411
+ g_thread_pool_push(pool, work, nullptr);
412
+ return gjsify_napi::clear_last_error(env);
413
+ }
414
+
415
+ // Node napi_cancel_async_work: cancel a not-yet-STARTED work. Semantics mirror
416
+ // Node's uv_cancel exactly:
417
+ // - kQueued (in the pool, no worker has claimed it) → napi_ok; the worker will
418
+ // later dequeue it, skip execute, and complete with napi_cancelled (uv's
419
+ // queued-work-removed → UV_ECANCELED after-callback);
420
+ // - kCreated (never queued → no uv request) → generic_failure (uv EINVAL);
421
+ // - kExecuting / kCompletePending / kComplete (already running or done) →
422
+ // generic_failure (uv EBUSY).
423
+ // The JS-thread cancel races the worker's claim under the SAME lock, so exactly
424
+ // one of them transitions kQueued: cancel loses (EBUSY) iff the worker already
425
+ // flipped kExecuting.
426
+ napi_status NAPI_CDECL napi_cancel_async_work(node_api_basic_env basic_env,
427
+ napi_async_work work) {
428
+ napi_env env = const_cast<napi_env>(basic_env);
429
+ GJSIFY_NAPI_CHECK_ENV(env);
430
+ GJSIFY_NAPI_CHECK_ARG(env, work);
431
+ g_mutex_lock(&work->mutex);
432
+ if (work->state != napi_async_work__::State::kQueued) {
433
+ g_mutex_unlock(&work->mutex);
434
+ return gjsify_napi::set_last_error(env, napi_generic_failure);
435
+ }
436
+ work->state = napi_async_work__::State::kCancelled;
437
+ g_mutex_unlock(&work->mutex);
438
+ return gjsify_napi::clear_last_error(env);
439
+ }