@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/dist/cli.js +108 -6
- package/dist/index.cjs +108 -6
- package/dist/index.d.cts +2 -2
- package/dist/index.d.ts +2 -2
- package/dist/index.js +108 -6
- package/docs/wasi.md +17 -0
- package/package.json +1 -1
- package/src/api/__tests__/__snapshots__/templates.spec.ts.md +400 -8
- package/src/api/__tests__/__snapshots__/templates.spec.ts.snap +0 -0
- package/src/api/__tests__/templates.spec.ts +105 -0
- package/src/api/templates/load-wasi-template.ts +106 -4
- package/src/utils/__tests__/reconciliation.spec.ts +9 -2
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
|
@@ -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
|
|
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
|
|
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
|
|
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
|
|
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 = {␊
|
|
Binary file
|
|
@@ -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')
|