@napi-rs/cli 3.10.4 → 3.10.6

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.
@@ -7,6 +7,47 @@ import {
7
7
  const WASI_DISPOSE_SYMBOL = 'napi.rs.wasi.dispose'
8
8
  const WASI_ROLLBACK_REGISTRY_SYMBOL = 'napi.rs.wasi.rollback.registry.v1'
9
9
 
10
+ /**
11
+ * Threaded loaders only: a view of the addon's crash flag for the pool workers.
12
+ *
13
+ * napi-async-runtime's shutdown waits run on the loader thread, inside the
14
+ * environment cleanup calls, and check one word of the shared wasm memory
15
+ * between 1 ms slices; once it is set they trap instead of waiting on a dead
16
+ * thread for good. napi's `napi_wasm_thread_crash_flag_address` export says
17
+ * where that word is. Each worker gets an `Int32Array` over it and raises it
18
+ * with `Atomics.store` when its wasm thread dies — right after the loader's own
19
+ * crash flag, where there is one. The store needs no instance, so a worker that
20
+ * fails while it loads, after the thread spawn that created it had already
21
+ * returned, raises it too.
22
+ *
23
+ * Read in `beforeInit`: before any registration code runs, and on Node before
24
+ * any pool worker exists (the pool is created on demand). An addon built with
25
+ * an older napi has no export, and a value that is not a 4-aligned address
26
+ * inside the memory is ignored: the view stays undefined and the waits stay
27
+ * unbounded, as before.
28
+ */
29
+ const ADDON_CRASH_FLAG_CAPTURE = `// A view of the addon's crash flag, one word of the shared wasm memory, for the
30
+ // pool workers: they raise it when their wasm thread dies, and the shutdown
31
+ // waits inside the cleanup calls on this thread then trap instead of waiting on
32
+ // the dead thread for good. Undefined for an addon built with an older napi.
33
+ let __wasiAddonCrashFlag
34
+
35
+ function __captureWasiAddonCrashFlag(instance) {
36
+ try {
37
+ const getAddress = instance.exports.napi_wasm_thread_crash_flag_address
38
+ if (typeof getAddress !== 'function') {
39
+ return
40
+ }
41
+ const address = getAddress() >>> 0
42
+ const buffer = __sharedMemory.buffer
43
+ if (address === 0 || address % 4 !== 0 || address + 4 > buffer.byteLength) {
44
+ return
45
+ }
46
+ __wasiAddonCrashFlag = new Int32Array(buffer, address, 1)
47
+ } catch {}
48
+ }
49
+ `
50
+
10
51
  /**
11
52
  * `Context.destroy()` disables JavaScript calls *before* it runs cleanup hooks
12
53
  * (`setStopping` -> `setCanCallIntoJs(false)` -> `runCleanup`), and the
@@ -88,7 +129,12 @@ function __wrapEmnapiContextDestroyForSettlement(
88
129
  * `__startWasiDisposal` / the rollback have already prepared and drained, so
89
130
  * one call there covers all three.
90
131
  */
91
- const createEmnapiContextLifecycle = (asyncRuntime: boolean) => {
132
+ const createEmnapiContextLifecycle = (
133
+ asyncRuntime: boolean,
134
+ // Only the threaded Node loader defines the crash latch
135
+ // (`__hasWasiThreadCrashed`, `__disposeWasiBindingAfterThreadCrash`).
136
+ threadCrashLatch = false,
137
+ ) => {
92
138
  const currentThreadHosts = asyncRuntime
93
139
  ? `
94
140
  let __currentThreadHostsDisposer
@@ -122,9 +168,203 @@ function __disposeCurrentThreadHosts() {
122
168
  }
123
169
  `
124
170
  : ''
171
+ const currentThreadHostTimersAfterThreadCrash =
172
+ asyncRuntime && threadCrashLatch
173
+ ? `
174
+ /**
175
+ * The CurrentThread timer host arms one referenced \`setTimeout\` per sleep in
176
+ * flight, so a long sleep holds the event loop open until it fires.
177
+ * \`__disposeCurrentThreadHosts\` releases them, but only
178
+ * \`__destroyEmnapiContext\` calls it, and the crash disposal never gets there:
179
+ * after a wasm thread died it must not enter wasm, and both the host
180
+ * unregister calls and a settled timer promise do. A dispose() that rejected
181
+ * after a crash then left the process alive until the longest sleep ended.
182
+ *
183
+ * So the loader keeps the package's own \`cancel\` for every timer in flight,
184
+ * and \`__releaseCurrentThreadHostTimers\` calls them after a crash. \`cancel\`
185
+ * is plain JavaScript: it clears the host timeout and resolves the package's
186
+ * promise. The promise the addon holds is the one below, and once released it
187
+ * never settles, so nothing is handed back to wasm. A sleep armed after that
188
+ * is never armed at all.
189
+ */
190
+ const __currentThreadHostTimerCancels = new Set()
191
+ let __currentThreadHostTimersReleased = false
192
+
193
+ function __scheduleCurrentThreadHostTimer(schedule, cancel, id, ms) {
194
+ if (__currentThreadHostTimersReleased) {
195
+ return new Promise(() => {})
196
+ }
197
+ const scheduled = schedule(id, ms)
198
+ if (!__isThenable(scheduled)) {
199
+ return scheduled
200
+ }
201
+ const release = () => cancel(id)
202
+ __currentThreadHostTimerCancels.add(release)
203
+ const settle = (settleUnreleased) => (outcome) => {
204
+ __currentThreadHostTimerCancels.delete(release)
205
+ return __currentThreadHostTimersReleased
206
+ ? new Promise(() => {})
207
+ : settleUnreleased(outcome)
208
+ }
209
+ return Promise.resolve(scheduled).then(
210
+ settle((value) => value),
211
+ settle((error) => {
212
+ throw error
213
+ }),
214
+ )
215
+ }
216
+
217
+ /**
218
+ * The binding as \`installCurrentThreadHosts\` should see it: every export
219
+ * reads through, and \`registerTimerHost\` hands the addon a \`schedule\` that
220
+ * records its \`cancel\`. A binding without a callable \`registerTimerHost\` is
221
+ * passed through, so the package reports the mismatch as before.
222
+ */
223
+ function __trackCurrentThreadHostTimers(binding) {
224
+ let registerTimerHost
225
+ try {
226
+ registerTimerHost = binding.registerTimerHost
227
+ } catch {
228
+ return binding
229
+ }
230
+ if (typeof registerTimerHost !== 'function') {
231
+ return binding
232
+ }
233
+ const view = Object.create(binding)
234
+ Object.defineProperty(view, 'registerTimerHost', {
235
+ configurable: true,
236
+ enumerable: true,
237
+ writable: true,
238
+ value: function (...args) {
239
+ const [, , schedule, cancel] = args
240
+ if (typeof schedule === 'function' && typeof cancel === 'function') {
241
+ args[2] = (id, ms) =>
242
+ __scheduleCurrentThreadHostTimer(schedule, cancel, id, ms)
243
+ }
244
+ return Reflect.apply(registerTimerHost, this, args)
245
+ },
246
+ })
247
+ return view
248
+ }
249
+
250
+ /**
251
+ * Clears every CurrentThread host timeout without entering wasm. Idempotent;
252
+ * a \`cancel\` that fails has already reported itself.
253
+ */
254
+ function __releaseCurrentThreadHostTimers() {
255
+ __currentThreadHostTimersReleased = true
256
+ const releases = [...__currentThreadHostTimerCancels]
257
+ __currentThreadHostTimerCancels.clear()
258
+ for (const release of releases) {
259
+ try {
260
+ release()
261
+ } catch {}
262
+ }
263
+ }
264
+ `
265
+ : ''
125
266
  const disposeCurrentThreadHosts = asyncRuntime
126
267
  ? ' __disposeCurrentThreadHosts()\n'
127
268
  : ''
269
+ // After the in-flight check: a disposal that was already running when a
270
+ // thread died settles through the crash disposal itself, so every caller of
271
+ // it keeps getting the one promise it handed out.
272
+ const disposeAfterThreadCrash = threadCrashLatch
273
+ ? ` if (!__wasiDisposed && __hasWasiThreadCrashed()) {
274
+ return __disposeWasiBindingAfterThreadCrash()
275
+ }
276
+ `
277
+ : ''
278
+ // A thread can die after the entry check above has passed. The disposal's
279
+ // polls and step boundaries check again, and a chain stopped that way — or
280
+ // failed any other way once a thread is dead — settles through the crash
281
+ // disposal. See `__abortWasiDisposalIfThreadCrashed`.
282
+ const abortDisposalAfterThreadCrash = (indent: string) =>
283
+ threadCrashLatch ? `${indent}__abortWasiDisposalIfThreadCrashed()\n` : ''
284
+ const settleDisposalAfterThreadCrash = (indent: string, exit: string) =>
285
+ threadCrashLatch
286
+ ? `${indent}if (__settleWasiDisposalAfterThreadCrash(resolveDispose, rejectDispose)) {
287
+ ${indent} ${exit}
288
+ ${indent}}
289
+ `
290
+ : ''
291
+ // The barrier's poll ends in `…_finish`, which joins the runtime's work. A
292
+ // dead thread's work never goes idle, so after a crash the join would block
293
+ // this thread for good: leave the barrier parked and stop the disposal.
294
+ const finishWasmEnvCleanupUnlessCrashed = threadCrashLatch
295
+ ? ` const finishCleanupUnlessCrashed = () => {
296
+ try {
297
+ __abortWasiDisposalIfThreadCrashed()
298
+ } catch (error) {
299
+ __finishParkedWasmEnvCleanup = undefined
300
+ throw error
301
+ }
302
+ finishCleanup()
303
+ }
304
+ `
305
+ : ''
306
+ const finishWasmEnvCleanupAfterPoll = threadCrashLatch
307
+ ? 'finishCleanupUnlessCrashed'
308
+ : 'finishCleanup'
309
+ // The initialization rollback runs the same polls and steps. With the latch,
310
+ // its body keeps its own name and `__rollbackWasiInitialization` becomes the
311
+ // wrapper that stops it after a crash. See
312
+ // `__rollbackWasiInitializationAfterThreadCrash`.
313
+ const rollbackStepsName = threadCrashLatch
314
+ ? '__runWasiInitializationRollbackSteps'
315
+ : '__rollbackWasiInitialization'
316
+ const rollbackAfterThreadCrash = threadCrashLatch
317
+ ? `
318
+ /**
319
+ * The rollback above, stopped when a wasm thread has died.
320
+ *
321
+ * Its polls wait for work the dead thread still counts, so they never end, and
322
+ * the barrier's \`…_finish\` and \`Context.destroy()\` would re-enter wasm
323
+ * and wait on that thread for good. While the rollback runs, the poll turns
324
+ * and step boundaries stop it (\`__abortWasiDisposalIfThreadCrashed\`), and a
325
+ * crash seen before, during or after it ends it through
326
+ * \`__rollbackWasiInitializationAfterThreadCrash\`. Without a crash the
327
+ * result — synchronous or not — is passed through unchanged.
328
+ */
329
+ function __rollbackWasiInitialization() {
330
+ if (__hasWasiThreadCrashed()) {
331
+ return __rollbackWasiInitializationAfterThreadCrash()
332
+ }
333
+ __wasiInitializationRollbackActive = true
334
+ let result
335
+ try {
336
+ result = __runWasiInitializationRollbackSteps()
337
+ } catch (error) {
338
+ __wasiInitializationRollbackActive = false
339
+ if (__hasWasiThreadCrashed()) {
340
+ return __rollbackWasiInitializationAfterThreadCrash()
341
+ }
342
+ throw error
343
+ }
344
+ if (!__isThenable(result)) {
345
+ __wasiInitializationRollbackActive = false
346
+ return __hasWasiThreadCrashed()
347
+ ? __rollbackWasiInitializationAfterThreadCrash()
348
+ : result
349
+ }
350
+ return Promise.resolve(result).then(
351
+ (cleanupErrors) => {
352
+ __wasiInitializationRollbackActive = false
353
+ return __hasWasiThreadCrashed()
354
+ ? __rollbackWasiInitializationAfterThreadCrash()
355
+ : cleanupErrors
356
+ },
357
+ (error) => {
358
+ __wasiInitializationRollbackActive = false
359
+ if (__hasWasiThreadCrashed()) {
360
+ return __rollbackWasiInitializationAfterThreadCrash()
361
+ }
362
+ throw error
363
+ },
364
+ )
365
+ }
366
+ `
367
+ : ''
128
368
 
129
369
  return `
130
370
  const __wasiDisposeSymbol = Symbol.for('${WASI_DISPOSE_SYMBOL}')
@@ -163,6 +403,20 @@ let __emnapiContextDestroyed = false
163
403
  let __emnapiContextDestroyPromise
164
404
  let __emnapiWasmEnvCleanupPrepared = false
165
405
  let __emnapiWasmEnvCleanupPreparing = false
406
+ // The closer for a barrier that is parked between \`…_begin\` and \`…_finish\`,
407
+ // set only while that window is open. \`__emnapiWasmEnvCleanupPreparing\` cannot
408
+ // tell those two apart on its own: it is raised both for a purely synchronous
409
+ // frame — which must not be re-entered, and which nothing outside it can
410
+ // finish — and across this window, which spans real event-loop turns, so a
411
+ // caller that cannot yield can land in the middle of one. That caller can close
412
+ // this window, because \`…_finish\` is idempotent and joins, which is exactly
413
+ // what the single call does. See \`__prepareWasmEnvCleanup\`.
414
+ let __finishParkedWasmEnvCleanup
415
+ // Raised while a caller that can still yield is driving the barrier, so the
416
+ // queue it leaves behind is expected rather than lost. See
417
+ // \`__reportUnreachedWasmEnvSettlements\`.
418
+ let __emnapiWasmEnvCleanupYielding = false
419
+ let __emnapiWasmEnvSettlementLossReported = false
166
420
  let __emnapiWasmEnvCleanupRan = false
167
421
  let __emnapiWasmEnvCleanupDrained = false
168
422
  let __emnapiWasmEnvCleanupDrainPromise
@@ -174,7 +428,7 @@ let __completeWasiDisposal = function () {}
174
428
  // that stopped short of destroying the context. See
175
429
  // \`__rollbackWasiInitialization\`.
176
430
  let __retainWasiRollbackForRetry = function () {}
177
- ${currentThreadHosts}
431
+ ${currentThreadHosts}${currentThreadHostTimersAfterThreadCrash}
178
432
  function __isThenable(value) {
179
433
  return (
180
434
  value !== null &&
@@ -242,7 +496,24 @@ function __isPreparingWasmEnvCleanup() {
242
496
  }
243
497
 
244
498
  function __prepareWasmEnvCleanup() {
245
- if (__emnapiWasmEnvCleanupPrepared || __emnapiWasmEnvCleanupPreparing) {
499
+ if (__emnapiWasmEnvCleanupPrepared) {
500
+ return
501
+ }
502
+ // A handshake parked between its two halves is one this frame can close, and
503
+ // must: every caller of this function is about to destroy the context, and
504
+ // the turns the poll is waiting for will not come — an 'exit' teardown is
505
+ // the last thing the process runs, and \`Context.destroy()\` takes the
506
+ // environment away. Closing it here runs \`…_finish\`, which is the call that joins, so
507
+ // this degrades to exactly the single call below. Leaving it open instead
508
+ // destroys the context with the barrier still raised, the runtime never
509
+ // joined and the workers never drained.
510
+ const finishParked = __finishParkedWasmEnvCleanup
511
+ if (finishParked !== undefined) {
512
+ finishParked()
513
+ __reportUnreachedWasmEnvSettlements()
514
+ return
515
+ }
516
+ if (__emnapiWasmEnvCleanupPreparing) {
246
517
  return
247
518
  }
248
519
  const prepare = __napiInstance?.exports?.napi_prepare_wasm_env_cleanup
@@ -257,10 +528,57 @@ function __prepareWasmEnvCleanup() {
257
528
  __emnapiWasmEnvCleanupPreparing = false
258
529
  }
259
530
  __emnapiWasmEnvCleanupRan = true
531
+ __reportUnreachedWasmEnvSettlements()
260
532
  }
261
533
  __emnapiWasmEnvCleanupPrepared = true
262
534
  }
263
535
 
536
+ /**
537
+ * Say so when the barrier leaves settlements queued and nothing is left that
538
+ * could deliver them.
539
+ *
540
+ * Only the disposal chain yields the event-loop turns @emnapi/core needs to
541
+ * dispatch its queue. Every other caller of the barrier destroys in the same
542
+ * turn — a raw \`Context.destroy()\`, the 'exit' teardown — and
543
+ * \`Context.destroy()\` runs the threadsafe function's cleanup hook, which drains
544
+ * that queue with a null env and discards it. The promises those settlements
545
+ * were for then hang forever, silently.
546
+ *
547
+ * Loud, once, and never throwing: this runs from inside \`Context.destroy()\`,
548
+ * emnapi's own beforeExit destroy included, where throwing would take the whole
549
+ * teardown down with it. Destroying anyway is still the right trade — the queue
550
+ * is already unreachable by then.
551
+ */
552
+ function __reportUnreachedWasmEnvSettlements() {
553
+ if (__emnapiWasmEnvCleanupYielding || __emnapiWasmEnvSettlementLossReported) {
554
+ return
555
+ }
556
+ const pending = __napiInstance?.exports?.napi_wasm_env_cleanup_pending
557
+ if (typeof pending !== 'function') {
558
+ return
559
+ }
560
+ let queued
561
+ try {
562
+ queued = pending()
563
+ } catch {
564
+ return
565
+ }
566
+ if (!queued) {
567
+ return
568
+ }
569
+ __emnapiWasmEnvSettlementLossReported = true
570
+ try {
571
+ const consoleHost = globalThis.console
572
+ if (consoleHost && typeof consoleHost.error === 'function') {
573
+ consoleHost.error(
574
+ "napi-rs: the wasm environment is being destroyed with " +
575
+ queued +
576
+ " queued promise settlement(s). Context.destroy() discards them, so those promises never settle. Dispose with binding[Symbol.for('${WASI_DISPOSE_SYMBOL}')]() instead: only it yields the event-loop turns the settlements need.",
577
+ )
578
+ }
579
+ } catch {}
580
+ }
581
+
264
582
  // Mirror the primitive @emnapi/core schedules its threadsafe-function dispatch
265
583
  // on, so the drain turns below interleave with that dispatch instead of racing
266
584
  // ahead of it on a faster queue.
@@ -310,6 +628,293 @@ function __scheduleTimer(callback, delay) {
310
628
  }
311
629
  }
312
630
 
631
+ // A real, referenced timer rather than a zero-delay macrotask, for the same
632
+ // reason the async-work drain uses one: this polls the addon instead of
633
+ // interleaving with the @emnapi/core dispatch, so a zero-delay turn would spin
634
+ // the loop instead of yielding it.
635
+ const __WASM_RUNTIME_WORK_POLL_INTERVAL_MS = 1
636
+ // Arrivals it takes before the poll paces on the host's timers alone. One
637
+ // proves nothing: a timer armed before the host's timers stopped still fires.
638
+ const __WASM_RUNTIME_WORK_POLL_TRUSTED_ARRIVALS = 2
639
+ // How long a parked turn's own timer must already have been due before a
640
+ // backup that runs calls it dropped. Slack, not a deadline: a timer is due
641
+ // against the event loop's clock, which is read once per iteration, while
642
+ // these are \`Date.now()\` readings taken part-way through one, so the two
643
+ // drift apart by however long the loop has been inside the current iteration.
644
+ const __WASM_RUNTIME_WORK_POLL_STALL_MS = 50
645
+ // How long a backup itself waits. What is left of it after the slack and one
646
+ // interval — 149 ms — has to cover the *two* poll turns that can separate a
647
+ // parked turn from the last backup armed while the host's timers still
648
+ // worked, so the ceiling on a single turn is half of it. See the invariant on
649
+ // \`__armWasmRuntimePollStallBackup\`.
650
+ const __WASM_RUNTIME_WORK_POLL_BACKUP_MS = 200
651
+
652
+ /**
653
+ * Pacing state for one runtime-work poll.
654
+ *
655
+ * Per poll, never per module: whether the host's timers arrive is not a
656
+ * property of the module. A host can lose its timers between two disposals,
657
+ * and in the deferred shape every instance shares this module — one healthy
658
+ * instance must not disarm the fallback for the next one.
659
+ */
660
+ function __createWasmRuntimePollPace() {
661
+ return {
662
+ // Timers armed by *this* poll that have actually arrived.
663
+ arrivals: 0,
664
+ // The turn waiting on a timer alone *right now* — undefined whenever no
665
+ // turn is parked — and when that turn's own timer came due.
666
+ settleTurn: undefined,
667
+ turnTimerDueAt: 0,
668
+ }
669
+ }
670
+
671
+ /**
672
+ * The backup that ends a turn whose timer is never going to arrive.
673
+ *
674
+ * Once the poll paces on the timer alone it has nothing left to fall back on
675
+ * if the host's timers stop mid-poll: the turn that armed the dead timer is
676
+ * the turn that parks, and a parked poll schedules nothing that could notice.
677
+ * So every turn arms one of these before it yields, and each one compares due
678
+ * times instead of measuring how long the parked turn has been waiting.
679
+ *
680
+ * Invariant: a parked turn is ended by the newest backup that was armed while
681
+ * the host's timers still worked, and a backup ends a turn only when that
682
+ * turn's own timer was already due a whole window before the backup itself.
683
+ * Neither half turns on how far apart the arms happen to fall — what bounds
684
+ * the rescue is how far back that newest live backup is:
685
+ *
686
+ * - *Ends it.* Hosts run timers in due order, so a backup that runs while a
687
+ * turn due a whole window earlier is still parked proves that turn's timer
688
+ * was dropped rather than merely late. That same comparison is what leaves a
689
+ * healthy host alone: there the turn's timer has already run and cleared
690
+ * \`settleTurn\` before any backup due after it can look.
691
+ * - *Two turns back, not one.* A turn that ended does not prove its own timer
692
+ * arrived: until \`…_TRUSTED_ARRIVALS\` is reached every turn arms both
693
+ * primitives and the macrotask wins, so such a turn can end with its own
694
+ * timer — and the backup armed one line before it — already dead. The
695
+ * arrival that then flips the poll onto the timer alone can itself be a
696
+ * timer armed before the host's timers died. So the turn that parks can sit
697
+ * two turns past the last live arm, and the newest live backup is due
698
+ * \`…_BACKUP_MS\` less *two* turn lengths after that turn's own timer.
699
+ * Arming on every turn is what holds it to two, rather than however far back
700
+ * a throttle last let one through.
701
+ * - *Ceiling.* Coverage therefore holds while two consecutive poll turns fit
702
+ * inside \`…_BACKUP_MS\` less the slack and one interval: 149 ms, so 74 ms
703
+ * per turn (measured: a 74 ms turn is still rescued, a 75 ms one parks).
704
+ * Past that the turn stays parked and the disposal promise never settles.
705
+ * The bound is deliberate: reaching it takes a host that drops timers
706
+ * mid-poll *and* keeps every poll turn busy for more than 74 ms, and neither
707
+ * Node nor WebContainer — the hosts that run the threaded artifact — does
708
+ * the second.
709
+ *
710
+ * The poll then goes back to arming both primitives until two fresh arrivals
711
+ * prove the timers again. A host that stops running the timers it has
712
+ * *already* accepted leaves nothing to fire, and the disposal promise stays
713
+ * pending rather than wedging the thread — the same outcome as a blocking
714
+ * closure that never returns. Unreferenced wherever the host allows it: the
715
+ * poll's own turn timers are what keep the loop alive, never these.
716
+ */
717
+ function __armWasmRuntimePollStallBackup(pace) {
718
+ const setTimer = globalThis.setTimeout
719
+ if (typeof setTimer !== 'function') {
720
+ // Nothing to back up: \`__scheduleTimer\` is on the macrotask channel
721
+ // already, and that one cannot park.
722
+ return
723
+ }
724
+ // Read before arming, so this never claims to be due earlier than the timer
725
+ // actually is: a backup ends a turn only when it is provably due after it.
726
+ const dueAt = Date.now() + __WASM_RUNTIME_WORK_POLL_BACKUP_MS
727
+ let handle
728
+ try {
729
+ handle = setTimer(() => {
730
+ const settleTurn = pace.settleTurn
731
+ if (
732
+ !settleTurn ||
733
+ pace.turnTimerDueAt > dueAt - __WASM_RUNTIME_WORK_POLL_STALL_MS
734
+ ) {
735
+ // No turn is parked, or the parked one's timer came due too close to
736
+ // this backup to call it dropped — it may still arrive, and the turn
737
+ // that armed it armed a backup due a whole window after *that*.
738
+ return
739
+ }
740
+ pace.arrivals = 0
741
+ pace.settleTurn = undefined
742
+ settleTurn()
743
+ }, __WASM_RUNTIME_WORK_POLL_BACKUP_MS)
744
+ } catch {
745
+ return
746
+ }
747
+ if (handle && typeof handle.unref === 'function') {
748
+ try {
749
+ handle.unref()
750
+ } catch {}
751
+ }
752
+ }
753
+
754
+ /**
755
+ * One turn of the runtime-work poll.
756
+ *
757
+ * \`__scheduleTimer\` falls back to the macrotask scheduler when \`setTimeout\` is
758
+ * missing or throws, but not when it is present, returns a handle and never
759
+ * fires — fake timers in a test suite that disposes from an \`afterEach\`, or a
760
+ * host whose timers belong to an IO context that is already gone. That host
761
+ * would park this poll forever, and the poll is unbounded, so nothing would
762
+ * ever call \`…_finish\`.
763
+ *
764
+ * Arm both primitives until timers armed by this poll have arrived twice, and
765
+ * let whichever lands first end the turn; the loser resolves nothing. A host
766
+ * with working timers therefore pays the double arming for the first turn or
767
+ * two — the macrotask wins the race, but the timers behind it still arrive and
768
+ * are counted — and paces on the timer alone from then on, instead of spinning
769
+ * the loop on a zero-delay queue. A host whose timers never arrive keeps both,
770
+ * and the macrotask is what keeps the poll moving. A host whose timers stop
771
+ * after proving themselves is caught by \`__armWasmRuntimePollStallBackup\`,
772
+ * which ends the parked turn and puts this poll back on both.
773
+ */
774
+ function __yieldWasmRuntimePollTurn(pace) {
775
+ // Armed before the turn yields, and by every turn: what rescues a parked
776
+ // turn has to have been armed while the host's timers still worked, and the
777
+ // turn that parks is the one whose own timer is already dead.
778
+ __armWasmRuntimePollStallBackup(pace)
779
+ return new Promise((resolve) => {
780
+ let settled = false
781
+ const settle = () => {
782
+ if (settled) {
783
+ return
784
+ }
785
+ settled = true
786
+ if (pace.settleTurn === settle) {
787
+ // Nothing is parked any more: a backup running later must not read a
788
+ // due time this turn has already answered.
789
+ pace.settleTurn = undefined
790
+ }
791
+ resolve()
792
+ }
793
+ __scheduleTimer(() => {
794
+ pace.arrivals++
795
+ settle()
796
+ }, __WASM_RUNTIME_WORK_POLL_INTERVAL_MS)
797
+ // Read next to the arming it describes; see
798
+ // \`__armWasmRuntimePollStallBackup\` for what the two due times mean.
799
+ const turnTimerDueAt = Date.now() + __WASM_RUNTIME_WORK_POLL_INTERVAL_MS
800
+ if (pace.arrivals < __WASM_RUNTIME_WORK_POLL_TRUSTED_ARRIVALS) {
801
+ __scheduleMacrotask(settle)
802
+ return
803
+ }
804
+ // Paced by the timer alone from here; the backup is what ends this turn if
805
+ // the timer never arrives.
806
+ pace.settleTurn = settle
807
+ pace.turnTimerDueAt = turnTimerDueAt
808
+ })
809
+ }
810
+
811
+ /**
812
+ * The barrier for callers that can yield: \`__prepareWasmEnvCleanup\` with real
813
+ * event-loop turns in the middle.
814
+ *
815
+ * \`napi_prepare_wasm_env_cleanup\` waits — it returns only once the addon's
816
+ * async runtime has quiesced, and on \`wasm32-wasip1-threads\` the thread it
817
+ * waits on is this one, the only thread that can give a running blocking
818
+ * closure the JavaScript turn *it* is waiting for. A single call there can wait
819
+ * for work that can never finish. The addon's two-phase form splits that:
820
+ * \`…_begin\` stops the runtime without joining and reports whether anything is
821
+ * still live, \`napi_wasm_runtime_work_pending\` answers that question again
822
+ * without blocking, and \`…_finish\` joins. The turns yielded in between are the
823
+ * entire point.
824
+ *
825
+ * The poll has no deadline, for the same reason the async-work drain below has
826
+ * none: giving up means calling \`…_finish\`, which joins on this thread, and the
827
+ * work it would join is the work that is waiting for a turn from this thread —
828
+ * so a bound does not end the wait, it only moves it somewhere the JavaScript
829
+ * thread can no longer be reached. A blocking closure that never returns keeps
830
+ * the disposal promise pending instead, exactly as a task whose \`execute\` never
831
+ * returns already keeps an *undisposed* process alive. The host contract is in
832
+ * \`crates/async-runtime/README.md\`: a blocking closure must never wait on a
833
+ * JavaScript turn. The process-exit path still blocks in \`…_finish\`, because it
834
+ * has no turns left to give (see \`__prepareWasmEnvCleanup\`).
835
+ *
836
+ * Feature-detected like every other export in this teardown, so an addon built
837
+ * against a napi crate that predates the split keeps the single blocking call.
838
+ * Returns nothing whenever the handshake finished without yielding, which keeps
839
+ * an idle disposal synchronous.
840
+ */
841
+ function __prepareWasmEnvCleanupWithTurns() {
842
+ if (__emnapiWasmEnvCleanupPrepared || __emnapiWasmEnvCleanupPreparing) {
843
+ return
844
+ }
845
+ const exports = __napiInstance?.exports
846
+ const begin = exports?.napi_prepare_wasm_env_cleanup_begin
847
+ const finish = exports?.napi_prepare_wasm_env_cleanup_finish
848
+ if (typeof begin !== 'function' || typeof finish !== 'function') {
849
+ // No split to use. The settlement drain still follows this, so the queue
850
+ // the single call leaves behind is expected rather than lost.
851
+ __emnapiWasmEnvCleanupYielding = true
852
+ try {
853
+ __prepareWasmEnvCleanup()
854
+ } finally {
855
+ __emnapiWasmEnvCleanupYielding = false
856
+ }
857
+ return
858
+ }
859
+ const workPending = exports?.napi_wasm_runtime_work_pending
860
+ // The in-flight flag stays raised across the turns below, so a \`destroy()\`
861
+ // from one of the JavaScript handlers they run is the same no-op it is inside
862
+ // the single call: the barrier is up and the runtime is mid-teardown, and
863
+ // destroying between the halves would strand exactly what this delivers.
864
+ __emnapiWasmEnvCleanupPreparing = true
865
+ let live
866
+ try {
867
+ live = begin()
868
+ } catch (error) {
869
+ __emnapiWasmEnvCleanupPreparing = false
870
+ throw error
871
+ }
872
+ __emnapiWasmEnvCleanupRan = true
873
+ const finishCleanup = () => {
874
+ if (__emnapiWasmEnvCleanupPrepared) {
875
+ // Already closed by a caller that could not yield — the 'exit' teardown
876
+ // reached \`__prepareWasmEnvCleanup\` while this poll was parked. \`…_finish\`
877
+ // is idempotent, but the flags it lowers are not: running it again here
878
+ // would clear a \`preparing\` some later barrier had raised.
879
+ return
880
+ }
881
+ __finishParkedWasmEnvCleanup = undefined
882
+ try {
883
+ finish()
884
+ } finally {
885
+ __emnapiWasmEnvCleanupPreparing = false
886
+ }
887
+ __emnapiWasmEnvCleanupPrepared = true
888
+ }
889
+ if (!live || typeof workPending !== 'function') {
890
+ finishCleanup()
891
+ return
892
+ }
893
+ // Publish the closer before yielding: from here until \`finishCleanup\` runs,
894
+ // a caller that cannot yield is entitled to end this handshake itself.
895
+ __finishParkedWasmEnvCleanup = finishCleanup
896
+ ${finishWasmEnvCleanupUnlessCrashed}\
897
+ return (async () => {
898
+ // Unbounded, exactly like the async-work drain below. The wait ends when
899
+ // the addon reports its runtime work finished; the turns spent here are
900
+ // what let that happen at all.
901
+ const pace = __createWasmRuntimePollPace()
902
+ for (;;) {
903
+ await __yieldWasmRuntimePollTurn(pace)
904
+ ${abortDisposalAfterThreadCrash(' ')}\
905
+ try {
906
+ if (!workPending()) {
907
+ return
908
+ }
909
+ } catch {
910
+ // A trap is the only way this fails, and a trapped instance has no
911
+ // reachable work left. Stop polling and finish.
912
+ return
913
+ }
914
+ }
915
+ })().then(${finishWasmEnvCleanupAfterPoll}, ${finishWasmEnvCleanupAfterPoll})
916
+ }
917
+
313
918
  // Turns to wait for while the addon still reports queued settlements. Reaching
314
919
  // zero is the only success. A counter still nonzero at this bound rejects the
315
920
  // disposal as retryable (\`ERR_NAPI_WASI_CLEANUP_PENDING\`) rather than
@@ -447,6 +1052,19 @@ function __destroyEmnapiContext() {
447
1052
 
448
1053
  ${disposeCurrentThreadHosts}\
449
1054
  __prepareWasmEnvCleanup()
1055
+ if (__isPreparingWasmEnvCleanup()) {
1056
+ // Reached from inside the synchronous barrier — a promise hook one of the
1057
+ // settlements above ran, which is the reentrancy the destroy wrapper
1058
+ // exists for. \`Context.destroy()\` below would hit that wrapper's in-flight
1059
+ // no-op and answer \`undefined\`, and recording that as a completed destroy
1060
+ // is what makes the frame that *did* start the barrier skip the real one
1061
+ // afterwards, leaving the context retained with its cleanup hooks unrun.
1062
+ // Refuse instead: nothing is flagged, and that frame destroys for real the
1063
+ // moment it returns. The deferred loader carries the same backstop. A
1064
+ // parked handshake cannot get here — \`__prepareWasmEnvCleanup\` closes one
1065
+ // rather than skipping it.
1066
+ return
1067
+ }
450
1068
  const result = __emnapiContext.destroy()
451
1069
  if (!__isThenable(result)) {
452
1070
  __emnapiContextDestroyed = true
@@ -618,6 +1236,7 @@ function __drainWasiAsyncWork() {
618
1236
  await new Promise((resolve) => {
619
1237
  __scheduleTimer(resolve, __WASI_ASYNC_WORK_POLL_INTERVAL_MS)
620
1238
  })
1239
+ ${abortDisposalAfterThreadCrash(' ')}\
621
1240
  }
622
1241
  })(),
623
1242
  ).then(
@@ -713,6 +1332,7 @@ function __finishWasiDisposal() {
713
1332
  }
714
1333
 
715
1334
  function __continueWasiDisposal() {
1335
+ ${abortDisposalAfterThreadCrash(' ')}\
716
1336
  const destroyResult = __destroyEmnapiContext()
717
1337
  if (__isThenable(destroyResult)) {
718
1338
  return Promise.resolve(destroyResult).then(__finishWasiDisposal)
@@ -720,11 +1340,8 @@ function __continueWasiDisposal() {
720
1340
  return __finishWasiDisposal()
721
1341
  }
722
1342
 
723
- function __cleanUpWasmEnvForWasiDisposal() {
724
- // Run the pre-teardown barrier, then let the settlements it queued actually
725
- // reach JavaScript, and only then destroy the environment. Doing these two
726
- // back to back is what strands them.
727
- __prepareWasmEnvCleanup()
1343
+ function __drainWasmEnvForWasiDisposal() {
1344
+ ${abortDisposalAfterThreadCrash(' ')}\
728
1345
  const drainResult = __drainWasmEnvCleanup()
729
1346
  if (__isThenable(drainResult)) {
730
1347
  return Promise.resolve(drainResult).then(__continueWasiDisposal)
@@ -732,6 +1349,19 @@ function __cleanUpWasmEnvForWasiDisposal() {
732
1349
  return __continueWasiDisposal()
733
1350
  }
734
1351
 
1352
+ function __cleanUpWasmEnvForWasiDisposal() {
1353
+ ${abortDisposalAfterThreadCrash(' ')}\
1354
+ // Run the pre-teardown barrier — yielding the turns its two-phase form asks
1355
+ // for, when the addon has one — then let the settlements it queued actually
1356
+ // reach JavaScript, and only then destroy the environment. Doing any two of
1357
+ // these back to back is what strands them.
1358
+ const prepareResult = __prepareWasmEnvCleanupWithTurns()
1359
+ if (__isThenable(prepareResult)) {
1360
+ return Promise.resolve(prepareResult).then(__drainWasmEnvForWasiDisposal)
1361
+ }
1362
+ return __drainWasmEnvForWasiDisposal()
1363
+ }
1364
+
735
1365
  function __startWasiDisposal() {
736
1366
  // Outstanding \`napi_async_work\` goes first, while the environment is still
737
1367
  // completely live: the completion callbacks run addon code, and everything
@@ -757,6 +1387,7 @@ function __disposeWasiBinding() {
757
1387
  if (__wasiDisposePromise) {
758
1388
  return __wasiDisposePromise
759
1389
  }
1390
+ ${disposeAfterThreadCrash}\
760
1391
  if (__wasiDisposed) {
761
1392
  return Promise.resolve()
762
1393
  }
@@ -773,6 +1404,7 @@ function __disposeWasiBinding() {
773
1404
  try {
774
1405
  result = __startWasiDisposal()
775
1406
  } catch (error) {
1407
+ ${settleDisposalAfterThreadCrash(' ', 'return disposePromise')}\
776
1408
  __wasiDisposePromise = undefined
777
1409
  rejectDispose(error)
778
1410
  return disposePromise
@@ -784,6 +1416,7 @@ function __disposeWasiBinding() {
784
1416
  resolveDispose(value)
785
1417
  },
786
1418
  (error) => {
1419
+ ${settleDisposalAfterThreadCrash(' ', 'return')}\
787
1420
  __wasiDisposePromise = undefined
788
1421
  rejectDispose(error)
789
1422
  },
@@ -819,6 +1452,7 @@ function __finishWasiInitializationRollback(cleanupErrors) {
819
1452
  }
820
1453
 
821
1454
  function __destroyContextForWasiRollback(cleanupErrors) {
1455
+ ${abortDisposalAfterThreadCrash(' ')}\
822
1456
  let destroyResult
823
1457
  try {
824
1458
  destroyResult = __destroyEmnapiContext()
@@ -887,19 +1521,43 @@ function __retainFailedWasiRollback(cleanupErrors) {
887
1521
  * goes away. That is the deliberate choice: a hung promise is a silent liveness
888
1522
  * bug with no upper bound, while the retained bookkeeping is bounded by the page.
889
1523
  */
890
- function __rollbackWasiInitialization() {
1524
+ function ${rollbackStepsName}() {
891
1525
  // The environment teardown this rollback performs, kept nested so it cannot
892
1526
  // be reached without the async-work drain below running first.
893
1527
  function __rollbackWasmEnvForWasiInitialization() {
1528
+ ${abortDisposalAfterThreadCrash(' ')}\
894
1529
  const cleanupErrors = []
1530
+ let prepareResult
1531
+ try {
1532
+ prepareResult = __prepareWasmEnvCleanupWithTurns()
1533
+ } catch (cleanupError) {
1534
+ cleanupErrors.push(cleanupError)
1535
+ return __retainFailedWasiRollback(cleanupErrors)
1536
+ }
1537
+ if (__isThenable(prepareResult)) {
1538
+ return Promise.resolve(prepareResult).then(
1539
+ () => __drainWasmEnvForWasiRollback(cleanupErrors),
1540
+ (cleanupError) => {
1541
+ cleanupErrors.push(cleanupError)
1542
+ return __retainFailedWasiRollback(cleanupErrors)
1543
+ },
1544
+ )
1545
+ }
1546
+ return __drainWasmEnvForWasiRollback(cleanupErrors)
1547
+ }
1548
+
1549
+ // The settlement drain of the rollback above, reached either straight away or
1550
+ // after the barrier's two-phase form has yielded its turns. A barrier that
1551
+ // did not finish never gets here: it retains instead, exactly as a drain that
1552
+ // did not finish does.
1553
+ function __drainWasmEnvForWasiRollback(cleanupErrors) {
1554
+ ${abortDisposalAfterThreadCrash(' ')}\
895
1555
  let drainResult
896
- let settlementsUnreached = false
897
1556
  try {
898
- __prepareWasmEnvCleanup()
899
1557
  drainResult = __drainWasmEnvCleanup()
900
1558
  } catch (cleanupError) {
901
1559
  cleanupErrors.push(cleanupError)
902
- settlementsUnreached = true
1560
+ return __retainFailedWasiRollback(cleanupErrors)
903
1561
  }
904
1562
  if (__isThenable(drainResult)) {
905
1563
  return Promise.resolve(drainResult).then(
@@ -910,9 +1568,6 @@ function __rollbackWasiInitialization() {
910
1568
  },
911
1569
  )
912
1570
  }
913
- if (settlementsUnreached) {
914
- return __retainFailedWasiRollback(cleanupErrors)
915
- }
916
1571
  return __destroyContextForWasiRollback(cleanupErrors)
917
1572
  }
918
1573
 
@@ -943,6 +1598,7 @@ function __rollbackWasiInitialization() {
943
1598
  }
944
1599
  return __rollbackWasmEnvForWasiInitialization()
945
1600
  }
1601
+ ${rollbackAfterThreadCrash}\
946
1602
  `
947
1603
  }
948
1604
 
@@ -1096,10 +1752,35 @@ const __workerPoolSize = Math.max(
1096
1752
  type: 'module',
1097
1753
  })
1098
1754
  __wasiWorkers.add(worker)
1755
+ __shareWasiAddonCrashFlag(worker)
1099
1756
  ${workerFsHandler}
1100
1757
  ${workerErrorHandler}
1101
1758
  return worker
1102
1759
  },
1760
+ `
1761
+ : ''
1762
+ // The pool is created before the wasm is instantiated, so the workers that
1763
+ // exist by `beforeInit` get the view there, after their 'load' message and
1764
+ // before any 'start'; a worker created later gets it first thing. A message
1765
+ // rather than worker options: a browser Worker has no `workerData`.
1766
+ const addonCrashFlagSharing = threads
1767
+ ? `${ADDON_CRASH_FLAG_CAPTURE}
1768
+ function __shareWasiAddonCrashFlag(worker) {
1769
+ if (__wasiAddonCrashFlag === undefined) {
1770
+ return
1771
+ }
1772
+ try {
1773
+ worker.postMessage({ __napiRsAddonCrashFlag: __wasiAddonCrashFlag })
1774
+ } catch {}
1775
+ }
1776
+
1777
+ `
1778
+ : ''
1779
+ const captureAddonCrashFlag = threads
1780
+ ? ` __captureWasiAddonCrashFlag(instance)
1781
+ for (const worker of __wasiWorkers) {
1782
+ __shareWasiAddonCrashFlag(worker)
1783
+ }
1103
1784
  `
1104
1785
  : ''
1105
1786
 
@@ -1139,6 +1820,7 @@ ${threads ? ' shared: true,\n' : ''}\
1139
1820
  ${workerPoolSizeBinding}\
1140
1821
  let __emnapiContext
1141
1822
  ${createEmnapiContextLifecycle(asyncRuntime)}
1823
+ ${addonCrashFlagSharing}\
1142
1824
  let __wasiModule
1143
1825
  let __napiModule
1144
1826
 
@@ -1172,6 +1854,7 @@ ${workerOption}\
1172
1854
  },
1173
1855
  beforeInit({ instance }) {
1174
1856
  __napiInstance = instance
1857
+ ${captureAddonCrashFlag}\
1175
1858
  for (const name of Object.keys(instance.exports)) {
1176
1859
  if (name.startsWith('__napi_register__')) {
1177
1860
  instance.exports[name]()
@@ -1716,6 +2399,218 @@ function __scheduleTimer(__callback, __delay) {
1716
2399
  }
1717
2400
  }
1718
2401
 
2402
+ // A real, referenced timer rather than a zero-delay macrotask, for the same
2403
+ // reason the async-work wait uses one: this polls the addon instead of
2404
+ // interleaving with the @emnapi/core dispatch, so a zero-delay turn would spin
2405
+ // the loop instead of yielding it.
2406
+ const __WASM_RUNTIME_WORK_POLL_INTERVAL_MS = 1
2407
+ // Arrivals it takes before the poll paces on the host's timers alone. One
2408
+ // proves nothing: a timer armed before the host's timers stopped still fires.
2409
+ const __WASM_RUNTIME_WORK_POLL_TRUSTED_ARRIVALS = 2
2410
+ // How long a parked turn's own timer must already have been due before a
2411
+ // backup that runs calls it dropped. Slack, not a deadline: a timer is due
2412
+ // against the event loop's clock, which is read once per iteration, while
2413
+ // these are \`Date.now()\` readings taken part-way through one, so the two
2414
+ // drift apart by however long the loop has been inside the current iteration.
2415
+ const __WASM_RUNTIME_WORK_POLL_STALL_MS = 50
2416
+ // How long a backup itself waits. What is left of it after the slack and one
2417
+ // interval — 149 ms — has to cover the *two* poll turns that can separate a
2418
+ // parked turn from the last backup armed while the host's timers still
2419
+ // worked, so the ceiling on a single turn is half of it. See the invariant on
2420
+ // \`__armWasmRuntimePollStallBackup\`.
2421
+ const __WASM_RUNTIME_WORK_POLL_BACKUP_MS = 200
2422
+
2423
+ /**
2424
+ * Pacing state for one runtime-work poll.
2425
+ *
2426
+ * Per poll, never per module: whether the host's timers arrive is not a
2427
+ * property of the module. A host can lose its timers between two disposals,
2428
+ * and in the deferred shape every instance shares this module — one healthy
2429
+ * instance must not disarm the fallback for the next one.
2430
+ */
2431
+ function __createWasmRuntimePollPace() {
2432
+ return {
2433
+ // Timers armed by *this* poll that have actually arrived.
2434
+ arrivals: 0,
2435
+ // The turn waiting on a timer alone *right now* — undefined whenever no
2436
+ // turn is parked — and when that turn's own timer came due.
2437
+ settleTurn: undefined,
2438
+ turnTimerDueAt: 0,
2439
+ }
2440
+ }
2441
+
2442
+ /**
2443
+ * The backup that ends a turn whose timer is never going to arrive.
2444
+ *
2445
+ * Once the poll paces on the timer alone it has nothing left to fall back on
2446
+ * if the host's timers stop mid-poll: the turn that armed the dead timer is
2447
+ * the turn that parks, and a parked poll schedules nothing that could notice.
2448
+ * So every turn arms one of these before it yields, and each one compares due
2449
+ * times instead of measuring how long the parked turn has been waiting.
2450
+ *
2451
+ * Invariant: a parked turn is ended by the newest backup that was armed while
2452
+ * the host's timers still worked, and a backup ends a turn only when that
2453
+ * turn's own timer was already due a whole window before the backup itself.
2454
+ * Neither half turns on how far apart the arms happen to fall — what bounds
2455
+ * the rescue is how far back that newest live backup is:
2456
+ *
2457
+ * - *Ends it.* Hosts run timers in due order, so a backup that runs while a
2458
+ * turn due a whole window earlier is still parked proves that turn's timer
2459
+ * was dropped rather than merely late. That same comparison is what leaves a
2460
+ * healthy host alone: there the turn's timer has already run and cleared
2461
+ * \`settleTurn\` before any backup due after it can look.
2462
+ * - *Two turns back, not one.* A turn that ended does not prove its own timer
2463
+ * arrived: until \`…_TRUSTED_ARRIVALS\` is reached every turn arms both
2464
+ * primitives and the macrotask wins, so such a turn can end with its own
2465
+ * timer — and the backup armed one line before it — already dead. The
2466
+ * arrival that then flips the poll onto the timer alone can itself be a
2467
+ * timer armed before the host's timers died. So the turn that parks can sit
2468
+ * two turns past the last live arm, and the newest live backup is due
2469
+ * \`…_BACKUP_MS\` less *two* turn lengths after that turn's own timer.
2470
+ * Arming on every turn is what holds it to two, rather than however far back
2471
+ * a throttle last let one through.
2472
+ * - *Ceiling.* Coverage therefore holds while two consecutive poll turns fit
2473
+ * inside \`…_BACKUP_MS\` less the slack and one interval: 149 ms, so 74 ms
2474
+ * per turn (measured: a 74 ms turn is still rescued, a 75 ms one parks).
2475
+ * Past that the turn stays parked and the disposal promise never settles.
2476
+ * The bound is deliberate: reaching it takes a host that drops timers
2477
+ * mid-poll *and* keeps every poll turn busy for more than 74 ms, and neither
2478
+ * Node nor WebContainer — the hosts that run the threaded artifact — does
2479
+ * the second.
2480
+ *
2481
+ * The poll then goes back to arming both primitives until two fresh arrivals
2482
+ * prove the timers again. A host that stops running the timers it has
2483
+ * *already* accepted leaves nothing to fire, and the disposal promise stays
2484
+ * pending rather than wedging the thread — the same outcome as a blocking
2485
+ * closure that never returns. Unreferenced wherever the host allows it: the
2486
+ * poll's own turn timers are what keep the loop alive, never these.
2487
+ */
2488
+ function __armWasmRuntimePollStallBackup(__pace) {
2489
+ const __setTimer = globalThis.setTimeout
2490
+ if (typeof __setTimer !== 'function') {
2491
+ // Nothing to back up: \`__scheduleTimer\` is on the macrotask channel
2492
+ // already, and that one cannot park.
2493
+ return
2494
+ }
2495
+ // Read before arming, so this never claims to be due earlier than the timer
2496
+ // actually is: a backup ends a turn only when it is provably due after it.
2497
+ const __dueAt = Date.now() + __WASM_RUNTIME_WORK_POLL_BACKUP_MS
2498
+ let __handle
2499
+ try {
2500
+ __handle = __setTimer(() => {
2501
+ const __settleTurn = __pace.settleTurn
2502
+ if (
2503
+ !__settleTurn ||
2504
+ __pace.turnTimerDueAt > __dueAt - __WASM_RUNTIME_WORK_POLL_STALL_MS
2505
+ ) {
2506
+ // No turn is parked, or the parked one's timer came due too close to
2507
+ // this backup to call it dropped — it may still arrive, and the turn
2508
+ // that armed it armed a backup due a whole window after *that*.
2509
+ return
2510
+ }
2511
+ __pace.arrivals = 0
2512
+ __pace.settleTurn = undefined
2513
+ __settleTurn()
2514
+ }, __WASM_RUNTIME_WORK_POLL_BACKUP_MS)
2515
+ } catch {
2516
+ return
2517
+ }
2518
+ if (__handle && typeof __handle.unref === 'function') {
2519
+ try {
2520
+ __handle.unref()
2521
+ } catch {}
2522
+ }
2523
+ }
2524
+
2525
+ /**
2526
+ * One turn of the runtime-work poll.
2527
+ *
2528
+ * \`__scheduleTimer\` falls back to the macrotask scheduler when \`setTimeout\` is
2529
+ * missing or throws, but not when it is present, returns a handle and never
2530
+ * fires — fake timers in a test suite that disposes from an \`afterEach\`, or a
2531
+ * host whose timers belong to an IO context that is already gone. That host
2532
+ * would park this poll forever, and the poll is unbounded, so nothing would
2533
+ * ever call \`…_finish\`.
2534
+ *
2535
+ * Arm both primitives until timers armed by this poll have arrived twice, and
2536
+ * let whichever lands first end the turn; the loser resolves nothing. A host
2537
+ * with working timers therefore pays the double arming for the first turn or
2538
+ * two — the macrotask wins the race, but the timers behind it still arrive and
2539
+ * are counted — and paces on the timer alone from then on, instead of spinning
2540
+ * the loop on a zero-delay queue. A host whose timers never arrive keeps both,
2541
+ * and the macrotask is what keeps the poll moving. A host whose timers stop
2542
+ * after proving themselves is caught by \`__armWasmRuntimePollStallBackup\`,
2543
+ * which ends the parked turn and puts this poll back on both.
2544
+ */
2545
+ function __yieldWasmRuntimePollTurn(__pace) {
2546
+ // Armed before the turn yields, and by every turn: what rescues a parked
2547
+ // turn has to have been armed while the host's timers still worked, and the
2548
+ // turn that parks is the one whose own timer is already dead.
2549
+ __armWasmRuntimePollStallBackup(__pace)
2550
+ return new Promise((resolve) => {
2551
+ let __settled = false
2552
+ const __settle = () => {
2553
+ if (__settled) {
2554
+ return
2555
+ }
2556
+ __settled = true
2557
+ if (__pace.settleTurn === __settle) {
2558
+ // Nothing is parked any more: a backup running later must not read a
2559
+ // due time this turn has already answered.
2560
+ __pace.settleTurn = undefined
2561
+ }
2562
+ resolve()
2563
+ }
2564
+ __scheduleTimer(() => {
2565
+ __pace.arrivals++
2566
+ __settle()
2567
+ }, __WASM_RUNTIME_WORK_POLL_INTERVAL_MS)
2568
+ // Read next to the arming it describes; see
2569
+ // \`__armWasmRuntimePollStallBackup\` for what the two due times mean.
2570
+ const __turnTimerDueAt = Date.now() + __WASM_RUNTIME_WORK_POLL_INTERVAL_MS
2571
+ if (__pace.arrivals < __WASM_RUNTIME_WORK_POLL_TRUSTED_ARRIVALS) {
2572
+ __scheduleMacrotask(__settle)
2573
+ return
2574
+ }
2575
+ // Paced by the timer alone from here; the backup is what ends this turn if
2576
+ // the timer never arrives.
2577
+ __pace.settleTurn = __settle
2578
+ __pace.turnTimerDueAt = __turnTimerDueAt
2579
+ })
2580
+ }
2581
+
2582
+ /**
2583
+ * Yield event-loop turns until the addon reports its runtime work finished.
2584
+ *
2585
+ * The window between \`napi_prepare_wasm_env_cleanup_begin\` and
2586
+ * \`…_finish\` — the turns are the entire point of splitting the barrier, because
2587
+ * on a threaded artifact the work \`…_finish\` joins can itself be waiting for a
2588
+ * JavaScript turn from this very thread.
2589
+ *
2590
+ * Unbounded, for the same reason the async-work wait above is: giving up means
2591
+ * calling \`…_finish\`, which joins on this thread, and the work it would join is
2592
+ * the work waiting for a turn from this thread — so a bound does not end the
2593
+ * wait, it only moves it somewhere the JavaScript thread can no longer be
2594
+ * reached. A blocking closure that never returns keeps the disposal promise
2595
+ * pending instead. The host contract is in \`crates/async-runtime/README.md\`: a
2596
+ * blocking closure must never wait on a JavaScript turn.
2597
+ */
2598
+ async function __pollWasmRuntimeWork(__workPending) {
2599
+ const __pace = __createWasmRuntimePollPace()
2600
+ for (;;) {
2601
+ await __yieldWasmRuntimePollTurn(__pace)
2602
+ try {
2603
+ if (!__workPending()) {
2604
+ return
2605
+ }
2606
+ } catch {
2607
+ // A trap is the only way this fails, and a trapped instance has no
2608
+ // reachable work left. Stop polling and finish.
2609
+ return
2610
+ }
2611
+ }
2612
+ }
2613
+
1719
2614
  // How often to re-read \`napi_wasm_async_work_pending\` while waiting. The wait
1720
2615
  // ends when the addon reports zero, so this only decides how promptly disposal
1721
2616
  // notices — not how long it waits.
@@ -2087,7 +2982,9 @@ ${managedHostDisposeParam}) {
2087
2982
  // hooks unrun. Refuse instead: nothing is flagged, the context stays
2088
2983
  // registered for managed beforeExit cleanup, and a later destroy still
2089
2984
  // works. dispose() coalesces reentrancy before it can get here, so this
2090
- // is the backstop for any other caller that manages to.
2985
+ // is the backstop for any other caller that manages to. A handshake
2986
+ // parked between the two halves of the barrier does not reach here —
2987
+ // \`__prepareEnvCleanup\` closes one rather than skipping it.
2091
2988
  throw __createLifecycleReentryError('dispose')
2092
2989
  }
2093
2990
  ${managedHostDisposeCall}\
@@ -2220,11 +3117,82 @@ ${instanceHostState}\
2220
3117
  let __wasmEnvCleanupRan = false
2221
3118
  let __wasmEnvCleanupPrepared = false
2222
3119
  let __wasmEnvCleanupPreparing = false
3120
+ // The closer for a barrier parked between \`…_begin\` and \`…_finish\`, set only
3121
+ // while that window is open. \`__wasmEnvCleanupPreparing\` cannot tell that
3122
+ // apart from a purely synchronous frame, which must not be re-entered and
3123
+ // which nothing outside it can finish; this window spans real event-loop
3124
+ // turns, so a caller that cannot yield — the managed beforeExit teardown —
3125
+ // can land inside it, and can close it. See \`__prepareEnvCleanup\`.
3126
+ let __finishParkedEnvCleanup
3127
+ // Raised while a caller that can still yield is driving the barrier, so the
3128
+ // queue it leaves behind is expected rather than lost.
3129
+ let __wasmEnvCleanupYielding = false
3130
+ let __wasmEnvSettlementLossReported = false
2223
3131
  let __wasmEnvCleanupDrained = false
2224
3132
  let __wasmEnvCleanupDrainPromise
2225
3133
  const __isPreparingEnvCleanup = () => __wasmEnvCleanupPreparing
3134
+ /**
3135
+ * Say so when the barrier leaves settlements queued and nothing is left that
3136
+ * could deliver them.
3137
+ *
3138
+ * Only \`dispose()\` and the initialization rollback yield the event-loop turns
3139
+ * @emnapi/core needs to dispatch its queue. Every other caller of the barrier
3140
+ * destroys in the same turn — a raw \`Context.destroy()\`, the managed
3141
+ * beforeExit teardown — and \`Context.destroy()\` runs the threadsafe
3142
+ * function's cleanup hook, which drains that queue with a null env and
3143
+ * discards it. The promises those settlements were for then hang forever,
3144
+ * silently.
3145
+ *
3146
+ * Loud, once, and never throwing: this runs from inside \`Context.destroy()\`,
3147
+ * where throwing would take the whole teardown down with it. Destroying
3148
+ * anyway is still the right trade — the queue is already unreachable by then.
3149
+ */
3150
+ const __reportUnreachedSettlements = () => {
3151
+ if (__wasmEnvCleanupYielding || __wasmEnvSettlementLossReported) {
3152
+ return
3153
+ }
3154
+ const __pending = __napiInstance?.exports.napi_wasm_env_cleanup_pending
3155
+ if (typeof __pending !== 'function') {
3156
+ return
3157
+ }
3158
+ let __queued
3159
+ try {
3160
+ __queued = __pending()
3161
+ } catch {
3162
+ return
3163
+ }
3164
+ if (!__queued) {
3165
+ return
3166
+ }
3167
+ __wasmEnvSettlementLossReported = true
3168
+ try {
3169
+ const __consoleHost = globalThis.console
3170
+ if (__consoleHost && typeof __consoleHost.error === 'function') {
3171
+ __consoleHost.error(
3172
+ 'napi-rs: the wasm environment is being destroyed with ' +
3173
+ __queued +
3174
+ ' queued promise settlement(s). Context.destroy() discards them, so those promises never settle. Dispose the instance instead: only dispose() yields the event-loop turns the settlements need.',
3175
+ )
3176
+ }
3177
+ } catch {}
3178
+ }
2226
3179
  const __prepareEnvCleanup = () => {
2227
- if (__wasmEnvCleanupPrepared || __wasmEnvCleanupPreparing) {
3180
+ if (__wasmEnvCleanupPrepared) {
3181
+ return
3182
+ }
3183
+ // A handshake parked between its two halves is one this frame can close,
3184
+ // and must: every caller of this is about to destroy the context, and the
3185
+ // turns the poll is waiting for will not come. Closing it runs \`…_finish\`,
3186
+ // which is the call that joins, so this degrades to exactly the single
3187
+ // call below. Leaving it open destroys the context with the barrier still
3188
+ // raised and the runtime never joined.
3189
+ const __finishParked = __finishParkedEnvCleanup
3190
+ if (__finishParked !== undefined) {
3191
+ __finishParked()
3192
+ __reportUnreachedSettlements()
3193
+ return
3194
+ }
3195
+ if (__wasmEnvCleanupPreparing) {
2228
3196
  return
2229
3197
  }
2230
3198
  const __prepareWasmEnvCleanup =
@@ -2240,9 +3208,86 @@ ${instanceHostState}\
2240
3208
  __wasmEnvCleanupPreparing = false
2241
3209
  }
2242
3210
  __wasmEnvCleanupRan = true
3211
+ __reportUnreachedSettlements()
2243
3212
  }
2244
3213
  __wasmEnvCleanupPrepared = true
2245
3214
  }
3215
+ /**
3216
+ * The barrier for the callers that can yield: \`__prepareEnvCleanup\` with real
3217
+ * event-loop turns in the middle.
3218
+ *
3219
+ * \`napi_prepare_wasm_env_cleanup\` waits — it returns only once the addon's
3220
+ * async runtime has quiesced, and the work it waits for can itself be waiting
3221
+ * for a JavaScript turn from this thread. The addon's two-phase form splits
3222
+ * that: \`…_begin\` stops the runtime without joining and reports whether
3223
+ * anything is still live, \`napi_wasm_runtime_work_pending\` answers that again
3224
+ * without blocking, and \`…_finish\` joins.
3225
+ *
3226
+ * The poll is unbounded — see \`__pollWasmRuntimeWork\` — but a caller that
3227
+ * cannot yield closes the handshake itself rather than waiting for it, so
3228
+ * \`…_finish\` still runs on every teardown path. Feature-detected like every
3229
+ * other export here, and returns nothing whenever the handshake finished
3230
+ * without yielding.
3231
+ */
3232
+ const __prepareEnvCleanupWithTurns = () => {
3233
+ if (__wasmEnvCleanupPrepared || __wasmEnvCleanupPreparing) {
3234
+ return
3235
+ }
3236
+ const __exports = __napiInstance?.exports
3237
+ const __begin = __exports?.napi_prepare_wasm_env_cleanup_begin
3238
+ const __finish = __exports?.napi_prepare_wasm_env_cleanup_finish
3239
+ if (typeof __begin !== 'function' || typeof __finish !== 'function') {
3240
+ // No split to use. The settlement drain still follows this, so the queue
3241
+ // the single call leaves behind is expected rather than lost.
3242
+ __wasmEnvCleanupYielding = true
3243
+ try {
3244
+ __prepareEnvCleanup()
3245
+ } finally {
3246
+ __wasmEnvCleanupYielding = false
3247
+ }
3248
+ return
3249
+ }
3250
+ const __workPending = __exports?.napi_wasm_runtime_work_pending
3251
+ // The in-flight flag stays raised across the turns below, so a \`destroy()\`
3252
+ // from one of the JavaScript handlers they run is the same no-op it is
3253
+ // inside the single call: the barrier is up and the runtime is
3254
+ // mid-teardown, and destroying between the halves would strand exactly what
3255
+ // this delivers.
3256
+ __wasmEnvCleanupPreparing = true
3257
+ let __live
3258
+ try {
3259
+ __live = __begin()
3260
+ } catch (__error) {
3261
+ __wasmEnvCleanupPreparing = false
3262
+ throw __error
3263
+ }
3264
+ __wasmEnvCleanupRan = true
3265
+ const __finishEnvCleanup = () => {
3266
+ if (__wasmEnvCleanupPrepared) {
3267
+ // Already closed by a caller that could not yield. \`…_finish\` is
3268
+ // idempotent, but the flags it lowers are not.
3269
+ return
3270
+ }
3271
+ __finishParkedEnvCleanup = undefined
3272
+ try {
3273
+ __finish()
3274
+ } finally {
3275
+ __wasmEnvCleanupPreparing = false
3276
+ }
3277
+ __wasmEnvCleanupPrepared = true
3278
+ }
3279
+ if (!__live || typeof __workPending !== 'function') {
3280
+ __finishEnvCleanup()
3281
+ return
3282
+ }
3283
+ // Publish the closer before yielding: from here until \`__finishEnvCleanup\`
3284
+ // runs, a caller that cannot yield is entitled to end this handshake.
3285
+ __finishParkedEnvCleanup = __finishEnvCleanup
3286
+ return __pollWasmRuntimeWork(__workPending).then(
3287
+ __finishEnvCleanup,
3288
+ __finishEnvCleanup,
3289
+ )
3290
+ }
2246
3291
  // The barrier + settlement drain, hoisted out of the context destroyer so the
2247
3292
  // drain can yield without widening the destroyer's reentry window. Both
2248
3293
  // yielding paths run it — dispose() and the initialization-failure rollback.
@@ -2255,6 +3300,20 @@ ${instanceHostState}\
2255
3300
  // is enough — and dispose() stays retryable after it rejects, so marking the
2256
3301
  // drain complete up front would make the retry skip it and destroy the context
2257
3302
  // with the barrier's settlements still queued.
3303
+ const __drainAfterEnvCleanup = () => {
3304
+ if (!__wasmEnvCleanupRan) {
3305
+ return
3306
+ }
3307
+ const __drained = __drainWasmEnvCleanup(__napiInstance)
3308
+ if (!__drained || typeof __drained.then !== 'function') {
3309
+ __wasmEnvCleanupDrained = true
3310
+ return
3311
+ }
3312
+ return __drained.then((__value) => {
3313
+ __wasmEnvCleanupDrained = true
3314
+ return __value
3315
+ })
3316
+ }
2258
3317
  const __prepareForDisposal = () => {
2259
3318
  if (__wasmEnvCleanupDrained) {
2260
3319
  return
@@ -2262,18 +3321,19 @@ ${instanceHostState}\
2262
3321
  if (__wasmEnvCleanupDrainPromise) {
2263
3322
  return __wasmEnvCleanupDrainPromise
2264
3323
  }
2265
- __prepareEnvCleanup()
2266
- if (!__wasmEnvCleanupRan) {
3324
+ // The barrier itself can yield now, so the memo below has to cover it too:
3325
+ // a reentrant caller must join this handshake rather than start a second
3326
+ // one while the first is parked between the two halves.
3327
+ const __prepared = __prepareEnvCleanupWithTurns()
3328
+ const __settled =
3329
+ __prepared && typeof __prepared.then === 'function'
3330
+ ? __prepared.then(__drainAfterEnvCleanup)
3331
+ : __drainAfterEnvCleanup()
3332
+ if (!__settled || typeof __settled.then !== 'function') {
2267
3333
  return
2268
3334
  }
2269
- const __drained = __drainWasmEnvCleanup(__napiInstance)
2270
- if (!__drained || typeof __drained.then !== 'function') {
2271
- __wasmEnvCleanupDrained = true
2272
- return
2273
- }
2274
- const __tracked = __drained.then(
3335
+ const __tracked = __settled.then(
2275
3336
  (__value) => {
2276
- __wasmEnvCleanupDrained = true
2277
3337
  __wasmEnvCleanupDrainPromise = undefined
2278
3338
  return __value
2279
3339
  },
@@ -2828,9 +3888,11 @@ export const createWasiBinding = (
2828
3888
  } = require('@napi-rs/async-runtime')
2829
3889
  `
2830
3890
  : ''
3891
+ // The threaded loader has the thread crash latch, whose disposal releases
3892
+ // the host timers it tracks. See \`__trackCurrentThreadHostTimers\`.
2831
3893
  const installAsyncRuntimeHosts = asyncRuntime
2832
3894
  ? ` __currentThreadHostsDisposer = __installCurrentThreadHosts(
2833
- __napiModule.exports,
3895
+ ${threads ? '__trackCurrentThreadHostTimers(__napiModule.exports)' : '__napiModule.exports'},
2834
3896
  )
2835
3897
  `
2836
3898
  : ''
@@ -2914,7 +3976,13 @@ function __createWasiWorker(filename) {
2914
3976
  return new Worker(filename, {
2915
3977
  env: process.env,
2916
3978
  execArgv: __workerExecArgv,
2917
- workerData: { hostRoot: __hostRoot, rootDir: __rootDir },
3979
+ workerData: {
3980
+ hostRoot: __hostRoot,
3981
+ rootDir: __rootDir,
3982
+ crashFlag: __wasiThreadCrashFlag,
3983
+ crashReport: __wasiThreadCrashReport,
3984
+ addonCrashFlag: __wasiAddonCrashFlag,
3985
+ },
2918
3986
  })
2919
3987
  } catch (error) {
2920
3988
  if (!error || error.code !== 'ERR_WORKER_INVALID_EXEC_ARGV') {
@@ -2929,6 +3997,306 @@ function __createWasiWorker(filename) {
2929
3997
  }
2930
3998
  }
2931
3999
  }
4000
+ `
4001
+ : ''
4002
+ // The host timeouts a CurrentThread sleep armed, released without entering
4003
+ // wasm on every crash path. See `__trackCurrentThreadHostTimers`.
4004
+ const releaseCurrentThreadHostTimers = (indent: string) =>
4005
+ threads && asyncRuntime
4006
+ ? `${indent}__releaseCurrentThreadHostTimers()\n`
4007
+ : ''
4008
+ // Only the threaded flavor has pool workers whose wasm thread can die under
4009
+ // this one. See `__disposeWasiBindingAtExit`.
4010
+ const threadCrashLatch = threads
4011
+ ? `
4012
+ // Set to 1 by a pool worker (see wasi-worker.mjs) right before it reports that
4013
+ // its wasm thread died. Shared memory, so this thread reads it synchronously even
4014
+ // while the worker's 'error' event is still queued behind the code that is
4015
+ // exiting right now.
4016
+ const __wasiThreadCrashFlag = new Int32Array(new SharedArrayBuffer(4))
4017
+ // Written by the first pool worker whose wasm thread dies, before it raises the
4018
+ // flag: its error and \`threadId\` (layout in wasi-worker.mjs). Read without
4019
+ // waiting for any event, see \`__readWasiThreadCrashReport\`.
4020
+ const __wasiThreadCrashReport = new SharedArrayBuffer(4096)
4021
+ let __wasiThreadCrashReportRead
4022
+ // The first error a pool worker reported through its 'error' event, and that
4023
+ // worker's \`threadId\`. See \`__getWasiThreadCrashError\`.
4024
+ let __wasiThreadCrashWorkerError
4025
+ let __wasiThreadCrashWorkerId
4026
+ let __wasiThreadCrashError
4027
+ // Raised while \`__runWasiInitializationRollbackSteps\` runs, so its polls and
4028
+ // steps stop after a crash the way a public disposal's do.
4029
+ let __wasiInitializationRollbackActive = false
4030
+ let __wasiThreadCrashed = false
4031
+
4032
+ ${ADDON_CRASH_FLAG_CAPTURE}
4033
+ /**
4034
+ * Whether any wasm thread of this binding has died: a trap or an uncaught error
4035
+ * in a pool worker, including one that failed to load after its thread spawn
4036
+ * had already been reported as started.
4037
+ *
4038
+ * After that the shared wasm state cannot be trusted: a lock the dead thread
4039
+ * held stays held, and a join or park that waits on it — the async runtime's
4040
+ * \`finish_shutdown\` waiting for the dead thread's work to go idle — blocks
4041
+ * this thread forever in a raw \`memory.atomic.wait32\`, which neither
4042
+ * emnapi's crash check nor a signal can interrupt.
4043
+ */
4044
+ function __hasWasiThreadCrashed() {
4045
+ if (__wasiThreadCrashed || Atomics.load(__wasiThreadCrashFlag, 0) !== 0) {
4046
+ return true
4047
+ }
4048
+ const manager = __getWasiThreadManager()
4049
+ return Boolean(manager && manager._fatalError)
4050
+ }
4051
+
4052
+ let __wasiThreadCrashDisposePromise
4053
+
4054
+ /**
4055
+ * Stores the first error a pool worker reported, with the worker's id. Called
4056
+ * from the loader's own 'error' listener, which runs before emnapi's.
4057
+ */
4058
+ function __recordWasiThreadCrashError(error, workerId) {
4059
+ if (__wasiThreadCrashWorkerError !== undefined || error === undefined) {
4060
+ return
4061
+ }
4062
+ __wasiThreadCrashWorkerError = error
4063
+ __wasiThreadCrashWorkerId = workerId
4064
+ __fillWasiThreadCrashError()
4065
+ }
4066
+
4067
+ /**
4068
+ * The error in the shared crash report, rebuilt once it is complete: the
4069
+ * worker writes it before it raises the flag, so it is there as soon as the
4070
+ * crash is seen. Plain JavaScript over shared memory; never enters wasm.
4071
+ */
4072
+ function __readWasiThreadCrashReport() {
4073
+ if (__wasiThreadCrashReportRead !== undefined) {
4074
+ return __wasiThreadCrashReportRead
4075
+ }
4076
+ try {
4077
+ const header = new Int32Array(__wasiThreadCrashReport, 0, 3)
4078
+ if (Atomics.load(header, 0) !== 2) {
4079
+ return
4080
+ }
4081
+ const length = Atomics.load(header, 1)
4082
+ const threadId = Atomics.load(header, 2)
4083
+ let error
4084
+ if (length > 0) {
4085
+ // Copied out of shared memory: TextDecoder does not take a shared view.
4086
+ const bytes = new Uint8Array(__wasiThreadCrashReport, 12, length).slice()
4087
+ const report = JSON.parse(new TextDecoder().decode(bytes))
4088
+ error = new Error(String(report.message))
4089
+ if (typeof report.name === 'string') {
4090
+ error.name = report.name
4091
+ }
4092
+ if (typeof report.stack === 'string') {
4093
+ error.stack = report.stack
4094
+ }
4095
+ }
4096
+ __wasiThreadCrashReportRead = { error, threadId }
4097
+ } catch {
4098
+ __wasiThreadCrashReportRead = { error: undefined, threadId: 0 }
4099
+ }
4100
+ return __wasiThreadCrashReportRead
4101
+ }
4102
+
4103
+ /**
4104
+ * The one error every crash path of this binding reports, created on first
4105
+ * use.
4106
+ *
4107
+ * The shared flag is raised before this thread has processed the worker's
4108
+ * 'error' event, and once the workers are terminated that event is often
4109
+ * never delivered, so neither it nor emnapi's \`_fatalError\` can be counted
4110
+ * on. The cause is filled in from the first source that has it: the error the
4111
+ * 'error' listener kept, else the worker's shared crash report, else
4112
+ * \`_fatalError\` — here, and again from the 'error' listener when it arrives
4113
+ * later. Until then the error carries only its message.
4114
+ */
4115
+ function __getWasiThreadCrashError() {
4116
+ if (__wasiThreadCrashError === undefined) {
4117
+ __wasiThreadCrashError = new Error(
4118
+ 'napi-rs: WASI binding cannot be disposed after a worker thread crashed',
4119
+ )
4120
+ }
4121
+ __fillWasiThreadCrashError()
4122
+ return __wasiThreadCrashError
4123
+ }
4124
+
4125
+ function __fillWasiThreadCrashError() {
4126
+ const crashError = __wasiThreadCrashError
4127
+ if (crashError === undefined) {
4128
+ return
4129
+ }
4130
+ try {
4131
+ if (crashError.cause === undefined) {
4132
+ let cause = __wasiThreadCrashWorkerError
4133
+ if (cause === undefined) {
4134
+ const report = __readWasiThreadCrashReport()
4135
+ cause = report ? report.error : undefined
4136
+ }
4137
+ if (cause === undefined) {
4138
+ const manager = __getWasiThreadManager()
4139
+ cause = manager ? manager._fatalError : undefined
4140
+ }
4141
+ if (cause !== undefined && cause !== null) {
4142
+ crashError.cause = cause
4143
+ }
4144
+ }
4145
+ if (crashError.workerThreadId === undefined) {
4146
+ let workerId = __wasiThreadCrashWorkerId
4147
+ if (workerId === undefined) {
4148
+ const report = __readWasiThreadCrashReport()
4149
+ workerId = report && report.threadId > 0 ? report.threadId : undefined
4150
+ }
4151
+ if (workerId !== undefined) {
4152
+ crashError.workerThreadId = workerId
4153
+ }
4154
+ }
4155
+ } catch {}
4156
+ }
4157
+
4158
+ /**
4159
+ * Let the event loop drain after a wasm thread died, without entering wasm.
4160
+ *
4161
+ * emnapi's \`Context\` keeps a \`NodejsWaitingRequestCounter\` on Node: a
4162
+ * \`MessagePort\` (\`refCounter.refHandle\`) that it refs when the count of
4163
+ * in-flight async work and threadsafe-function requests leaves zero and unrefs
4164
+ * when it comes back. The requests the dead thread held never complete, so the
4165
+ * count never returns to zero and the port holds the process open for good.
4166
+ * \`Context.destroy()\` would not release it either — and it runs cleanup
4167
+ * hooks, which is exactly what must not happen now. So unref the port directly.
4168
+ * The count is left as is: it stays above zero, so a later request never refs
4169
+ * the port again.
4170
+ *
4171
+ * The field is private in emnapi's typings, so read it defensively; a context
4172
+ * without it (a non-Node host, a future emnapi) is left alone.
4173
+ */
4174
+ function __releaseEmnapiWaitingRequestHandle() {
4175
+ try {
4176
+ const refCounter = __emnapiContext && __emnapiContext.refCounter
4177
+ const refHandle = refCounter && refCounter.refHandle
4178
+ if (refHandle && typeof refHandle.unref === 'function') {
4179
+ refHandle.unref()
4180
+ }
4181
+ } catch {}
4182
+ }
4183
+
4184
+ /**
4185
+ * The public disposer after a wasm thread died. The normal chain drains async
4186
+ * work, runs the environment cleanup barrier and destroys the context — every
4187
+ * one of those re-enters wasm, and the barrier's shutdown waits for the dead
4188
+ * thread's work in the same raw atomic wait \`__disposeWasiBindingAtExit\`
4189
+ * avoids. An app that handled the worker's error and then disposes would block
4190
+ * there for good. Only stop the workers, then reject: the binding was not
4191
+ * cleaned up and cannot be, so reporting success would be a lie. The context
4192
+ * is not destroyed — only its waiting-request port is unrefed, so the process
4193
+ * can exit on its own — and the 'exit' listener takes its short path too.
4194
+ *
4195
+ * Latched: every later call returns the same promise.
4196
+ */
4197
+ function __disposeWasiBindingAfterThreadCrash() {
4198
+ if (__wasiThreadCrashDisposePromise) {
4199
+ return __wasiThreadCrashDisposePromise
4200
+ }
4201
+ __releaseEmnapiWaitingRequestHandle()
4202
+ ${releaseCurrentThreadHostTimers(' ')}\
4203
+ let workerResult
4204
+ try {
4205
+ workerResult = __terminateWasiWorkers()
4206
+ } catch (terminateError) {
4207
+ workerResult = Promise.reject(terminateError)
4208
+ }
4209
+ // Built at settlement, after the workers stopped: by then the 'error' event
4210
+ // has usually delivered the worker's error.
4211
+ __wasiThreadCrashDisposePromise = Promise.resolve(workerResult).then(
4212
+ () => {
4213
+ throw __getWasiThreadCrashError()
4214
+ },
4215
+ (terminateError) => {
4216
+ throw __attachCleanupErrors(__getWasiThreadCrashError(), [
4217
+ terminateError,
4218
+ ])
4219
+ },
4220
+ )
4221
+ return __wasiThreadCrashDisposePromise
4222
+ }
4223
+
4224
+ /**
4225
+ * Stops a public disposal that a thread crash overtook.
4226
+ *
4227
+ * The check at the top of \`__disposeWasiBinding\` only sees a crash that came
4228
+ * first. A thread that dies once the chain is running leaves it polling for
4229
+ * work the dead thread still counts — the async-work drain for
4230
+ * \`napi_wasm_async_work_pending\`, the barrier's poll for
4231
+ * \`napi_wasm_runtime_work_pending\` — and neither count ever reaches zero, so
4232
+ * the poll's referenced timers keep the process alive forever with the
4233
+ * disposal promise pending. Each poll turn and each step boundary calls this,
4234
+ * and the throw ends the chain before anything re-enters wasm again; the
4235
+ * disposer then settles it through \`__settleWasiDisposalAfterThreadCrash\`.
4236
+ *
4237
+ * The initialization rollback runs the same polls and steps, so they stop while
4238
+ * it runs too; its wrapper, \`__rollbackWasiInitialization\`, then ends it
4239
+ * through \`__rollbackWasiInitializationAfterThreadCrash\`. A bare poll with
4240
+ * neither in flight is left alone.
4241
+ */
4242
+ function __abortWasiDisposalIfThreadCrashed() {
4243
+ if (
4244
+ (__wasiDisposePromise !== undefined ||
4245
+ __wasiInitializationRollbackActive) &&
4246
+ __hasWasiThreadCrashed()
4247
+ ) {
4248
+ throw new Error(
4249
+ 'napi-rs: WASI disposal stopped because a worker thread crashed',
4250
+ )
4251
+ }
4252
+ }
4253
+
4254
+ /**
4255
+ * Settles an in-flight public disposal whose chain failed after a thread
4256
+ * died — stopped by \`__abortWasiDisposalIfThreadCrashed\` or failing any other
4257
+ * way — through the crash disposal: it releases the waiting-request port,
4258
+ * terminates the workers and rejects with the same error an entry-time crash
4259
+ * gets. \`__wasiDisposePromise\` stays set, so this caller, every caller that
4260
+ * joined it and every later one hold the same promise. Returns false when no
4261
+ * thread died, leaving the ordinary failure handling alone.
4262
+ */
4263
+ function __settleWasiDisposalAfterThreadCrash(resolve, reject) {
4264
+ if (!__hasWasiThreadCrashed()) {
4265
+ return false
4266
+ }
4267
+ __disposeWasiBindingAfterThreadCrash().then(resolve, reject)
4268
+ return true
4269
+ }
4270
+
4271
+ /**
4272
+ * Ends an initialization rollback after a wasm thread died, without entering
4273
+ * wasm: the barrier, its finish and \`Context.destroy()\` are skipped, and the
4274
+ * crash disposal releases the waiting-request port and terminates the workers,
4275
+ * once. The initialization error itself is left to propagate: the crash error
4276
+ * is returned as the rollback's cleanup error, so
4277
+ * \`__completeWasiInitializationRollback\` attaches it — as the cause when the
4278
+ * error has none — and keeps the record, since the context was not destroyed.
4279
+ */
4280
+ function __rollbackWasiInitializationAfterThreadCrash() {
4281
+ const crashError = __getWasiThreadCrashError()
4282
+ // Its rejection is \`crashError\` itself, which the caller reports.
4283
+ void __disposeWasiBindingAfterThreadCrash().catch(() => {})
4284
+ return [crashError]
4285
+ }
4286
+ `
4287
+ : ''
4288
+ const skipTeardownAfterThreadCrash = threads
4289
+ ? ` if (__hasWasiThreadCrashed()) {
4290
+ // Never re-enter wasm after a thread died: the environment cleanup joins
4291
+ // the dead thread's work and would hang the exit forever — SIGTERM
4292
+ // included, since its JavaScript listener never gets a turn. Stop the
4293
+ // workers and leave.
4294
+ ${releaseCurrentThreadHostTimers(' ')}\
4295
+ try {
4296
+ void Promise.resolve(__terminateWasiWorkers()).catch(() => {})
4297
+ } catch {}
4298
+ return
4299
+ }
2932
4300
  `
2933
4301
  : ''
2934
4302
  const workerRuntimeImport = threads
@@ -2975,10 +4343,18 @@ function __createWasiWorker(filename) {
2975
4343
  // with threads there is no JavaScript seam at all, so `__drainWasiAsyncWork`
2976
4344
  // asks the addon instead.
2977
4345
  const emnapiPluginRequire = ` emnapiAsyncWorkPlugin: __emnapiAsyncWorkPlugin,\n emnapiTSFNPlugin: __emnapiTSFNPlugin,\n`
4346
+ const captureAddonCrashFlag = threads
4347
+ ? ' __captureWasiAddonCrashFlag(instance)\n'
4348
+ : ''
2978
4349
  const workerOption = threads
2979
4350
  ? ` onCreateWorker() {
2980
4351
  const worker = __createWasiWorker(__nodePath.join(__dirname, 'wasi-worker.mjs'))
2981
4352
  __wasiWorkers.add(worker)
4353
+ // Registered before emnapi's own listeners, which rethrow the error.
4354
+ worker.on('error', (error) => {
4355
+ __wasiThreadCrashed = true
4356
+ __recordWasiThreadCrashError(error, worker.threadId)
4357
+ })
2982
4358
  worker.onmessage = ({ data }) => {
2983
4359
  __wasmCreateOnMessageForFsProxy(__nodeFs)(data)
2984
4360
  }
@@ -3033,6 +4409,7 @@ ${workerRuntimeImport}\
3033
4409
  const { createContext: __emnapiCreateContext } = require('@emnapi/runtime')
3034
4410
  ${asyncRuntimeImport}\
3035
4411
  ${workerExecArgv}\
4412
+ ${threadCrashLatch}\
3036
4413
 
3037
4414
  const __cwd = process.cwd()
3038
4415
  const __rootDir = __nodePath.parse(__cwd).root
@@ -3075,7 +4452,7 @@ if (__nodeFs.existsSync(__wasmDebugFilePath)) {
3075
4452
 
3076
4453
  const __wasmFile = __nodeFs.readFileSync(__wasmFilePath)
3077
4454
  let __emnapiContext
3078
- ${createEmnapiContextLifecycle(asyncRuntime)}
4455
+ ${createEmnapiContextLifecycle(asyncRuntime, threads)}
3079
4456
  const __wasiRollbackRegistrySymbol = Symbol.for('${WASI_ROLLBACK_REGISTRY_SYMBOL}')
3080
4457
  const __wasiRollbackRegistryKey =
3081
4458
  typeof __filename === 'string' ? __filename : __wasmFilePath
@@ -3179,11 +4556,15 @@ function __removeWasiExitListener() {
3179
4556
 
3180
4557
  function __disposeWasiBindingAtExit() {
3181
4558
  __wasiExitListenerRegistered = false
4559
+ ${skipTeardownAfterThreadCrash}\
3182
4560
  // An 'exit' handler cannot yield, so it cannot wait for queued promise
3183
4561
  // settlements the way __startWasiDisposal does — the process is leaving and
3184
4562
  // those promises have no observer left anyway. Run the synchronous teardown
3185
4563
  // directly. Every step is idempotent, which also makes this the synchronous
3186
- // finish for a disposal that is still waiting for its drain.
4564
+ // finish for a disposal that is still waiting for its drain — and, through
4565
+ // __prepareWasmEnvCleanup, for one still parked between the two halves of
4566
+ // the environment cleanup barrier: there are no turns left to poll with, so
4567
+ // this closes that handshake with \`…_finish\`, which joins.
3187
4568
  try {
3188
4569
  __destroyEmnapiContext()
3189
4570
  } catch {}
@@ -3285,6 +4666,7 @@ ${workerOption}\
3285
4666
  },
3286
4667
  beforeInit({ instance }) {
3287
4668
  __napiInstance = instance
4669
+ ${captureAddonCrashFlag}\
3288
4670
  for (const name of Object.keys(instance.exports)) {
3289
4671
  if (name.startsWith('__napi_register__')) {
3290
4672
  instance.exports[name]()