@napi-rs/cli 3.10.0 → 3.10.2
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 +445 -26
- package/dist/index.cjs +445 -26
- package/dist/index.d.cts +1 -1
- package/dist/index.js +445 -26
- package/docs/wasi.md +17 -0
- package/package.json +1 -1
- package/src/api/__tests__/__snapshots__/templates.spec.ts.md +1492 -86
- package/src/api/__tests__/__snapshots__/templates.spec.ts.snap +0 -0
- package/src/api/__tests__/templates.spec.ts +223 -0
- package/src/api/templates/load-wasi-template.ts +473 -36
- package/src/utils/__tests__/reconciliation.spec.ts +9 -2
|
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')
|
|
@@ -900,6 +1005,124 @@ function initializationRollbackBody(code: string): string {
|
|
|
900
1005
|
return code.slice(deferredStart, code.indexOf('throw error', deferredStart))
|
|
901
1006
|
}
|
|
902
1007
|
|
|
1008
|
+
/**
|
|
1009
|
+
* The body of `__startWasiDisposal`, sliced so that "the drain runs on the
|
|
1010
|
+
* disposal path" cannot be satisfied by a call somewhere else in the file.
|
|
1011
|
+
*/
|
|
1012
|
+
function disposalStartBody(code: string): string {
|
|
1013
|
+
const start = code.indexOf(DISPOSAL_START_SIGNATURE)
|
|
1014
|
+
return start === -1 ? '' : code.slice(start, code.indexOf('\n}', start))
|
|
1015
|
+
}
|
|
1016
|
+
|
|
1017
|
+
const DISPOSAL_START_SIGNATURE = 'function __startWasiDisposal() {'
|
|
1018
|
+
|
|
1019
|
+
/**
|
|
1020
|
+
* `napi_async_work` is the one thing the settlement barrier above does not
|
|
1021
|
+
* cover. The eager loaders drain it from the shared prelude; the
|
|
1022
|
+
* deferred/workerd loader carries its own per-instance lifecycle and drains it
|
|
1023
|
+
* there, so both are asserted — just against different function names.
|
|
1024
|
+
*/
|
|
1025
|
+
const eagerWasiLoaderCases = wasiLoaderCases.filter(({ code }) =>
|
|
1026
|
+
code.includes(EAGER_ROLLBACK_SIGNATURE),
|
|
1027
|
+
)
|
|
1028
|
+
const deferredWasiLoaderCases = wasiLoaderCases.filter(
|
|
1029
|
+
({ code }) => !code.includes(EAGER_ROLLBACK_SIGNATURE),
|
|
1030
|
+
)
|
|
1031
|
+
|
|
1032
|
+
test('every WASI loader case is either eager or deferred', (t) => {
|
|
1033
|
+
t.is(
|
|
1034
|
+
eagerWasiLoaderCases.length + deferredWasiLoaderCases.length,
|
|
1035
|
+
wasiLoaderCases.length,
|
|
1036
|
+
)
|
|
1037
|
+
t.true(deferredWasiLoaderCases.length >= 2)
|
|
1038
|
+
})
|
|
1039
|
+
|
|
1040
|
+
for (const { name, code } of deferredWasiLoaderCases) {
|
|
1041
|
+
test(`deferred WASI loader drains outstanding async work before teardown: ${name}`, (t) => {
|
|
1042
|
+
t.true(
|
|
1043
|
+
code.includes('napi_wasm_async_work_pending'),
|
|
1044
|
+
'the deferred loader instantiates the same async-work plugin, so it strands the same work',
|
|
1045
|
+
)
|
|
1046
|
+
t.true(
|
|
1047
|
+
code.includes('napi_wasm_cancel_pending_async_work'),
|
|
1048
|
+
'threadless work is always queued rather than executing, so cancellation is what bounds the wait',
|
|
1049
|
+
)
|
|
1050
|
+
t.regex(
|
|
1051
|
+
code,
|
|
1052
|
+
/typeof __pending !== 'function' \|\|\s*typeof __cancelPending !== 'function'/,
|
|
1053
|
+
'loader must feature-detect both exports',
|
|
1054
|
+
)
|
|
1055
|
+
// Per instance, from that instance's own exports: two instances have
|
|
1056
|
+
// separate registries and must not wait on each other.
|
|
1057
|
+
t.true(
|
|
1058
|
+
code.includes('__drainInstanceAsyncWork(__napiInstance)'),
|
|
1059
|
+
'the drain must read the disposing instance, not a module-global one',
|
|
1060
|
+
)
|
|
1061
|
+
// Both teardown paths destroy the environment those completions need.
|
|
1062
|
+
const disposal = code.slice(
|
|
1063
|
+
code.indexOf('const __runInstanceDisposal'),
|
|
1064
|
+
code.indexOf('let __instanceDisposePromise'),
|
|
1065
|
+
)
|
|
1066
|
+
t.true(
|
|
1067
|
+
disposal.includes('__drainInstanceAsyncWork'),
|
|
1068
|
+
'per-instance disposal must drain outstanding async work',
|
|
1069
|
+
)
|
|
1070
|
+
t.true(
|
|
1071
|
+
initializationRollbackBody(code).includes('__drainInstanceAsyncWork'),
|
|
1072
|
+
'the initialization-failure path must drain it too',
|
|
1073
|
+
)
|
|
1074
|
+
})
|
|
1075
|
+
}
|
|
1076
|
+
|
|
1077
|
+
test('the eager loader cases are the ones that share the disposal prelude', (t) => {
|
|
1078
|
+
// Guards the filter above: a prelude change that stopped emitting the eager
|
|
1079
|
+
// rollback would silently empty the loop below instead of failing.
|
|
1080
|
+
t.true(eagerWasiLoaderCases.length >= 4)
|
|
1081
|
+
})
|
|
1082
|
+
|
|
1083
|
+
for (const { name, code } of eagerWasiLoaderCases) {
|
|
1084
|
+
test(`WASI loader drains outstanding async work before teardown: ${name}`, (t) => {
|
|
1085
|
+
t.true(
|
|
1086
|
+
code.includes('napi_wasm_async_work_pending'),
|
|
1087
|
+
'loader must poll the addon for outstanding async work; nothing about it is observable from JavaScript in a threaded build',
|
|
1088
|
+
)
|
|
1089
|
+
t.true(
|
|
1090
|
+
code.includes('napi_wasm_cancel_pending_async_work'),
|
|
1091
|
+
'loader must cancel work that has not started, or disposal waits for the whole queue instead of only what is running',
|
|
1092
|
+
)
|
|
1093
|
+
// Both exports are optional, exactly like the settlement handshake: an
|
|
1094
|
+
// addon built against a napi crate that predates them must keep loading and
|
|
1095
|
+
// disposing as it does today.
|
|
1096
|
+
t.regex(
|
|
1097
|
+
code,
|
|
1098
|
+
/typeof pending !== 'function' \|\| typeof cancelPending !== 'function'/,
|
|
1099
|
+
'loader must feature-detect both exports',
|
|
1100
|
+
)
|
|
1101
|
+
// The wait is a real referenced timer, not a macrotask spin: the addon is
|
|
1102
|
+
// polled, so a zero-delay turn would burn the loop instead of yielding it.
|
|
1103
|
+
t.true(
|
|
1104
|
+
code.includes(
|
|
1105
|
+
'__scheduleTimer(resolve, __WASI_ASYNC_WORK_POLL_INTERVAL_MS)',
|
|
1106
|
+
),
|
|
1107
|
+
'the async-work wait must yield with a real timer',
|
|
1108
|
+
)
|
|
1109
|
+
t.true(
|
|
1110
|
+
disposalStartBody(code).includes('__drainWasiAsyncWork'),
|
|
1111
|
+
'disposal must drain outstanding async work',
|
|
1112
|
+
)
|
|
1113
|
+
// Ordering is the whole point: the completion callbacks run addon code, and
|
|
1114
|
+
// the barrier, `Context.destroy()` and the termination each take that away.
|
|
1115
|
+
t.false(
|
|
1116
|
+
disposalStartBody(code).includes('__prepareWasmEnvCleanup'),
|
|
1117
|
+
'the async-work drain must run before the barrier, not beside it',
|
|
1118
|
+
)
|
|
1119
|
+
t.true(
|
|
1120
|
+
initializationRollbackBody(code).includes('__drainWasiAsyncWork'),
|
|
1121
|
+
'initialization rollback tears down the same things and needs the same drain',
|
|
1122
|
+
)
|
|
1123
|
+
})
|
|
1124
|
+
}
|
|
1125
|
+
|
|
903
1126
|
// The loaders order their own teardown barrier-then-destroy, but the emnapi
|
|
904
1127
|
// context is a live object: an embedder or test harness holding it, or emnapi's
|
|
905
1128
|
// own `beforeExit` auto-destroy on a host where `suppressDestroy()` is absent,
|