@gjsify/napi 0.22.0 → 0.25.1

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 CHANGED
@@ -42,7 +42,7 @@ gjsify install @gjsify/napi
42
42
 
43
43
  Published on the `@gjsify/*` release train. The tarball ships the shim
44
44
  **prebuilt** for the platforms in [Platforms](#platforms) below —
45
- `prebuilds/linux-x86_64/` and `prebuilds/darwin-arm64/`, each a `.so`/`.dylib`
45
+ `prebuilds/linux-x64/` and `prebuilds/darwin-arm64/`, each a `.so`/`.dylib`
46
46
  plus its `.gir` and `.typelib`, rebuilt from the released source by the
47
47
  `napi-prebuild-*` jobs in `.github/workflows/release.yml` — so an install brings
48
48
  the shim itself, not just a build recipe. Installing through the `gjsify` CLI
@@ -120,7 +120,7 @@ both:
120
120
 
121
121
  | Platform | Prebuild dir | Status |
122
122
  | --- | --- | --- |
123
- | Linux x86_64 | `prebuilds/linux-x86_64/` | Supported (full gate + conformance + consumer CI) |
123
+ | Linux x64 | `prebuilds/linux-x64/` | Supported (full gate + conformance + consumer CI) |
124
124
  | macOS arm64 | `prebuilds/darwin-arm64/` | Supported (build + load + P0.x value-model + **P1 tsfn** gates); the earlier tsfn exit-segfault (a UAF — the tsfn was freed while foreign claimants still held claims) is **fixed** (join-before-free, both abort + teardown paths); conformance/consumer/valgrind widening deferred |
125
125
  | Windows x64 | — | **Attempted, blocked at gjs-on-Windows** (shim-side portability done; see below) |
126
126
 
@@ -151,7 +151,7 @@ meson setup build . # or: gjsify run init:meson
151
151
  meson compile -C build # builds libgjsifynapi.so + GjsifyNapi-1.0.{gir,typelib}
152
152
 
153
153
  # stage the prebuild the loader/gates resolve from:
154
- gjsify run build:prebuilds # meson build + copy into prebuilds/linux-x86_64/
154
+ gjsify run build:prebuilds # meson build + copy into prebuilds/linux-x64/
155
155
  ```
156
156
 
157
157
  ## Test
@@ -167,6 +167,8 @@ gjsify run test:gate # smoke: load a minimal addon under gjs
167
167
  gjsify run test:gate:p01 # env + value model + scopes
168
168
  gjsify run test:gate:p02 # js_native_api core
169
169
  gjsify run test:gate:p03 # wrap + lifetime
170
+ gjsify run test:gate:p1 # threadsafe functions from real foreign pthreads
171
+ gjsify run test:gate:tsfn-teardown # tsfn env-teardown claim attribution (measured)
170
172
  gjsify run test:mem # valgrind leak/UAF leg for the gates
171
173
 
172
174
  gjsify run test:conformance # golden-diff vs Node over node's js-native-api addons
@@ -190,6 +192,73 @@ reason (an unlisted failure fails the harness; a listed program that starts
190
192
  passing also fails — stale entry). `test:conformance:mem` / `test:consumer:mem`
191
193
  are the valgrind variants.
192
194
 
195
+ ## Diagnostics
196
+
197
+ Loading an addon prints **nothing**. The shim's internal diagnostics — the
198
+ symbol-promotion notes and the §3d P0.0 teardown probe (`GjsContext weak notify
199
+ fired…` / `N env(s) torn down before JS_DestroyContext`) — are `g_debug()`
200
+ messages on GLib's standard debug channel, so they are silent unless you ask for
201
+ them:
202
+
203
+ ```bash
204
+ G_MESSAGES_DEBUG=all GI_TYPELIB_PATH=build LD_LIBRARY_PATH=build gjs your-script.js
205
+ ```
206
+
207
+ Note GLib writes `DEBUG`-level records to **stdout** (only `WARNING` and above
208
+ go to stderr), so capture with `2>&1` — which is why `test:gate:p03`, the one
209
+ leg that asserts the teardown-ordering probe, both sets `G_MESSAGES_DEBUG=all`
210
+ and merges the streams.
211
+
212
+ Anything the shim reports **unconditionally** is a real problem, not a trace: a
213
+ `g_warning`, or the `napi_fatal_error` / unhandled-exception reports. Do not
214
+ silence those.
215
+
216
+ ### Threadsafe functions still holding claims at env teardown
217
+
218
+ The warnings an addon can provoke. Both come from `finalize_env_tsfns`
219
+ (`src/cc/tsfn.cc`) and both mean the same contract violation — **a claim that was
220
+ never handed back** — differing only in what the shim could establish about who
221
+ holds it.
222
+
223
+ At env teardown the shim closes every still-registered threadsafe function, runs
224
+ its JS-side finalization (thread finalizer, `env == NULL` queue drain, function
225
+ ref) and then frees it — but **only if no claim is outstanding**. It never frees
226
+ under a live claim: that is the use-after-free that crashed macOS at exit (#809).
227
+ When claims remain it does what Node's own `MaybeDelete()` does — releases what
228
+ it can and hands the control block to whichever thread returns the last claim,
229
+ which then frees it. If no thread ever does, the control block (a few hundred
230
+ bytes) leaks for the process lifetime. Node behaves identically.
231
+
232
+ To decide what is worth waiting for, each claim is attributed to an owner from
233
+ facts the shim can observe (`napi_acquire_threadsafe_function` takes a claim on a
234
+ known thread; a `napi_call_threadsafe_function` proves — per the ABI contract —
235
+ that its caller holds one). `initial_thread_count` claims start unattributed,
236
+ because the addon hands them out through memory the shim never sees.
237
+
238
+ * **`teardown join timed out after 2s with N claim(s) still held by foreign
239
+ thread(s)`** — a thread other than the JS thread demonstrably holds a claim and
240
+ then neither called again (a `napi_closing` return consumes the claim) nor
241
+ released for a full 2 s.
242
+ * **`closed at env teardown with N claim(s) outstanding that nothing can hand
243
+ back`** — the remainder: claims held by the JS thread itself (which is the
244
+ thread running the teardown, so they cannot drop) plus claims no thread was
245
+ ever observed to hold. The message reports the two counts separately rather
246
+ than guessing; an unattributed claim is named as unattributed.
247
+
248
+ **Release every claim before teardown** and neither fires (an
249
+ `napi_add_env_cleanup_hook` that calls `napi_release_threadsafe_function(…,
250
+ napi_tsfn_abort)` is the standard shape; it is what `@gjsify/node-gi` does, and
251
+ why it never trips this).
252
+
253
+ The 2 s deadline is not the problem, and only the first case can reach it: a
254
+ genuinely slow drain — eight producer threads each dropping their claim on the
255
+ next push — measures **tens of µs**, four orders of magnitude inside it. A claim
256
+ nothing can hand back is not joined at all, so it costs nothing.
257
+ `test/tsfn-teardown-gate.mjs` (`gjsify run test:gate:tsfn-teardown`) measures the
258
+ teardown of every claim-owner shape on each run and is the regression guard for
259
+ exactly that; `G_MESSAGES_DEBUG=all` prints the measured join time and the
260
+ disposition on every teardown that had claims to deal with.
261
+
193
262
  ## The mozjs-140 pin
194
263
 
195
264
  The pin is intrinsic and by design: the **shim** links GJS's SpiderMonkey (built
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gjsify/napi",
3
- "version": "0.22.0",
3
+ "version": "0.25.1",
4
4
  "description": "Node-API (N-API) host for GJS — implements the napi C ABI over SpiderMonkey (mozjs-140) so GJS can load unmodified compiled .node addons. The forward mirror of @gjsify/node-gi (GI in Node); this is N-API in GJS. Vala/C++ shim as .so+.gir+.typelib prebuild, loaded via imports.gi.",
5
5
  "type": "module",
6
6
  "main": "lib/esm/index.js",
@@ -22,9 +22,12 @@
22
22
  ],
23
23
  "gjsify": {
24
24
  "platforms": [
25
- "linux-x86_64",
25
+ "linux-x64",
26
26
  "darwin-arm64"
27
27
  ],
28
+ "platformsUncommitted": {
29
+ "darwin-arm64": "napi.yml's `macos` job builds, load-tests and uploads it as a workflow artifact for a release to ship; no job commits it back into this repo"
30
+ },
28
31
  "prebuilds": "prebuilds",
29
32
  "runtimes": {
30
33
  "gjs": "polyfill",
@@ -55,7 +58,7 @@
55
58
  "init:meson": "meson setup build .",
56
59
  "init:meson:wipe": "gjsify run init:meson --wipe",
57
60
  "build:meson": "gjsify run init:meson && meson compile -C build",
58
- "build:prebuilds": "gjsify run build:meson && mkdir -p prebuilds/linux-x86_64 && cp build/libgjsifynapi.so build/GjsifyNapi-1.0.gir build/GjsifyNapi-1.0.typelib prebuilds/linux-x86_64/",
61
+ "build:prebuilds": "gjsify run build:meson && node ../../../scripts/stage-prebuild.mjs .",
59
62
  "build:test-addons": "for d in test/*-addon; do (cd \"$d\" && npm exec -- node-gyp rebuild) || exit 1; done",
60
63
  "build:stubs": "python3 scripts/gen-stubs.py",
61
64
  "test:gate": "GI_TYPELIB_PATH=build LD_LIBRARY_PATH=build gjs test/load-gate.js test/hello-addon/build/Release/hello.node",
@@ -63,6 +66,7 @@
63
66
  "test:gate:p02": "GI_TYPELIB_PATH=build LD_LIBRARY_PATH=build gjs test/p02-gate.js",
64
67
  "test:gate:p03": "sh test/run-p03-gate.sh",
65
68
  "test:gate:p1": "GI_TYPELIB_PATH=build LD_LIBRARY_PATH=build gjs test/p1-tsfn-gate.js",
69
+ "test:gate:tsfn-teardown": "node test/tsfn-teardown-gate.mjs",
66
70
  "test:nodegi": "node test/nodegi-gate.mjs",
67
71
  "test:nodegi:mem": "node test/nodegi-gate.mjs --valgrind",
68
72
  "test:mem": "sh test/p03-mem.sh",
@@ -0,0 +1,19 @@
1
+ <?xml version="1.0"?>
2
+ <!-- GjsifyNapi-1.0.gir generated by valac 0.56.19, do not modify. -->
3
+ <repository version="1.2" xmlns="http://www.gtk.org/introspection/core/1.0" xmlns:c="http://www.gtk.org/introspection/c/1.0" xmlns:doc="http://www.gtk.org/introspection/doc/1.0" xmlns:glib="http://www.gtk.org/introspection/glib/1.0">
4
+ <package name="gjsifynapi"/>
5
+ <c:include name="gjsifynapi.h"/>
6
+ <doc:format name="unknown"/>
7
+ <namespace name="GjsifyNapi" version="1.0" shared-library="libgjsifynapi.dylib" c:prefix="GjsifyNapi" c:identifier-prefixes="GjsifyNapi" c:symbol-prefixes="gjsify_napi">
8
+ <function name="init" c:identifier="gjsify_napi_init">
9
+ <return-value transfer-ownership="full">
10
+ <type name="gboolean" c:type="gboolean"/>
11
+ </return-value>
12
+ </function>
13
+ <function name="is_available" c:identifier="gjsify_napi_is_available">
14
+ <return-value transfer-ownership="full">
15
+ <type name="gboolean" c:type="gboolean"/>
16
+ </return-value>
17
+ </function>
18
+ </namespace>
19
+ </repository>
package/src/cc/module.cc CHANGED
@@ -200,15 +200,22 @@ bool throw_load_error(JSContext* cx, const std::string& message) {
200
200
  // §3d env-teardown seam + P0.0 probe. Fires while the JSContext is still
201
201
  // alive (before the final GC + JS_DestroyContext) — exactly when Node-style
202
202
  // env teardown must run (§5e).
203
+ //
204
+ // The two P0.0 probe lines are DIAGNOSTICS, not events a consumer must see:
205
+ // merely loading an addon must leave stderr clean. They therefore go through
206
+ // g_debug() — the same gated channel the rest of this file's non-error
207
+ // diagnostics already use (the symbol-promotion notes below) — so GLib's
208
+ // standard `G_MESSAGES_DEBUG=all` (or the log domain) turns them back on for
209
+ // the gates that assert the teardown ordering. Real problems keep using
210
+ // g_warning and stay unconditional.
203
211
  void on_gjs_context_dispose(gpointer data, GObject* /* where_the_object_was */) {
204
212
  auto* cx = static_cast<JSContext*>(data);
205
213
  // Probe: JSRuntime-level and context-state calls must still succeed here.
206
214
  JSRuntime* rt = JS_GetRuntime(cx);
207
215
  const bool pending = JS_IsExceptionPending(cx);
208
- fprintf(stderr,
209
- "[gjsify-napi] P0.0 TEARDOWN PROBE: GjsContext weak notify fired; "
216
+ g_debug("[gjsify-napi] P0.0 TEARDOWN PROBE: GjsContext weak notify fired; "
210
217
  "JS_GetRuntime=%p exception_pending=%d — JSContext alive at the "
211
- "env-teardown seam\n",
218
+ "env-teardown seam",
212
219
  static_cast<void*>(rt), pending ? 1 : 0);
213
220
  size_t count = all_envs().size();
214
221
  // Teardown may run finalizers that trigger GC — the weak sweep callback
@@ -228,9 +235,8 @@ void on_gjs_context_dispose(gpointer data, GObject* /* where_the_object_was */)
228
235
  // JS_DestroyContext (refs/gjs/gjs/context.cpp:437,471).
229
236
  JS_RemoveWeakPointerZonesCallback(cx, gjsify_napi::weak_sweep_callback);
230
237
  loader_installed = false;
231
- fprintf(stderr,
232
- "[gjsify-napi] P0.0 TEARDOWN PROBE: %zu env(s) torn down before "
233
- "JS_DestroyContext\n",
238
+ g_debug("[gjsify-napi] P0.0 TEARDOWN PROBE: %zu env(s) torn down before "
239
+ "JS_DestroyContext",
234
240
  count);
235
241
  }
236
242
 
package/src/cc/tsfn.cc CHANGED
@@ -28,9 +28,9 @@
28
28
  // thread toggles that need this same mutex — the node-gi drain-lock
29
29
  // lesson, toggle.cc DrainTsfnCb).
30
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
31
+ // Lifetime: the struct is freed by the LAST claimant and NEVER while a claim is
32
+ // still outstanding. On the JS thread that means the dispatch finalizes only
33
+ // once thread_count has reached 0 (a natural close, OR an abort whose remaining
34
34
  // claimants have each drained their claim via a claim-consuming napi_closing
35
35
  // push/release, every one of which re-arms the dispatch as it drops the last
36
36
  // claim). This is the tsfn analog of async_work's join-before-free
@@ -43,14 +43,44 @@
43
43
  // but macOS's pthread + allocator + exit-time dylib unload turn into a crash.
44
44
  // The remaining fallback is env teardown (finalize_env_tsfns: a release(abort)
45
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.
46
+ // main context stops iterating once GjsContext dispose began) — there the JS
47
+ // thread runs the finalization itself and then DETACHES, handing ownership of
48
+ // the struct to whichever claimant drops the last claim (Node's MaybeDelete;
49
+ // see the comment above finalize_env_tsfns). ABI contract, as in Node: a thread
50
+ // must hold a claim (initial_thread_count / napi_acquire) while calling, and
51
+ // must make no call after its release — a claimless call racing the final free
52
+ // is UB in every host.
53
+ //
54
+ // CLAIM ATTRIBUTION — who holds the outstanding claims?
55
+ // `thread_count` is the ABI-visible count; on its own it cannot answer "can any
56
+ // of these still be handed back?", which is exactly the question env teardown
57
+ // has to decide. So the count is also carried as a PARTITION by owner:
58
+ // js_claims + foreign_claims + unattributed_claims == thread_count
59
+ // maintained under the same mutex, from facts the shim can observe rather than
60
+ // guess:
61
+ // • napi_acquire_threadsafe_function — the calling thread demonstrably takes a
62
+ // claim: exact attribution (js vs foreign by GThread identity).
63
+ // • napi_call_threadsafe_function — per the ABI contract the caller MUST hold
64
+ // a claim, so a call from thread T proves T owns one. The first call from
65
+ // each distinct thread therefore credits ONE initial (unattributed) claim to
66
+ // T; `attributed_threads` keeps that one-per-thread (crediting per call
67
+ // would over-attribute a single claim many times over).
68
+ // • napi_release_threadsafe_function / a claim-consuming napi_closing push —
69
+ // retires one claim from the owner's bucket.
70
+ // initial_thread_count claims start UNATTRIBUTED: they are handed out by the
71
+ // addon through memory the shim never sees, so nothing is assumed about them
72
+ // until a thread proves ownership by using the function. Guessing here is what
73
+ // #833 deliberately refused to do, and the partition keeps that honesty — an
74
+ // unattributed claim is reported as unattributed, never as "ours".
75
+ // Cost: `attribute_claim_locked` early-outs on `unattributed_claims == 0`
76
+ // (which is the steady state after every participating thread's first call), so
77
+ // the hot push path pays one predicted-not-taken branch on a word in the same
78
+ // cache line as thread_count — no g_thread_self(), no scan, no allocation.
50
79
 
51
80
  #include "common.h"
52
81
 
53
82
  #include <deque>
83
+ #include <vector>
54
84
 
55
85
  // ---- the tsfn object ----
56
86
  //
@@ -66,12 +96,25 @@ struct napi_threadsafe_function__ {
66
96
  napi_finalize finalize_cb = nullptr;
67
97
  void* finalize_data = nullptr;
68
98
  size_t max_queue_size = 0; // 0 = unbounded
99
+ // The thread that created this tsfn — the JS thread, and therefore the
100
+ // thread that will later run finalize_env_tsfns. A claim owned by it cannot
101
+ // be handed back from inside that teardown, by construction.
102
+ GThread* js_thread = nullptr;
69
103
 
70
104
  // Mutable state — guarded by mutex (any thread):
71
105
  GMutex mutex;
72
106
  GCond cond; // blocking-push space + waiter drain
73
107
  std::deque<void*> queue;
74
108
  size_t thread_count = 0;
109
+ // Owner partition of thread_count (see "CLAIM ATTRIBUTION" in the header):
110
+ // js_claims + foreign_claims + unattributed_claims == thread_count.
111
+ size_t js_claims = 0;
112
+ size_t foreign_claims = 0;
113
+ size_t unattributed_claims = 0;
114
+ // Threads already credited with one of the initial claims. Bounded by
115
+ // initial_thread_count (each entry costs one unattributed claim) and
116
+ // released the moment the last one is credited.
117
+ std::vector<GThread*> attributed_threads;
75
118
  size_t waiters = 0; // threads inside the blocking-push wait
76
119
  // No new pushes accepted: release(abort), the dispatch's natural-close
77
120
  // decision, or env teardown. Node's `is_closing`.
@@ -80,6 +123,14 @@ struct napi_threadsafe_function__ {
80
123
  // guard — at most ONE pending wake, exactly uv_async semantics.
81
124
  GSource* dispatch_source = nullptr;
82
125
  bool referenced = true; // napi_unref bookkeeping (see note below)
126
+ // finalize_env_tsfns is running this tsfn's JS-side finalization; a
127
+ // dispatch that somehow reaches the loop meanwhile must not free under it.
128
+ bool finalizing = false;
129
+ // Env teardown finished the JS-side finalization while claims were still
130
+ // outstanding and handed ownership of this struct to the last claimant
131
+ // (Node's MaybeDelete). The env and the main context are gone: whoever
132
+ // drops the last claim deletes directly instead of arming a dispatch.
133
+ bool detached = false;
83
134
 
84
135
  napi_threadsafe_function__() {
85
136
  g_mutex_init(&mutex);
@@ -118,6 +169,87 @@ void schedule_dispatch_locked(napi_threadsafe_function tsfn) {
118
169
  g_source_attach(src, tsfn->main_context);
119
170
  }
120
171
 
172
+ // ---- claim attribution (see "CLAIM ATTRIBUTION" in the file header) ----
173
+ //
174
+ // All three run under tsfn->mutex and only move counters between the buckets of
175
+ // the owner partition; they never touch thread_count itself, so they cannot
176
+ // perturb the ABI-visible claim count.
177
+
178
+ // A call from `self` PROVES (ABI contract) that `self` holds a claim. Credit it
179
+ // one of the still-unattributed initial claims — once per thread, or a single
180
+ // claim would be attributed to every thread that ever pushes.
181
+ //
182
+ // Hot path: this is called on every napi_call_threadsafe_function, so the
183
+ // early-out is the point — once every participating thread has pushed once,
184
+ // `unattributed_claims` is 0 forever after and the whole function is one branch.
185
+ inline void attribute_claim_locked(napi_threadsafe_function tsfn) {
186
+ if (tsfn->unattributed_claims == 0) {
187
+ return;
188
+ }
189
+ GThread* self = g_thread_self();
190
+ for (GThread* seen : tsfn->attributed_threads) {
191
+ if (seen == self) {
192
+ return;
193
+ }
194
+ }
195
+ tsfn->attributed_threads.push_back(self);
196
+ tsfn->unattributed_claims--;
197
+ if (self == tsfn->js_thread) {
198
+ tsfn->js_claims++;
199
+ } else {
200
+ tsfn->foreign_claims++;
201
+ }
202
+ if (tsfn->unattributed_claims == 0) {
203
+ // Nothing left to credit — drop the bookkeeping and the branch above
204
+ // becomes the only cost for the rest of this tsfn's life.
205
+ tsfn->attributed_threads.clear();
206
+ tsfn->attributed_threads.shrink_to_fit();
207
+ }
208
+ }
209
+
210
+ // napi_acquire_threadsafe_function: the calling thread takes a NEW claim, so
211
+ // this attribution is exact rather than inferred. Caller already did
212
+ // thread_count++.
213
+ inline void take_claim_locked(napi_threadsafe_function tsfn) {
214
+ if (g_thread_self() == tsfn->js_thread) {
215
+ tsfn->js_claims++;
216
+ } else {
217
+ tsfn->foreign_claims++;
218
+ }
219
+ }
220
+
221
+ // A release, or a claim-consuming napi_closing push, retires one claim. Caller
222
+ // already did thread_count--; this keeps the partition summing to it. Prefer the
223
+ // caller's own bucket, then the unattributed pool (the caller proved it held a
224
+ // claim, so an unattributed one was its), then whatever is left — the partition
225
+ // must never drift from thread_count even if an addon violates the contract.
226
+ inline void retire_claim_locked(napi_threadsafe_function tsfn) {
227
+ if (tsfn->js_claims == 0 && tsfn->unattributed_claims == 0) {
228
+ // Steady state for a fully-attributed foreign fan-out: no need to ask
229
+ // which thread we are.
230
+ if (tsfn->foreign_claims > 0) {
231
+ tsfn->foreign_claims--;
232
+ }
233
+ return;
234
+ }
235
+ if (g_thread_self() == tsfn->js_thread) {
236
+ if (tsfn->js_claims > 0) {
237
+ tsfn->js_claims--;
238
+ return;
239
+ }
240
+ } else if (tsfn->foreign_claims > 0) {
241
+ tsfn->foreign_claims--;
242
+ return;
243
+ }
244
+ if (tsfn->unattributed_claims > 0) {
245
+ tsfn->unattributed_claims--;
246
+ } else if (tsfn->js_claims > 0) {
247
+ tsfn->js_claims--;
248
+ } else if (tsfn->foreign_claims > 0) {
249
+ tsfn->foreign_claims--;
250
+ }
251
+ }
252
+
121
253
  // Node CallJs (node_api.cc): the default call_js when the creator passed none.
122
254
  void default_call_js(napi_env env, napi_value cb, void* /* context */,
123
255
  void* /* data */) {
@@ -158,12 +290,14 @@ void invoke_call_js(napi_threadsafe_function tsfn, void* item) {
158
290
  napi_close_handle_scope(env, scope);
159
291
  }
160
292
 
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) {
293
+ // JS-thread finalization WITHOUT the free (Node Finalize → EmptyQueue +
294
+ // finalize_cb + ReleaseResources): run the thread finalizer with JS available,
295
+ // drain leftover queue items with env == NULL so their data can be freed, drop
296
+ // the function ref, unregister from the env. Caller must have set `closing` and
297
+ // destroyed any pending dispatch source under the lock. Everything that needs a
298
+ // live env happens here; what remains afterwards is only GLib-owned memory,
299
+ // which is why the struct itself can outlive the env (see finalize_env_tsfns).
300
+ void finalize_tsfn_resources(napi_threadsafe_function tsfn) {
167
301
  napi_env env = tsfn->env;
168
302
  const bool can_call =
169
303
  env != nullptr && env->can_call_into_js && !env->torn_down;
@@ -204,6 +338,12 @@ void finalize_tsfn(napi_threadsafe_function tsfn) {
204
338
  }
205
339
  }
206
340
  }
341
+ }
342
+
343
+ // The full JS-thread finalization (Node CloseHandlesAndMaybeDelete → Finalize →
344
+ // EmptyQueueAndDelete). ONLY legal with thread_count == 0 — see the file header.
345
+ void finalize_tsfn(napi_threadsafe_function tsfn) {
346
+ finalize_tsfn_resources(tsfn);
207
347
  delete tsfn;
208
348
  }
209
349
 
@@ -222,6 +362,14 @@ gboolean dispatch_cb(gpointer data) {
222
362
  g_source_unref(tsfn->dispatch_source);
223
363
  tsfn->dispatch_source = nullptr;
224
364
  }
365
+ // Env teardown owns this tsfn: it is either mid-finalization on this very
366
+ // thread (a nested loop iteration from inside finalize_cb would otherwise
367
+ // free the struct under it) or already detached to its last claimant.
368
+ // Either way this dispatch has nothing left to do.
369
+ if (tsfn->finalizing || tsfn->detached) {
370
+ g_mutex_unlock(&tsfn->mutex);
371
+ return G_SOURCE_REMOVE;
372
+ }
225
373
  unsigned iterations_left = kMaxDispatchIterations;
226
374
  for (;;) {
227
375
  // Finalize (free) ONLY once thread_count == 0 — no foreign thread can
@@ -274,57 +422,158 @@ gboolean dispatch_cb(gpointer data) {
274
422
 
275
423
  } // namespace
276
424
 
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.
425
+ // Env teardown step 1b (env.cc): close every tsfn still registered. The
426
+ // release(abort) node-gi issues from its env cleanup hook schedules a dispatch
427
+ // that can never run — GjsContext dispose has begun, the main context will not
428
+ // be iterated again — so the deferred finalization is executed HERE,
429
+ // synchronously, while JS is still fully callable (§5e steps 1-4 discipline).
430
+ //
431
+ // Three steps, in this order:
432
+ //
433
+ // 1. JOIN the claims that CAN still be handed back. `foreign_claims` is the
434
+ // subset of thread_count that some OTHER thread has demonstrably taken
435
+ // (§CLAIM ATTRIBUTION in the file header) — each of those drains on its
436
+ // owner's next napi_closing push or release, so waiting for it is what lets
437
+ // the struct be freed here instead of leaked. Claims held by THIS thread,
438
+ // and claims no thread has ever been observed to hold, are NOT waited on:
439
+ // the first cannot drop while this thread is blocked in the wait, and for
440
+ // the second there is no evidence any thread will ever come back for it.
441
+ // That distinction is the whole point — before it, an unreturnable claim
442
+ // cost a deterministic full-deadline stall on a legal (if sloppy) shape.
443
+ // 2. Run the JS-side finalization (thread finalizer, env==NULL queue drain,
444
+ // function ref, env deregistration) — everything that needs a live env.
445
+ // 3. Free the struct if no claim is left; otherwise DETACH it.
446
+ //
447
+ // DETACH is Node's own posture, not an invention here: node_api.cc's
448
+ // `MaybeDelete()` bails out with `if (thread_count > 0) { ReleaseResources();
449
+ // return; }` — "we need to keep it alive for other threads that still have
450
+ // pointers to it until they release them" — and the last claimant's Release()/
451
+ // Push() runs `delete this` instead. So Node force-CLOSES at env teardown but
452
+ // never force-FREES under a live claim, and neither do we: `detached` tells the
453
+ // last claimant to delete directly rather than arm a dispatch on a main context
454
+ // that is gone. The consequence for a claim nobody ever hands back is a leaked
455
+ // control block at process exit — again exactly Node's, and strictly better than
456
+ // the force-free it replaces, which reopened the pre-#809 use-after-free window
457
+ // (benign on Linux/glibc, a hard crash at exit on macOS).
458
+ //
459
+ // THE WARNINGS ARE NOT NOISE — DO NOT GATE OR REMOVE THEM. They mark a real
460
+ // contract violation with a real consequence, and they are what keeps the
461
+ // attribution honest: a claim this function cannot attribute is REPORTED as
462
+ // unattributed, never quietly assumed to be ours.
463
+ // • join timed out → a foreign thread proved it holds a claim and then neither
464
+ // called again nor released for a full 2 s.
465
+ // • claims left that nothing can hand back → the JS thread's own claims plus
466
+ // any never-attributed initial claim; the struct leaks.
467
+ // A clean teardown (every claim released, or every foreign claim drained inside
468
+ // the deadline) says nothing at all.
469
+ //
470
+ // Measured (linux-x64, gjs 1.88, tsfn-addon driven into env teardown with claims
471
+ // outstanding — 3 runs each, wall time of the whole gjs process):
472
+ // • every claim owned by a FOREIGN thread that drains on its next napi_closing
473
+ // push (the shutdown-conformance shape): the join completes in tens of µs,
474
+ // four orders of magnitude inside the 2 s deadline — the deadline is NOT too
475
+ // tight and widening it would buy nothing.
476
+ // • a claim nothing hands back (the creating thread's own initial claim, or an
477
+ // unattributed one): BEFORE, a deterministic 2048 / 2090 / 2137 ms stall,
478
+ // then warn + force-free. AFTER, it is not joined at all — see
479
+ // test/tsfn-teardown-gate.mjs, which measures both shapes on every run.
295
480
  void finalize_env_tsfns(napi_env env) {
296
481
  while (!env->tsfns.empty()) {
297
482
  napi_threadsafe_function tsfn = env->tsfns.back();
483
+
298
484
  g_mutex_lock(&tsfn->mutex);
299
485
  // Stop accepting pushes + wake blocking pushers so they observe it.
300
486
  tsfn->closing = true;
487
+ tsfn->finalizing = true;
301
488
  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;
489
+ // A dispatch armed before dispose can no longer run — drop it now so it
490
+ // cannot fire into the finalization below.
491
+ if (tsfn->dispatch_source != nullptr) {
492
+ g_source_destroy(tsfn->dispatch_source);
493
+ g_source_unref(tsfn->dispatch_source);
494
+ tsfn->dispatch_source = nullptr;
495
+ }
496
+ // Step 1 — join only the claims a foreign thread demonstrably holds.
497
+ const size_t foreign_at_entry = tsfn->foreign_claims;
498
+ const gint64 join_started = g_get_monotonic_time();
499
+ const gint64 deadline = join_started + 2 * G_TIME_SPAN_SECOND;
306
500
  bool joined = true;
307
- while (tsfn->thread_count > 0) {
501
+ while (tsfn->foreign_claims > 0) {
308
502
  if (!g_cond_wait_until(&tsfn->cond, &tsfn->mutex, deadline)) {
309
503
  joined = false;
310
504
  break;
311
505
  }
312
506
  }
507
+ const gint64 join_us = g_get_monotonic_time() - join_started;
508
+ const size_t stuck_foreign = tsfn->foreign_claims;
509
+ g_mutex_unlock(&tsfn->mutex);
510
+
313
511
  if (!joined) {
314
512
  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);
513
+ "after 2s with %zu claim(s) still held by foreign "
514
+ "thread(s) that neither called nor released; the function "
515
+ "is closed and its JS resources are freed, but its control "
516
+ "block is handed to whichever thread hands the last claim "
517
+ "back — and leaks if none ever does. Release every claim "
518
+ "before env teardown (an napi_add_env_cleanup_hook calling "
519
+ "napi_release_threadsafe_function(…, napi_tsfn_abort) is "
520
+ "the standard shape)",
521
+ stuck_foreign);
522
+ } else if (foreign_at_entry > 0) {
523
+ g_debug("gjsify-napi: threadsafe-function teardown joined %zu "
524
+ "foreign claim(s) in %" G_GINT64_FORMAT " us "
525
+ "(deadline 2000000 us)",
526
+ foreign_at_entry, join_us);
318
527
  }
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.
528
+
529
+ // Step 2 — everything that needs a live env. Runs JS (the thread
530
+ // finalizer), so it must NOT hold the lock, and `detached` must not be
531
+ // set yet: a claimant that drops the last claim meanwhile has to leave
532
+ // the struct alone until we are done with it.
533
+ finalize_tsfn_resources(tsfn); // removes it from env->tsfns
534
+
535
+ // Step 3 — free, or detach to the last claimant.
536
+ g_mutex_lock(&tsfn->mutex);
537
+ tsfn->finalizing = false;
538
+ // A claimant that reached 0 during step 2 may have armed a dispatch that
539
+ // can no longer run (dispose has begun) — destroy it before the free.
321
540
  if (tsfn->dispatch_source != nullptr) {
322
541
  g_source_destroy(tsfn->dispatch_source);
323
542
  g_source_unref(tsfn->dispatch_source);
324
543
  tsfn->dispatch_source = nullptr;
325
544
  }
545
+ if (tsfn->thread_count == 0) {
546
+ g_mutex_unlock(&tsfn->mutex);
547
+ delete tsfn;
548
+ continue;
549
+ }
550
+ const size_t own = tsfn->js_claims;
551
+ const size_t unattributed = tsfn->unattributed_claims;
552
+ const size_t outstanding = tsfn->thread_count;
553
+ tsfn->detached = true;
554
+ tsfn->env = nullptr; // the env is about to stop being callable
326
555
  g_mutex_unlock(&tsfn->mutex);
327
- finalize_tsfn(tsfn); // removes it from env->tsfns
556
+
557
+ if (own > 0 || unattributed > 0) {
558
+ g_warning("gjsify-napi: threadsafe-function closed at env teardown "
559
+ "with %zu of %zu outstanding claim(s) that nothing can "
560
+ "hand back: %zu held by the thread running this teardown, "
561
+ "%zu never attributed to any thread (typically an "
562
+ "initial_thread_count claim the creating thread never "
563
+ "released). The function is closed and its JS resources "
564
+ "are freed; its control block is handed to the last "
565
+ "claimant and therefore leaks for the process lifetime. "
566
+ "Release every claim before env teardown (an "
567
+ "napi_add_env_cleanup_hook calling "
568
+ "napi_release_threadsafe_function(…, napi_tsfn_abort) is "
569
+ "the standard shape)",
570
+ own + unattributed, outstanding, own, unattributed);
571
+ } else {
572
+ g_debug("gjsify-napi: threadsafe-function detached at env teardown "
573
+ "with %zu foreign claim(s) outstanding; the last claimant "
574
+ "frees it",
575
+ outstanding);
576
+ }
328
577
  }
329
578
  }
330
579
 
@@ -375,6 +624,10 @@ napi_status NAPI_CDECL napi_create_threadsafe_function(
375
624
  tsfn->finalize_data = thread_finalize_data;
376
625
  tsfn->max_queue_size = max_queue_size;
377
626
  tsfn->thread_count = initial_thread_count;
627
+ // The addon hands the initial claims out through memory the shim never
628
+ // sees, so none of them is attributed until a thread proves it holds one.
629
+ tsfn->unattributed_claims = initial_thread_count;
630
+ tsfn->js_thread = g_thread_self();
378
631
  env->tsfns.push_back(tsfn);
379
632
 
380
633
  *result = tsfn;
@@ -391,6 +644,10 @@ napi_status NAPI_CDECL napi_call_threadsafe_function(
391
644
  return napi_invalid_arg;
392
645
  }
393
646
  g_mutex_lock(&func->mutex);
647
+ // This call PROVES the caller holds a claim (ABI contract) — credit it one
648
+ // of the still-unattributed initial claims. Early-outs to a single branch
649
+ // once every participating thread has been seen; see the file header.
650
+ gjsify_napi::attribute_claim_locked(func);
394
651
  while (func->max_queue_size > 0 &&
395
652
  func->queue.size() >= func->max_queue_size && !func->closing) {
396
653
  if (is_blocking == napi_tsfn_nonblocking) {
@@ -411,13 +668,24 @@ napi_status NAPI_CDECL napi_call_threadsafe_function(
411
668
  return napi_invalid_arg;
412
669
  }
413
670
  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.
671
+ gjsify_napi::retire_claim_locked(func);
672
+ // This closing push just consumed the caller's claim. If it was the LAST
673
+ // one AND env teardown already detached the struct, we own it now: free
674
+ // directly (no dispatch — the main context is gone). Otherwise arm the
675
+ // JS-thread dispatch so the deferred finalize runs now that no thread can
676
+ // re-enter (the free half of the abort-path fix — dispatch_cb frees only
677
+ // at thread_count == 0; coalesces if pending).
678
+ if (func->thread_count == 0 && func->detached) {
679
+ g_mutex_unlock(&func->mutex);
680
+ delete func;
681
+ return napi_closing;
682
+ }
419
683
  if (func->thread_count == 0) {
420
684
  gjsify_napi::schedule_dispatch_locked(func);
685
+ }
686
+ // Wake a teardown join (finalize_env_tsfns): it waits on the FOREIGN
687
+ // claims, which can reach 0 well before thread_count does.
688
+ if (func->thread_count == 0 || func->foreign_claims == 0) {
421
689
  g_cond_broadcast(&func->cond);
422
690
  }
423
691
  g_mutex_unlock(&func->mutex);
@@ -443,10 +711,19 @@ napi_status NAPI_CDECL napi_release_threadsafe_function(
443
711
  return napi_invalid_arg;
444
712
  }
445
713
  func->thread_count--;
714
+ gjsify_napi::retire_claim_locked(func);
446
715
  if (mode == napi_tsfn_abort && !func->closing) {
447
716
  func->closing = true;
448
717
  g_cond_broadcast(&func->cond); // wake blocking pushers
449
718
  }
719
+ // Env teardown already ran the finalization and handed the struct to its
720
+ // last claimant — that is us, so free it directly (the main context is gone,
721
+ // so a dispatch would never run).
722
+ if (func->thread_count == 0 && func->detached) {
723
+ g_mutex_unlock(&func->mutex);
724
+ delete func;
725
+ return napi_ok;
726
+ }
450
727
  // Arm the JS-thread dispatch when this drops the last claim (natural close,
451
728
  // OR the deferred abort-path finalize once every claimant has drained) or
452
729
  // when an abort just closed the tsfn (so a still-populated queue reaches its
@@ -455,8 +732,9 @@ napi_status NAPI_CDECL napi_release_threadsafe_function(
455
732
  if (func->thread_count == 0 || mode == napi_tsfn_abort) {
456
733
  gjsify_napi::schedule_dispatch_locked(func);
457
734
  }
458
- if (func->thread_count == 0) {
459
- // Wake a teardown JOIN (finalize_env_tsfns) waiting on the last claim.
735
+ // Wake a teardown join (finalize_env_tsfns): it waits on the FOREIGN claims,
736
+ // which can reach 0 well before thread_count does.
737
+ if (func->thread_count == 0 || func->foreign_claims == 0) {
460
738
  g_cond_broadcast(&func->cond);
461
739
  }
462
740
  g_mutex_unlock(&func->mutex);
@@ -527,6 +805,8 @@ napi_status NAPI_CDECL napi_acquire_threadsafe_function(
527
805
  return napi_closing;
528
806
  }
529
807
  func->thread_count++;
808
+ // The one claim whose owner is known exactly rather than inferred.
809
+ gjsify_napi::take_claim_locked(func);
530
810
  g_mutex_unlock(&func->mutex);
531
811
  return napi_ok;
532
812
  }