@napi-rs/cli 3.10.0 → 3.10.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/docs/wasi.md CHANGED
@@ -336,6 +336,23 @@ Consumers that need deterministic cleanup before process exit should use the
336
336
  deferred loader and call its `dispose()` function, or the `dispose` returned by
337
337
  `createInstance()`.
338
338
 
339
+ Every termination goes through the emnapi thread manager
340
+ (`PThread.terminateWorker`) rather than a bare `worker.terminate()`. The manager
341
+ counts a worker exit as expected only for terminations it performed itself and
342
+ reports any other one as a worker failure — a throw that lands inside Node.js's
343
+ `exit` emit and strands the promise the termination depends on. The manager is
344
+ captured while the emnapi module is being created, before the wasm is loaded, so
345
+ it is reachable even from the initialization rollback — the one path where
346
+ instantiation never returned.
347
+
348
+ Disposal holds the event loop open with a timer of its own until the
349
+ terminations settle, so `await dispose()` resolves even as the last statement of
350
+ a script. The pool workers cannot do that themselves: they are unreferenced so
351
+ an idle binding never keeps a process alive, and emnapi unreferences them again
352
+ whenever one reports that its thread is ready. The timer is released the moment
353
+ the terminations settle, and a binding that is never disposed still does not
354
+ hold an idle process open.
355
+
339
356
  When type generation is disabled, the generated browser root exposes the
340
357
  binding as its default export. `napi new` also removes the template's
341
358
  `index.d.ts` and declaration metadata instead of publishing stale template
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@napi-rs/cli",
3
- "version": "3.10.0",
3
+ "version": "3.10.1",
4
4
  "description": "Cli tools for napi-rs",
5
5
  "author": "LongYinan <lynweklm@gmail.com>",
6
6
  "homepage": "https://napi.rs/",
@@ -101,6 +101,35 @@ Generated by [AVA](https://avajs.dev).
101
101
  ␊
102
102
  const __wasiDisposeSymbol = Symbol.for('napi.rs.wasi.dispose')␊
103
103
  const __wasiWorkers = new Set()␊
104
+ // The thread manager has to be reachable *before* anything that can throw␊
105
+ // during load or registration. Initialization can fail after the pool has␊
106
+ // already spawned workers, and the rollback still has to mark their␊
107
+ // terminations as expected — but \`__napiModule\` is assigned only when␊
108
+ // instantiation RETURNS, so on exactly that path it is still undefined. A␊
109
+ // plugin factory runs while the emnapi module is being created, before the␊
110
+ // wasm is loaded and before any registration function runs, and its context␊
111
+ // carries the very same manager instance.␊
112
+ let __wasiThreadManager␊
113
+ ␊
114
+ function __captureWasiThreadManager(context) {␊
115
+ if (context && context.PThread) {␊
116
+ __wasiThreadManager = context.PThread␊
117
+ }␊
118
+ return {}␊
119
+ }␊
120
+ ␊
121
+ function __getWasiThreadManager() {␊
122
+ const manager =␊
123
+ __wasiThreadManager !== undefined␊
124
+ ? __wasiThreadManager␊
125
+ : __napiModule␊
126
+ ? __napiModule.PThread␊
127
+ : undefined␊
128
+ if (manager && typeof manager.terminateWorker === 'function') {␊
129
+ return manager␊
130
+ }␊
131
+ return undefined␊
132
+ }␊
104
133
  let __napiInstance␊
105
134
  let __emnapiContextDestroyed = false␊
106
135
  let __emnapiContextDestroyPromise␊
@@ -423,13 +452,80 @@ Generated by [AVA](https://avajs.dev).
423
452
  return destroyPromise␊
424
453
  }␊
425
454
  ␊
455
+ /**␊
456
+ * Holds the event loop open until \`work\` settles.␊
457
+ *␊
458
+ * Nothing else can: the pool workers are deliberately unreferenced so an idle␊
459
+ * binding cannot keep a process alive, and referencing them again for the␊
460
+ * termination does not hold either — emnapi unreferences a worker the moment it␊
461
+ * reports \`async-thread-ready\`, which for a worker that was still starting␊
462
+ * lands *after* the termination began. Without a handle of its own, an␊
463
+ * \`await dispose()\` with nothing else pending exits the process with its␊
464
+ * promise unsettled, and everything after the \`await\` is skipped.␊
465
+ *␊
466
+ * The timer is cleared as soon as the work settles, so this never outlives the␊
467
+ * disposal that asked for it.␊
468
+ */␊
469
+ function __keepEventLoopAliveUntil(work) {␊
470
+ const setTimer = globalThis.setInterval␊
471
+ const clearTimer = globalThis.clearInterval␊
472
+ if (typeof setTimer !== 'function' || typeof clearTimer !== 'function') {␊
473
+ return work␊
474
+ }␊
475
+ let timer␊
476
+ try {␊
477
+ timer = setTimer(function () {}, 50)␊
478
+ } catch {␊
479
+ return work␊
480
+ }␊
481
+ const release = function () {␊
482
+ try {␊
483
+ clearTimer(timer)␊
484
+ } catch {}␊
485
+ }␊
486
+ return work.then(␊
487
+ (value) => {␊
488
+ release()␊
489
+ return value␊
490
+ },␊
491
+ (error) => {␊
492
+ release()␊
493
+ throw error␊
494
+ },␊
495
+ )␊
496
+ }␊
497
+ ␊
498
+ /**␊
499
+ * \`@emnapi/wasi-threads\` counts a worker exit as expected only when its own␊
500
+ * thread manager performed the termination. A bare \`worker.terminate()\` reaches␊
501
+ * the manager's \`exit\` listener instead, which reports␊
502
+ * \`worker (tid = N) sent an error! ... stopped with exit code 1\` and rethrows␊
503
+ * inside the emit — aborting the \`once('exit')\` that backs the terminate␊
504
+ * promise, so disposal never settles and the process dies with an uncaught␊
505
+ * exception. Mark the termination through the manager first.␊
506
+ *␊
507
+ * The manager comes from \`__getWasiThreadManager\`, not from \`__napiModule\`:␊
508
+ * the initialization rollback runs on the one path where instantiation never␊
509
+ * returned, so \`__napiModule\` is still undefined there while the workers it␊
510
+ * spawned are already registered and loaded.␊
511
+ *␊
512
+ * Not \`terminateAllThreads()\`: that one recreates the pool it just shut down.␊
513
+ */␊
426
514
  function __terminateWasiWorkers() {␊
427
515
  const cleanupErrors = []␊
428
516
  const pending = []␊
517
+ const threadManager = __getWasiThreadManager()␊
429
518
  ␊
430
519
  for (const worker of __wasiWorkers) {␊
431
520
  let result␊
432
521
  try {␊
522
+ if (threadManager) {␊
523
+ threadManager.terminateWorker(worker)␊
524
+ // \`terminateWorker\` leaves behind a reporter that logs every message␊
525
+ // still queued on the port, which Node flushes on exit. Nothing is␊
526
+ // listening for those any more.␊
527
+ worker.onmessage = undefined␊
528
+ }␊
433
529
  result = worker.terminate()␊
434
530
  } catch (error) {␊
435
531
  cleanupErrors.push(error)␊
@@ -459,7 +555,9 @@ Generated by [AVA](https://avajs.dev).
459
555
  )␊
460
556
  }␊
461
557
  }␊
462
- return pending.length > 0 ? Promise.all(pending).then(finish) : finish()␊
558
+ return pending.length > 0␊
559
+ ? __keepEventLoopAliveUntil(Promise.all(pending)).then(finish)␊
560
+ : finish()␊
463
561
  }␊
464
562
  ␊
465
563
  function __finishWasiDisposal() {␊
@@ -675,7 +773,7 @@ Generated by [AVA](https://avajs.dev).
675
773
  context: __emnapiContext,␊
676
774
  asyncWorkPoolSize: __asyncWorkPoolSize,␊
677
775
  reuseWorker: { size: __asyncWorkPoolSize + __workerPoolSize },␊
678
- plugins: [__emnapiAsyncWorkPlugin, __emnapiTSFNPlugin],␊
776
+ plugins: [__captureWasiThreadManager, __emnapiAsyncWorkPlugin, __emnapiTSFNPlugin],␊
679
777
  wasi: __wasi,␊
680
778
  onCreateWorker() {␊
681
779
  const worker = new Worker(new URL('./wasi-worker-browser.mjs', import.meta.url), {␊
@@ -814,6 +912,35 @@ Generated by [AVA](https://avajs.dev).
814
912
  ␊
815
913
  const __wasiDisposeSymbol = Symbol.for('napi.rs.wasi.dispose')␊
816
914
  const __wasiWorkers = new Set()␊
915
+ // The thread manager has to be reachable *before* anything that can throw␊
916
+ // during load or registration. Initialization can fail after the pool has␊
917
+ // already spawned workers, and the rollback still has to mark their␊
918
+ // terminations as expected — but \`__napiModule\` is assigned only when␊
919
+ // instantiation RETURNS, so on exactly that path it is still undefined. A␊
920
+ // plugin factory runs while the emnapi module is being created, before the␊
921
+ // wasm is loaded and before any registration function runs, and its context␊
922
+ // carries the very same manager instance.␊
923
+ let __wasiThreadManager␊
924
+ ␊
925
+ function __captureWasiThreadManager(context) {␊
926
+ if (context && context.PThread) {␊
927
+ __wasiThreadManager = context.PThread␊
928
+ }␊
929
+ return {}␊
930
+ }␊
931
+ ␊
932
+ function __getWasiThreadManager() {␊
933
+ const manager =␊
934
+ __wasiThreadManager !== undefined␊
935
+ ? __wasiThreadManager␊
936
+ : __napiModule␊
937
+ ? __napiModule.PThread␊
938
+ : undefined␊
939
+ if (manager && typeof manager.terminateWorker === 'function') {␊
940
+ return manager␊
941
+ }␊
942
+ return undefined␊
943
+ }␊
817
944
  let __napiInstance␊
818
945
  let __emnapiContextDestroyed = false␊
819
946
  let __emnapiContextDestroyPromise␊
@@ -1136,13 +1263,80 @@ Generated by [AVA](https://avajs.dev).
1136
1263
  return destroyPromise␊
1137
1264
  }␊
1138
1265
  ␊
1266
+ /**␊
1267
+ * Holds the event loop open until \`work\` settles.␊
1268
+ *␊
1269
+ * Nothing else can: the pool workers are deliberately unreferenced so an idle␊
1270
+ * binding cannot keep a process alive, and referencing them again for the␊
1271
+ * termination does not hold either — emnapi unreferences a worker the moment it␊
1272
+ * reports \`async-thread-ready\`, which for a worker that was still starting␊
1273
+ * lands *after* the termination began. Without a handle of its own, an␊
1274
+ * \`await dispose()\` with nothing else pending exits the process with its␊
1275
+ * promise unsettled, and everything after the \`await\` is skipped.␊
1276
+ *␊
1277
+ * The timer is cleared as soon as the work settles, so this never outlives the␊
1278
+ * disposal that asked for it.␊
1279
+ */␊
1280
+ function __keepEventLoopAliveUntil(work) {␊
1281
+ const setTimer = globalThis.setInterval␊
1282
+ const clearTimer = globalThis.clearInterval␊
1283
+ if (typeof setTimer !== 'function' || typeof clearTimer !== 'function') {␊
1284
+ return work␊
1285
+ }␊
1286
+ let timer␊
1287
+ try {␊
1288
+ timer = setTimer(function () {}, 50)␊
1289
+ } catch {␊
1290
+ return work␊
1291
+ }␊
1292
+ const release = function () {␊
1293
+ try {␊
1294
+ clearTimer(timer)␊
1295
+ } catch {}␊
1296
+ }␊
1297
+ return work.then(␊
1298
+ (value) => {␊
1299
+ release()␊
1300
+ return value␊
1301
+ },␊
1302
+ (error) => {␊
1303
+ release()␊
1304
+ throw error␊
1305
+ },␊
1306
+ )␊
1307
+ }␊
1308
+ ␊
1309
+ /**␊
1310
+ * \`@emnapi/wasi-threads\` counts a worker exit as expected only when its own␊
1311
+ * thread manager performed the termination. A bare \`worker.terminate()\` reaches␊
1312
+ * the manager's \`exit\` listener instead, which reports␊
1313
+ * \`worker (tid = N) sent an error! ... stopped with exit code 1\` and rethrows␊
1314
+ * inside the emit — aborting the \`once('exit')\` that backs the terminate␊
1315
+ * promise, so disposal never settles and the process dies with an uncaught␊
1316
+ * exception. Mark the termination through the manager first.␊
1317
+ *␊
1318
+ * The manager comes from \`__getWasiThreadManager\`, not from \`__napiModule\`:␊
1319
+ * the initialization rollback runs on the one path where instantiation never␊
1320
+ * returned, so \`__napiModule\` is still undefined there while the workers it␊
1321
+ * spawned are already registered and loaded.␊
1322
+ *␊
1323
+ * Not \`terminateAllThreads()\`: that one recreates the pool it just shut down.␊
1324
+ */␊
1139
1325
  function __terminateWasiWorkers() {␊
1140
1326
  const cleanupErrors = []␊
1141
1327
  const pending = []␊
1328
+ const threadManager = __getWasiThreadManager()␊
1142
1329
  ␊
1143
1330
  for (const worker of __wasiWorkers) {␊
1144
1331
  let result␊
1145
1332
  try {␊
1333
+ if (threadManager) {␊
1334
+ threadManager.terminateWorker(worker)␊
1335
+ // \`terminateWorker\` leaves behind a reporter that logs every message␊
1336
+ // still queued on the port, which Node flushes on exit. Nothing is␊
1337
+ // listening for those any more.␊
1338
+ worker.onmessage = undefined␊
1339
+ }␊
1146
1340
  result = worker.terminate()␊
1147
1341
  } catch (error) {␊
1148
1342
  cleanupErrors.push(error)␊
@@ -1172,7 +1366,9 @@ Generated by [AVA](https://avajs.dev).
1172
1366
  )␊
1173
1367
  }␊
1174
1368
  }␊
1175
- return pending.length > 0 ? Promise.all(pending).then(finish) : finish()␊
1369
+ return pending.length > 0␊
1370
+ ? __keepEventLoopAliveUntil(Promise.all(pending)).then(finish)␊
1371
+ : finish()␊
1176
1372
  }␊
1177
1373
  ␊
1178
1374
  function __finishWasiDisposal() {␊
@@ -1388,7 +1584,7 @@ Generated by [AVA](https://avajs.dev).
1388
1584
  context: __emnapiContext,␊
1389
1585
  asyncWorkPoolSize: __asyncWorkPoolSize,␊
1390
1586
  reuseWorker: { size: __asyncWorkPoolSize + __workerPoolSize },␊
1391
- plugins: [__emnapiAsyncWorkPlugin, __emnapiTSFNPlugin],␊
1587
+ plugins: [__captureWasiThreadManager, __emnapiAsyncWorkPlugin, __emnapiTSFNPlugin],␊
1392
1588
  wasi: __wasi,␊
1393
1589
  onCreateWorker() {␊
1394
1590
  const worker = new Worker(new URL('./wasi-worker-browser.mjs', import.meta.url), {␊
@@ -1546,6 +1742,35 @@ Generated by [AVA](https://avajs.dev).
1546
1742
  ␊
1547
1743
  const __wasiDisposeSymbol = Symbol.for('napi.rs.wasi.dispose')␊
1548
1744
  const __wasiWorkers = new Set()␊
1745
+ // The thread manager has to be reachable *before* anything that can throw␊
1746
+ // during load or registration. Initialization can fail after the pool has␊
1747
+ // already spawned workers, and the rollback still has to mark their␊
1748
+ // terminations as expected — but \`__napiModule\` is assigned only when␊
1749
+ // instantiation RETURNS, so on exactly that path it is still undefined. A␊
1750
+ // plugin factory runs while the emnapi module is being created, before the␊
1751
+ // wasm is loaded and before any registration function runs, and its context␊
1752
+ // carries the very same manager instance.␊
1753
+ let __wasiThreadManager␊
1754
+ ␊
1755
+ function __captureWasiThreadManager(context) {␊
1756
+ if (context && context.PThread) {␊
1757
+ __wasiThreadManager = context.PThread␊
1758
+ }␊
1759
+ return {}␊
1760
+ }␊
1761
+ ␊
1762
+ function __getWasiThreadManager() {␊
1763
+ const manager =␊
1764
+ __wasiThreadManager !== undefined␊
1765
+ ? __wasiThreadManager␊
1766
+ : __napiModule␊
1767
+ ? __napiModule.PThread␊
1768
+ : undefined␊
1769
+ if (manager && typeof manager.terminateWorker === 'function') {␊
1770
+ return manager␊
1771
+ }␊
1772
+ return undefined␊
1773
+ }␊
1549
1774
  let __napiInstance␊
1550
1775
  let __emnapiContextDestroyed = false␊
1551
1776
  let __emnapiContextDestroyPromise␊
@@ -1868,13 +2093,80 @@ Generated by [AVA](https://avajs.dev).
1868
2093
  return destroyPromise␊
1869
2094
  }␊
1870
2095
  ␊
2096
+ /**␊
2097
+ * Holds the event loop open until \`work\` settles.␊
2098
+ *␊
2099
+ * Nothing else can: the pool workers are deliberately unreferenced so an idle␊
2100
+ * binding cannot keep a process alive, and referencing them again for the␊
2101
+ * termination does not hold either — emnapi unreferences a worker the moment it␊
2102
+ * reports \`async-thread-ready\`, which for a worker that was still starting␊
2103
+ * lands *after* the termination began. Without a handle of its own, an␊
2104
+ * \`await dispose()\` with nothing else pending exits the process with its␊
2105
+ * promise unsettled, and everything after the \`await\` is skipped.␊
2106
+ *␊
2107
+ * The timer is cleared as soon as the work settles, so this never outlives the␊
2108
+ * disposal that asked for it.␊
2109
+ */␊
2110
+ function __keepEventLoopAliveUntil(work) {␊
2111
+ const setTimer = globalThis.setInterval␊
2112
+ const clearTimer = globalThis.clearInterval␊
2113
+ if (typeof setTimer !== 'function' || typeof clearTimer !== 'function') {␊
2114
+ return work␊
2115
+ }␊
2116
+ let timer␊
2117
+ try {␊
2118
+ timer = setTimer(function () {}, 50)␊
2119
+ } catch {␊
2120
+ return work␊
2121
+ }␊
2122
+ const release = function () {␊
2123
+ try {␊
2124
+ clearTimer(timer)␊
2125
+ } catch {}␊
2126
+ }␊
2127
+ return work.then(␊
2128
+ (value) => {␊
2129
+ release()␊
2130
+ return value␊
2131
+ },␊
2132
+ (error) => {␊
2133
+ release()␊
2134
+ throw error␊
2135
+ },␊
2136
+ )␊
2137
+ }␊
2138
+ ␊
2139
+ /**␊
2140
+ * \`@emnapi/wasi-threads\` counts a worker exit as expected only when its own␊
2141
+ * thread manager performed the termination. A bare \`worker.terminate()\` reaches␊
2142
+ * the manager's \`exit\` listener instead, which reports␊
2143
+ * \`worker (tid = N) sent an error! ... stopped with exit code 1\` and rethrows␊
2144
+ * inside the emit — aborting the \`once('exit')\` that backs the terminate␊
2145
+ * promise, so disposal never settles and the process dies with an uncaught␊
2146
+ * exception. Mark the termination through the manager first.␊
2147
+ *␊
2148
+ * The manager comes from \`__getWasiThreadManager\`, not from \`__napiModule\`:␊
2149
+ * the initialization rollback runs on the one path where instantiation never␊
2150
+ * returned, so \`__napiModule\` is still undefined there while the workers it␊
2151
+ * spawned are already registered and loaded.␊
2152
+ *␊
2153
+ * Not \`terminateAllThreads()\`: that one recreates the pool it just shut down.␊
2154
+ */␊
1871
2155
  function __terminateWasiWorkers() {␊
1872
2156
  const cleanupErrors = []␊
1873
2157
  const pending = []␊
2158
+ const threadManager = __getWasiThreadManager()␊
1874
2159
  ␊
1875
2160
  for (const worker of __wasiWorkers) {␊
1876
2161
  let result␊
1877
2162
  try {␊
2163
+ if (threadManager) {␊
2164
+ threadManager.terminateWorker(worker)␊
2165
+ // \`terminateWorker\` leaves behind a reporter that logs every message␊
2166
+ // still queued on the port, which Node flushes on exit. Nothing is␊
2167
+ // listening for those any more.␊
2168
+ worker.onmessage = undefined␊
2169
+ }␊
1878
2170
  result = worker.terminate()␊
1879
2171
  } catch (error) {␊
1880
2172
  cleanupErrors.push(error)␊
@@ -1904,7 +2196,9 @@ Generated by [AVA](https://avajs.dev).
1904
2196
  )␊
1905
2197
  }␊
1906
2198
  }␊
1907
- return pending.length > 0 ? Promise.all(pending).then(finish) : finish()␊
2199
+ return pending.length > 0␊
2200
+ ? __keepEventLoopAliveUntil(Promise.all(pending)).then(finish)␊
2201
+ : finish()␊
1908
2202
  }␊
1909
2203
  ␊
1910
2204
  function __finishWasiDisposal() {␊
@@ -2120,7 +2414,7 @@ Generated by [AVA](https://avajs.dev).
2120
2414
  context: __emnapiContext,␊
2121
2415
  asyncWorkPoolSize: __asyncWorkPoolSize,␊
2122
2416
  reuseWorker: { size: __asyncWorkPoolSize + __workerPoolSize },␊
2123
- plugins: [__emnapiAsyncWorkPlugin, __emnapiTSFNPlugin],␊
2417
+ plugins: [__captureWasiThreadManager, __emnapiAsyncWorkPlugin, __emnapiTSFNPlugin],␊
2124
2418
  wasi: __wasi,␊
2125
2419
  onCreateWorker() {␊
2126
2420
  const worker = new Worker(new URL('./wasi-worker-browser.mjs', import.meta.url), {␊
@@ -2266,6 +2560,35 @@ Generated by [AVA](https://avajs.dev).
2266
2560
  ␊
2267
2561
  const __wasiDisposeSymbol = Symbol.for('napi.rs.wasi.dispose')␊
2268
2562
  const __wasiWorkers = new Set()␊
2563
+ // The thread manager has to be reachable *before* anything that can throw␊
2564
+ // during load or registration. Initialization can fail after the pool has␊
2565
+ // already spawned workers, and the rollback still has to mark their␊
2566
+ // terminations as expected — but \`__napiModule\` is assigned only when␊
2567
+ // instantiation RETURNS, so on exactly that path it is still undefined. A␊
2568
+ // plugin factory runs while the emnapi module is being created, before the␊
2569
+ // wasm is loaded and before any registration function runs, and its context␊
2570
+ // carries the very same manager instance.␊
2571
+ let __wasiThreadManager␊
2572
+ ␊
2573
+ function __captureWasiThreadManager(context) {␊
2574
+ if (context && context.PThread) {␊
2575
+ __wasiThreadManager = context.PThread␊
2576
+ }␊
2577
+ return {}␊
2578
+ }␊
2579
+ ␊
2580
+ function __getWasiThreadManager() {␊
2581
+ const manager =␊
2582
+ __wasiThreadManager !== undefined␊
2583
+ ? __wasiThreadManager␊
2584
+ : __napiModule␊
2585
+ ? __napiModule.PThread␊
2586
+ : undefined␊
2587
+ if (manager && typeof manager.terminateWorker === 'function') {␊
2588
+ return manager␊
2589
+ }␊
2590
+ return undefined␊
2591
+ }␊
2269
2592
  let __napiInstance␊
2270
2593
  let __emnapiContextDestroyed = false␊
2271
2594
  let __emnapiContextDestroyPromise␊
@@ -2619,13 +2942,80 @@ Generated by [AVA](https://avajs.dev).
2619
2942
  return destroyPromise␊
2620
2943
  }␊
2621
2944
  ␊
2945
+ /**␊
2946
+ * Holds the event loop open until \`work\` settles.␊
2947
+ *␊
2948
+ * Nothing else can: the pool workers are deliberately unreferenced so an idle␊
2949
+ * binding cannot keep a process alive, and referencing them again for the␊
2950
+ * termination does not hold either — emnapi unreferences a worker the moment it␊
2951
+ * reports \`async-thread-ready\`, which for a worker that was still starting␊
2952
+ * lands *after* the termination began. Without a handle of its own, an␊
2953
+ * \`await dispose()\` with nothing else pending exits the process with its␊
2954
+ * promise unsettled, and everything after the \`await\` is skipped.␊
2955
+ *␊
2956
+ * The timer is cleared as soon as the work settles, so this never outlives the␊
2957
+ * disposal that asked for it.␊
2958
+ */␊
2959
+ function __keepEventLoopAliveUntil(work) {␊
2960
+ const setTimer = globalThis.setInterval␊
2961
+ const clearTimer = globalThis.clearInterval␊
2962
+ if (typeof setTimer !== 'function' || typeof clearTimer !== 'function') {␊
2963
+ return work␊
2964
+ }␊
2965
+ let timer␊
2966
+ try {␊
2967
+ timer = setTimer(function () {}, 50)␊
2968
+ } catch {␊
2969
+ return work␊
2970
+ }␊
2971
+ const release = function () {␊
2972
+ try {␊
2973
+ clearTimer(timer)␊
2974
+ } catch {}␊
2975
+ }␊
2976
+ return work.then(␊
2977
+ (value) => {␊
2978
+ release()␊
2979
+ return value␊
2980
+ },␊
2981
+ (error) => {␊
2982
+ release()␊
2983
+ throw error␊
2984
+ },␊
2985
+ )␊
2986
+ }␊
2987
+ ␊
2988
+ /**␊
2989
+ * \`@emnapi/wasi-threads\` counts a worker exit as expected only when its own␊
2990
+ * thread manager performed the termination. A bare \`worker.terminate()\` reaches␊
2991
+ * the manager's \`exit\` listener instead, which reports␊
2992
+ * \`worker (tid = N) sent an error! ... stopped with exit code 1\` and rethrows␊
2993
+ * inside the emit — aborting the \`once('exit')\` that backs the terminate␊
2994
+ * promise, so disposal never settles and the process dies with an uncaught␊
2995
+ * exception. Mark the termination through the manager first.␊
2996
+ *␊
2997
+ * The manager comes from \`__getWasiThreadManager\`, not from \`__napiModule\`:␊
2998
+ * the initialization rollback runs on the one path where instantiation never␊
2999
+ * returned, so \`__napiModule\` is still undefined there while the workers it␊
3000
+ * spawned are already registered and loaded.␊
3001
+ *␊
3002
+ * Not \`terminateAllThreads()\`: that one recreates the pool it just shut down.␊
3003
+ */␊
2622
3004
  function __terminateWasiWorkers() {␊
2623
3005
  const cleanupErrors = []␊
2624
3006
  const pending = []␊
3007
+ const threadManager = __getWasiThreadManager()␊
2625
3008
  ␊
2626
3009
  for (const worker of __wasiWorkers) {␊
2627
3010
  let result␊
2628
3011
  try {␊
3012
+ if (threadManager) {␊
3013
+ threadManager.terminateWorker(worker)␊
3014
+ // \`terminateWorker\` leaves behind a reporter that logs every message␊
3015
+ // still queued on the port, which Node flushes on exit. Nothing is␊
3016
+ // listening for those any more.␊
3017
+ worker.onmessage = undefined␊
3018
+ }␊
2629
3019
  result = worker.terminate()␊
2630
3020
  } catch (error) {␊
2631
3021
  cleanupErrors.push(error)␊
@@ -2655,7 +3045,9 @@ Generated by [AVA](https://avajs.dev).
2655
3045
  )␊
2656
3046
  }␊
2657
3047
  }␊
2658
- return pending.length > 0 ? Promise.all(pending).then(finish) : finish()␊
3048
+ return pending.length > 0␊
3049
+ ? __keepEventLoopAliveUntil(Promise.all(pending)).then(finish)␊
3050
+ : finish()␊
2659
3051
  }␊
2660
3052
  ␊
2661
3053
  function __finishWasiDisposal() {␊
@@ -2870,7 +3262,7 @@ Generated by [AVA](https://avajs.dev).
2870
3262
  } = __emnapiInstantiateNapiModuleSync(__wasmFile, {␊
2871
3263
  context: __emnapiContext,␊
2872
3264
  asyncWorkPoolSize: 0,␊
2873
- plugins: [__emnapiAsyncWorkPlugin, __emnapiTSFNPlugin],␊
3265
+ plugins: [__captureWasiThreadManager, __emnapiAsyncWorkPlugin, __emnapiTSFNPlugin],␊
2874
3266
  wasi: __wasi,␊
2875
3267
  overwriteImports(importObject) {␊
2876
3268
  importObject.env = {␊
@@ -492,6 +492,111 @@ for (const { name, code, install } of asyncRuntimeLoaderCases) {
492
492
  })
493
493
  }
494
494
 
495
+ // `@emnapi/wasi-threads` records a worker exit as expected only when ITS thread
496
+ // manager performed the termination. A bare `worker.terminate()` reaches the
497
+ // manager's own 'exit' listener, which reports the exit as a worker failure and
498
+ // rethrows inside the emit — aborting the `once('exit')` that backs the
499
+ // terminate promise, so `dispose()` never settles.
500
+ for (const { name, code } of [
501
+ { name: 'node cjs', code: createWasiBinding('test', '@scope/test') },
502
+ { name: 'browser esm', code: createWasiBrowserBinding('test') },
503
+ ]) {
504
+ test(`pool workers are terminated through the thread manager: ${name}`, (t) => {
505
+ const start = code.indexOf('function __terminateWasiWorkers() {')
506
+ t.true(start > 0)
507
+ const body = code.slice(
508
+ start,
509
+ code.indexOf('function __finishWasiDisposal() {'),
510
+ )
511
+ const mark = body.indexOf('threadManager.terminateWorker(worker)')
512
+ const terminate = body.indexOf('result = worker.terminate()')
513
+ t.true(terminate > 0)
514
+ t.true(mark > 0, 'the termination has to be marked on the thread manager')
515
+ t.true(mark < terminate, 'and marked before the worker is terminated')
516
+ // Not `terminateAllThreads()`: it recreates the pool it just shut down.
517
+ t.false(body.includes('terminateAllThreads'))
518
+ // `terminateWorker` leaves a reporter behind that logs every message still
519
+ // queued on the port, which Node flushes on exit.
520
+ t.true(body.includes('worker.onmessage = undefined'))
521
+ // The manager is resolved through the helper, not read off `__napiModule`:
522
+ // the rollback runs on the one path where that binding was never assigned.
523
+ t.true(body.includes('const threadManager = __getWasiThreadManager()'))
524
+ t.false(body.includes('__napiModule.PThread'))
525
+ })
526
+ }
527
+
528
+ // The pool workers are unreferenced on purpose, and emnapi unreferences them
529
+ // again when one reports `async-thread-ready`, so a pending termination has no
530
+ // handle of its own to hold the loop open with.
531
+ for (const { name, code } of [
532
+ { name: 'node cjs', code: createWasiBinding('test', '@scope/test') },
533
+ { name: 'browser esm', code: createWasiBrowserBinding('test') },
534
+ ]) {
535
+ test(`a pending termination holds the event loop open: ${name}`, (t) => {
536
+ const body = code.slice(
537
+ code.indexOf('function __terminateWasiWorkers() {'),
538
+ code.indexOf('function __finishWasiDisposal() {'),
539
+ )
540
+ t.true(
541
+ body.includes(
542
+ '__keepEventLoopAliveUntil(Promise.all(pending)).then(finish)',
543
+ ),
544
+ 'the terminate promises have to be awaited under a keep-alive',
545
+ )
546
+ // …and the keep-alive is released the moment the work settles, so it can
547
+ // never outlive the disposal that asked for it.
548
+ const keepAlive = code.slice(
549
+ code.indexOf('function __keepEventLoopAliveUntil(work) {'),
550
+ )
551
+ t.true(keepAlive.indexOf('clearTimer(timer)') > 0)
552
+ t.true(keepAlive.indexOf('release()') < keepAlive.indexOf('return value'))
553
+ // Nothing puts the stubbed `ref` functions back: doing so is what raced
554
+ // emnapi's own unreference.
555
+ t.false(code.includes('__wasiWorkerRefRestorers'))
556
+ t.false(code.includes('__restoreWasiWorkerRef'))
557
+ })
558
+ }
559
+
560
+ test('the node loader keeps its pool workers unreferenced for life', (t) => {
561
+ const code = createWasiBinding('test', '@scope/test')
562
+ t.true(code.includes('worker[kPublicPort].ref = () => {}'))
563
+ t.true(code.includes('worker[kHandle].ref = () => {}'))
564
+ t.true(code.includes('worker.unref()'))
565
+ // An idle binding must not hold the process open, and disposal does not
566
+ // reverse that — `__keepEventLoopAliveUntil` covers the termination instead.
567
+ t.false(code.includes('.ref = publicPortRef'))
568
+ t.false(code.includes('.ref = handleRef'))
569
+ })
570
+
571
+ // `examples/custom-async-runtime` asserts a threadless loader never mentions
572
+ // `Worker`, so nothing in the *shared* prelude may name the class — comments
573
+ // included. That lane needs a wasm build to fail; this does not.
574
+ for (const { name, code } of [
575
+ {
576
+ name: 'node cjs threadless',
577
+ code: createWasiBinding('test', '@scope/test', 4000, 65536, false),
578
+ },
579
+ {
580
+ name: 'browser esm threadless',
581
+ code: createWasiBrowserBinding(
582
+ 'test',
583
+ 4000,
584
+ 65536,
585
+ false,
586
+ false,
587
+ false,
588
+ false,
589
+ false,
590
+ ),
591
+ },
592
+ { name: 'deferred/workerd', code: createWasiDeferredBrowserBinding('test') },
593
+ ]) {
594
+ test(`threadless loaders never name Worker: ${name}`, (t) => {
595
+ t.notRegex(code, /\bWorker\b/)
596
+ t.notRegex(code, /node:worker_threads/)
597
+ })
598
+ }
599
+
495
600
  test('asyncRuntime deferred loader registers per instance', (t) => {
496
601
  const code = asyncRuntimeDeferredCode
497
602
  assertValidJS(t, code, 'deferred asyncRuntime')