@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.
@@ -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,