@napi-rs/cli 3.10.1 → 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.
@@ -1005,6 +1005,124 @@ function initializationRollbackBody(code: string): string {
1005
1005
  return code.slice(deferredStart, code.indexOf('throw error', deferredStart))
1006
1006
  }
1007
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
+
1008
1126
  // The loaders order their own teardown barrier-then-destroy, but the emnapi
1009
1127
  // context is a live object: an embedder or test harness holding it, or emnapi's
1010
1128
  // own `beforeExit` auto-destroy on a host where `suppressDestroy()` is absent,
@@ -167,6 +167,7 @@ let __emnapiWasmEnvCleanupRan = false
167
167
  let __emnapiWasmEnvCleanupDrained = false
168
168
  let __emnapiWasmEnvCleanupDrainPromise
169
169
  let __wasiDisposed = false
170
+ let __wasiAsyncWorkDrainPromise
170
171
  let __wasiDisposePromise
171
172
  let __completeWasiDisposal = function () {}
172
173
  // Overridden by loader flavors that have a last-resort reclaim for a rollback
@@ -291,6 +292,24 @@ const __scheduleMacrotask = (function () {
291
292
  }
292
293
  })()
293
294
 
295
+ // A real, *referenced* timer, for waits that must let the whole host make
296
+ // progress between looks — the async-work drain polls the addon rather than
297
+ // interleaving with the @emnapi/core dispatch, so a zero-delay macrotask there
298
+ // would spin the loop instead of yielding it. Falls back to the macrotask
299
+ // scheduler on a host without timers.
300
+ function __scheduleTimer(callback, delay) {
301
+ const setTimer = globalThis.setTimeout
302
+ if (typeof setTimer !== 'function') {
303
+ __scheduleMacrotask(callback)
304
+ return
305
+ }
306
+ try {
307
+ setTimer(callback, delay)
308
+ } catch {
309
+ __scheduleMacrotask(callback)
310
+ }
311
+ }
312
+
294
313
  // Turns to wait for while the addon still reports queued settlements. Reaching
295
314
  // zero is the only success. A counter still nonzero at this bound rejects the
296
315
  // disposal as retryable (\`ERR_NAPI_WASI_CLEANUP_PENDING\`) rather than
@@ -491,6 +510,135 @@ function __keepEventLoopAliveUntil(work) {
491
510
  )
492
511
  }
493
512
 
513
+ // How often to re-read \`napi_wasm_async_work_pending\` while waiting. The wait
514
+ // ends when the addon reports zero, so this only decides how promptly disposal
515
+ // notices — not how long it waits.
516
+ const __WASI_ASYNC_WORK_POLL_INTERVAL_MS = 1
517
+
518
+ /**
519
+ * Settles this addon's outstanding \`napi_async_work\` before the teardown that
520
+ * would strand it.
521
+ *
522
+ * \`napi_prepare_wasm_env_cleanup\` does not cover async work, and nothing about
523
+ * it is observable from JavaScript: the threadless archive resolves
524
+ * \`napi_*_async_work\` through the \`@emnapi/core\` plugins, but the threaded one
525
+ * links the C \`async_work.c\` on the uv threadpool, so there the wasm neither
526
+ * imports nor exports those symbols and the only brackets a loader could watch
527
+ * (\`_emnapi_ctx_*_waiting_request_counter\`) are shared with threadsafe
528
+ * functions. The addon is the one place both flavors go through, so it answers
529
+ * for both, through the same kind of handshake the settlement drain uses:
530
+ *
531
+ * - \`napi_wasm_cancel_pending_async_work()\` cancels what no thread has
532
+ * started. Those completion callbacks run with \`napi_cancelled\`, which
533
+ * napi-rs turns into a promise rejected with an \`AbortError\`.
534
+ * - \`napi_wasm_async_work_pending()\` counts what is still owed a completion
535
+ * callback. Work already executing refuses cancellation and stays counted
536
+ * until it finishes normally — which it can, because this runs before the
537
+ * barrier, before \`Context.destroy()\` and before anything is terminated.
538
+ *
539
+ * Both exports are optional: an addon built against a napi crate that predates
540
+ * them drains nothing and keeps the previous behavior, exactly as the
541
+ * \`napi_wasm_env_cleanup_pending\` handshake degrades.
542
+ *
543
+ * Returns nothing when there is nothing outstanding, which keeps disposal
544
+ * synchronous in the common case. The promise it returns otherwise never
545
+ * rejects.
546
+ *
547
+ * The wait has no deadline, and that is the point: giving up would destroy the
548
+ * environment with a completion callback still owed, which is the stranding
549
+ * this exists to prevent. A task whose \`execute\` never returns already keeps an
550
+ * *undisposed* process alive in exactly the same way, so disposal inherits that
551
+ * rather than inventing a bound it cannot honor.
552
+ *
553
+ * Safe to call from inside a completion callback, which is reachable: settling
554
+ * a task runs addon code that can re-enter JavaScript — a setter on the value
555
+ * being handed back, a threadsafe-function callback — and that JavaScript can
556
+ * call \`dispose()\`. Two things make it terminate rather than wait on itself:
557
+ *
558
+ * - The addon keeps a work registered until its completion callback
559
+ * *finishes*, so the count read here is at least one and this takes the
560
+ * polling path instead of declaring the environment drained and tearing it
561
+ * down from inside the frame that is still settling a promise.
562
+ * - The poll is a timer, so it cannot run until the callback has returned to
563
+ * the host — by which time that work has left the registry. The count the
564
+ * next poll reads is the one taken after the callback finished.
565
+ *
566
+ * \`__disposeWasiBinding\` hands every caller the same in-flight promise, so the
567
+ * nested call joins this disposal rather than starting a second one.
568
+ */
569
+ function __drainWasiAsyncWork() {
570
+ if (__wasiAsyncWorkDrainPromise !== undefined) {
571
+ return __wasiAsyncWorkDrainPromise
572
+ }
573
+ const exports = __napiInstance?.exports
574
+ const pending = exports?.napi_wasm_async_work_pending
575
+ const cancelPending = exports?.napi_wasm_cancel_pending_async_work
576
+ if (typeof pending !== 'function' || typeof cancelPending !== 'function') {
577
+ return
578
+ }
579
+
580
+ const readPending = () => {
581
+ try {
582
+ return pending()
583
+ } catch (error) {
584
+ // A trap is the only way this call fails: it reads a counter and cannot
585
+ // allocate or call back into JavaScript. A trapped instance can no longer
586
+ // run anything, so its outstanding work is unreachable by definition —
587
+ // there is nothing left to wait for, and refusing to dispose would only
588
+ // keep a dead instance and its stuck counter alive. Best-effort here is
589
+ // the honest answer, and it is what disposal did before this drain
590
+ // existed.
591
+ //
592
+ // Only a trap. Anything else means the export is not what this loader
593
+ // thinks it is, which is a defect worth surfacing rather than disposing
594
+ // over.
595
+ if (error instanceof globalThis.WebAssembly.RuntimeError) {
596
+ return 0
597
+ }
598
+ throw error
599
+ }
600
+ }
601
+
602
+ if (!readPending()) {
603
+ return
604
+ }
605
+ try {
606
+ cancelPending()
607
+ } catch {
608
+ // Cancellation is an optimization: it bounds the wait by the work already
609
+ // executing. Failing it only means waiting for the whole queue instead.
610
+ }
611
+ if (!readPending()) {
612
+ return
613
+ }
614
+
615
+ const drainPromise = __keepEventLoopAliveUntil(
616
+ (async () => {
617
+ while (readPending()) {
618
+ await new Promise((resolve) => {
619
+ __scheduleTimer(resolve, __WASI_ASYNC_WORK_POLL_INTERVAL_MS)
620
+ })
621
+ }
622
+ })(),
623
+ ).then(
624
+ () => {
625
+ __wasiAsyncWorkDrainPromise = undefined
626
+ },
627
+ (error) => {
628
+ // A wait that could not run is not a wait that finished. The only way
629
+ // here is a host whose timers and macrotask primitives all refuse, and
630
+ // the work is still outstanding — reporting success would destroy the
631
+ // environment over it, which is the stranding this exists to prevent.
632
+ // Reject instead: disposal stays retryable, and the context is not
633
+ // destroyed. Clearing the memo first is what makes the retry re-run this.
634
+ __wasiAsyncWorkDrainPromise = undefined
635
+ throw error
636
+ },
637
+ )
638
+ __wasiAsyncWorkDrainPromise = drainPromise
639
+ return drainPromise
640
+ }
641
+
494
642
  /**
495
643
  * \`@emnapi/wasi-threads\` counts a worker exit as expected only when its own
496
644
  * thread manager performed the termination. A bare \`worker.terminate()\` reaches
@@ -572,7 +720,7 @@ function __continueWasiDisposal() {
572
720
  return __finishWasiDisposal()
573
721
  }
574
722
 
575
- function __startWasiDisposal() {
723
+ function __cleanUpWasmEnvForWasiDisposal() {
576
724
  // Run the pre-teardown barrier, then let the settlements it queued actually
577
725
  // reach JavaScript, and only then destroy the environment. Doing these two
578
726
  // back to back is what strands them.
@@ -584,6 +732,21 @@ function __startWasiDisposal() {
584
732
  return __continueWasiDisposal()
585
733
  }
586
734
 
735
+ function __startWasiDisposal() {
736
+ // Outstanding \`napi_async_work\` goes first, while the environment is still
737
+ // completely live: the completion callbacks run addon code, and everything
738
+ // after this point takes that away from them — the barrier shuts the async
739
+ // runtime down, \`Context.destroy()\` stops JavaScript calls, and terminating
740
+ // the pool threads removes what would have reported the work finished.
741
+ const asyncWorkResult = __drainWasiAsyncWork()
742
+ if (__isThenable(asyncWorkResult)) {
743
+ return Promise.resolve(asyncWorkResult).then(
744
+ __cleanUpWasmEnvForWasiDisposal,
745
+ )
746
+ }
747
+ return __cleanUpWasmEnvForWasiDisposal()
748
+ }
749
+
587
750
  /**
588
751
  * Disposes this generated WASI binding.
589
752
  *
@@ -725,29 +888,60 @@ function __retainFailedWasiRollback(cleanupErrors) {
725
888
  * bug with no upper bound, while the retained bookkeeping is bounded by the page.
726
889
  */
727
890
  function __rollbackWasiInitialization() {
728
- const cleanupErrors = []
729
- let drainResult
730
- let settlementsUnreached = false
891
+ // The environment teardown this rollback performs, kept nested so it cannot
892
+ // be reached without the async-work drain below running first.
893
+ function __rollbackWasmEnvForWasiInitialization() {
894
+ const cleanupErrors = []
895
+ let drainResult
896
+ let settlementsUnreached = false
897
+ try {
898
+ __prepareWasmEnvCleanup()
899
+ drainResult = __drainWasmEnvCleanup()
900
+ } catch (cleanupError) {
901
+ cleanupErrors.push(cleanupError)
902
+ settlementsUnreached = true
903
+ }
904
+ if (__isThenable(drainResult)) {
905
+ return Promise.resolve(drainResult).then(
906
+ () => __destroyContextForWasiRollback(cleanupErrors),
907
+ (cleanupError) => {
908
+ cleanupErrors.push(cleanupError)
909
+ return __retainFailedWasiRollback(cleanupErrors)
910
+ },
911
+ )
912
+ }
913
+ if (settlementsUnreached) {
914
+ return __retainFailedWasiRollback(cleanupErrors)
915
+ }
916
+ return __destroyContextForWasiRollback(cleanupErrors)
917
+ }
918
+
919
+ // Same reason as \`__startWasiDisposal\`: a module-init hook can start async
920
+ // work before the load goes on to fail, and this rollback tears down exactly
921
+ // what those completions need. Settle them while everything is still live,
922
+ // before the barrier and the teardown above take that away.
923
+ //
924
+ // A drain that could not finish leaves async work possibly outstanding, and
925
+ // destroying the context over it would strand exactly what this rollback is
926
+ // there to settle. Stop short and retain instead — the same trade
927
+ // \`__rollbackWasmEnvForWasiInitialization\` makes for the settlement drain, so
928
+ // the context stays reclaimable by a retry or by this flavor's own
929
+ // last-resort teardown.
930
+ const __retainAfterAsyncWorkDrainFailure = (cleanupError) =>
931
+ __retainFailedWasiRollback([cleanupError])
932
+ let asyncWorkResult
731
933
  try {
732
- __prepareWasmEnvCleanup()
733
- drainResult = __drainWasmEnvCleanup()
934
+ asyncWorkResult = __drainWasiAsyncWork()
734
935
  } catch (cleanupError) {
735
- cleanupErrors.push(cleanupError)
736
- settlementsUnreached = true
936
+ return __retainAfterAsyncWorkDrainFailure(cleanupError)
737
937
  }
738
- if (__isThenable(drainResult)) {
739
- return Promise.resolve(drainResult).then(
740
- () => __destroyContextForWasiRollback(cleanupErrors),
741
- (cleanupError) => {
742
- cleanupErrors.push(cleanupError)
743
- return __retainFailedWasiRollback(cleanupErrors)
744
- },
938
+ if (__isThenable(asyncWorkResult)) {
939
+ return Promise.resolve(asyncWorkResult).then(
940
+ __rollbackWasmEnvForWasiInitialization,
941
+ __retainAfterAsyncWorkDrainFailure,
745
942
  )
746
943
  }
747
- if (settlementsUnreached) {
748
- return __retainFailedWasiRollback(cleanupErrors)
749
- }
750
- return __destroyContextForWasiRollback(cleanupErrors)
944
+ return __rollbackWasmEnvForWasiInitialization()
751
945
  }
752
946
  `
753
947
  }
@@ -875,14 +1069,27 @@ const __workerPoolSize = Math.max(
875
1069
  const memoryName = threads ? '__sharedMemory' : '__wasmMemory'
876
1070
  const asyncWorkPoolOption = ` asyncWorkPoolSize: ${threads ? '__asyncWorkPoolSize' : 0},
877
1071
  `
878
- // Every build links a "basic" emnapi archive without the C async-work and
879
- // threadsafe-function implementations (the `emnapi-napi-rs(-mt)` archives shipped by the emnapi package), so the
880
- // JavaScript implementations must be provided through the emnapi plugins in
881
- // both threading modes. Without threads the C code would be unconditional
882
- // `napi_generic_failure` stubs; with threads it would shadow the
883
- // `@emnapi/core` threaded TSFN/async-work protocol the plugins implement.
1072
+ // Which archive a build links decides who implements async work and
1073
+ // threadsafe functions — see `emnapi_link_library` in
1074
+ // `crates/build/src/wasi.rs`. Without threads it is `emnapi-basic-napi-rs`,
1075
+ // the "basic" model: both stay wasm imports, and the `@emnapi/core`
1076
+ // JavaScript plugins below are what resolves them; without the plugins
1077
+ // instantiation fails with a LinkError naming the missing import. With
1078
+ // threads it is `emnapi-napi-rs-mt`, the full composition, whose C
1079
+ // `async_work.c` / `threadsafe_function.c` run on the uv threadpool inside
1080
+ // the wasm — it imports none of those symbols, so the plugins are inert
1081
+ // there. They are passed in both modes because only the archive knows which
1082
+ // applies, and a plugin nothing imports costs nothing.
1083
+ //
1084
+ // This is why the async-work drain on the disposal path cannot live here:
1085
+ // with threads there is no JavaScript seam at all, so `__drainWasiAsyncWork`
1086
+ // asks the addon instead.
884
1087
  const emnapiPluginImport = ` emnapiAsyncWorkPlugin as __emnapiAsyncWorkPlugin,\n emnapiTSFNPlugin as __emnapiTSFNPlugin,\n`
885
- const emnapiPluginOption = ` plugins: [__captureWasiThreadManager, __emnapiAsyncWorkPlugin, __emnapiTSFNPlugin],\n`
1088
+ const emnapiPluginOption = ` plugins: [
1089
+ __captureWasiThreadManager,
1090
+ __emnapiAsyncWorkPlugin,
1091
+ __emnapiTSFNPlugin,
1092
+ ],\n`
886
1093
  const workerOption = threads
887
1094
  ? ` onCreateWorker() {
888
1095
  const worker = new Worker(new URL('./wasi-worker-browser.mjs', import.meta.url), {
@@ -1492,6 +1699,101 @@ function __drainWasmEnvCleanup(__instance) {
1492
1699
  })()
1493
1700
  }
1494
1701
 
1702
+ // A real, *referenced* timer for the async-work wait below, which polls the
1703
+ // addon rather than interleaving with the @emnapi/core dispatch: a zero-delay
1704
+ // macrotask there would spin the loop instead of yielding it. Falls back to the
1705
+ // macrotask scheduler on a host without timers.
1706
+ function __scheduleTimer(__callback, __delay) {
1707
+ const __setTimer = globalThis.setTimeout
1708
+ if (typeof __setTimer !== 'function') {
1709
+ __scheduleMacrotask(__callback)
1710
+ return
1711
+ }
1712
+ try {
1713
+ __setTimer(__callback, __delay)
1714
+ } catch {
1715
+ __scheduleMacrotask(__callback)
1716
+ }
1717
+ }
1718
+
1719
+ // How often to re-read \`napi_wasm_async_work_pending\` while waiting. The wait
1720
+ // ends when the addon reports zero, so this only decides how promptly disposal
1721
+ // notices — not how long it waits.
1722
+ const __WASI_ASYNC_WORK_POLL_INTERVAL_MS = 1
1723
+
1724
+ /**
1725
+ * Settles this instance's outstanding \`napi_async_work\` before the teardown
1726
+ * that would strand it.
1727
+ *
1728
+ * The same hole the eager loaders had, and the same fix: the environment
1729
+ * cleanup barrier covers promise settlements queued on the threadsafe-function
1730
+ * queue and says nothing about \`napi_async_work\`, so destroying the context
1731
+ * with a work still outstanding leaves its completion callback with nowhere to
1732
+ * run and its promise unsettled forever.
1733
+ *
1734
+ * This flavor is threadless, so \`compute\` runs on the JavaScript thread inside
1735
+ * the macrotask that dequeued it: while this is running, any outstanding work
1736
+ * is queued rather than executing, and \`napi_wasm_cancel_pending_async_work\`
1737
+ * can take all of it. The poll is still what decides when the drain is done —
1738
+ * cancellation delivers those completions from a later macrotask, not
1739
+ * synchronously.
1740
+ *
1741
+ * Per instance, from that instance's own exports: two instances of this loader
1742
+ * have separate registries and must not wait on each other.
1743
+ *
1744
+ * Both exports are optional, so an addon built against a napi crate that
1745
+ * predates them keeps the previous behavior.
1746
+ *
1747
+ * Returns nothing when there is nothing outstanding, which keeps disposal
1748
+ * synchronous in the common case. No deadline: giving up would destroy the
1749
+ * environment with a completion callback still owed.
1750
+ */
1751
+ function __drainInstanceAsyncWork(__instance) {
1752
+ const __exports = __instance?.exports
1753
+ const __pending = __exports?.napi_wasm_async_work_pending
1754
+ const __cancelPending = __exports?.napi_wasm_cancel_pending_async_work
1755
+ if (typeof __pending !== 'function' || typeof __cancelPending !== 'function') {
1756
+ return
1757
+ }
1758
+
1759
+ const __readPending = () => {
1760
+ try {
1761
+ return __pending()
1762
+ } catch (__error) {
1763
+ // A trap is the only way this call fails: it reads a counter and cannot
1764
+ // allocate or call back into JavaScript. A trapped instance can no longer
1765
+ // run anything, so its outstanding work is unreachable by definition and
1766
+ // best-effort is the honest answer. Anything else means the export is not
1767
+ // what this loader thinks it is — a defect worth surfacing.
1768
+ if (__error instanceof globalThis.WebAssembly.RuntimeError) {
1769
+ return 0
1770
+ }
1771
+ throw __error
1772
+ }
1773
+ }
1774
+
1775
+ if (!__readPending()) {
1776
+ return
1777
+ }
1778
+ try {
1779
+ __cancelPending()
1780
+ } catch {
1781
+ // Cancellation only bounds the wait. Failing it means waiting for the queue
1782
+ // to run instead, which the poll below already does.
1783
+ }
1784
+ if (!__readPending()) {
1785
+ return
1786
+ }
1787
+
1788
+ return (async () => {
1789
+ while (__readPending()) {
1790
+ await new Promise((__resolve) => {
1791
+ __scheduleTimer(__resolve, __WASI_ASYNC_WORK_POLL_INTERVAL_MS)
1792
+ })
1793
+ }
1794
+ })()
1795
+ }
1796
+
1495
1797
  function __createLifecycleReentryError(__operation) {
1496
1798
  const __error = new Error(
1497
1799
  __operation +
@@ -1988,7 +2290,15 @@ ${instanceHostState}\
1988
2290
  if (__lifecycleState !== 'failed') {
1989
2291
  __lifecycleState = 'disposal'
1990
2292
  }
1991
- // Settle what the barrier cancelled before the environment stops
2293
+ // Outstanding async work first, while the environment is still completely
2294
+ // live: its completion callbacks run addon code, and the barrier and
2295
+ // \`Context.destroy()\` below each take that away. Undefined unless work is
2296
+ // outstanding.
2297
+ const __asyncWorkDrained = __drainInstanceAsyncWork(__napiInstance)
2298
+ if (__asyncWorkDrained) {
2299
+ await __asyncWorkDrained
2300
+ }
2301
+ // Then settle what the barrier cancelled, before the environment stops
1992
2302
  // accepting JavaScript calls. Undefined unless something is queued, so
1993
2303
  // an idle disposal is not delayed by a single turn.
1994
2304
  const __drained = __prepareForDisposal()
@@ -2145,6 +2455,14 @@ ${installInstanceHosts}\
2145
2455
  // so a failure before beforeInit costs no extra turn.
2146
2456
  let __settlementsUnreached = false
2147
2457
  try {
2458
+ // Registration can start async work too, and this path destroys the same
2459
+ // environment its completions need. A drain that cannot finish leaves the
2460
+ // work outstanding, so it counts as settlements unreached and stops the
2461
+ // rollback short of destroying, exactly like a failed barrier drain.
2462
+ const __asyncWorkDrained = __drainInstanceAsyncWork(__napiInstance)
2463
+ if (__asyncWorkDrained) {
2464
+ await __asyncWorkDrained
2465
+ }
2148
2466
  const __drained = __prepareForDisposal()
2149
2467
  if (__drained) {
2150
2468
  await __drained
@@ -2628,17 +2946,34 @@ function __createWasiWorker(filename) {
2628
2946
  }
2629
2947
  })(),
2630
2948
  reuseWorker: true,
2631
- plugins: [__captureWasiThreadManager, __emnapiAsyncWorkPlugin, __emnapiTSFNPlugin],
2949
+ plugins: [
2950
+ __captureWasiThreadManager,
2951
+ __emnapiAsyncWorkPlugin,
2952
+ __emnapiTSFNPlugin,
2953
+ ],
2632
2954
  `
2633
2955
  : ` asyncWorkPoolSize: 0,
2634
- plugins: [__captureWasiThreadManager, __emnapiAsyncWorkPlugin, __emnapiTSFNPlugin],
2956
+ plugins: [
2957
+ __captureWasiThreadManager,
2958
+ __emnapiAsyncWorkPlugin,
2959
+ __emnapiTSFNPlugin,
2960
+ ],
2635
2961
  `
2636
- // Every build links a "basic" emnapi archive without the C async-work and
2637
- // threadsafe-function implementations (the `emnapi-napi-rs(-mt)` archives shipped by the emnapi package), so the
2638
- // JavaScript implementations must be provided through the emnapi plugins in
2639
- // both threading modes. Without threads the C code would be unconditional
2640
- // `napi_generic_failure` stubs; with threads it would shadow the
2641
- // `@emnapi/core` threaded TSFN/async-work protocol the plugins implement.
2962
+ // Which archive a build links decides who implements async work and
2963
+ // threadsafe functions — see `emnapi_link_library` in
2964
+ // `crates/build/src/wasi.rs`. Without threads it is `emnapi-basic-napi-rs`,
2965
+ // the "basic" model: both stay wasm imports, and the `@emnapi/core`
2966
+ // JavaScript plugins below are what resolves them; without the plugins
2967
+ // instantiation fails with a LinkError naming the missing import. With
2968
+ // threads it is `emnapi-napi-rs-mt`, the full composition, whose C
2969
+ // `async_work.c` / `threadsafe_function.c` run on the uv threadpool inside
2970
+ // the wasm — it imports none of those symbols, so the plugins are inert
2971
+ // there. They are passed in both modes because only the archive knows which
2972
+ // applies, and a plugin nothing imports costs nothing.
2973
+ //
2974
+ // This is why the async-work drain on the disposal path cannot live here:
2975
+ // with threads there is no JavaScript seam at all, so `__drainWasiAsyncWork`
2976
+ // asks the addon instead.
2642
2977
  const emnapiPluginRequire = ` emnapiAsyncWorkPlugin: __emnapiAsyncWorkPlugin,\n emnapiTSFNPlugin: __emnapiTSFNPlugin,\n`
2643
2978
  const workerOption = threads
2644
2979
  ? ` onCreateWorker() {