@napi-rs/cli 3.10.5 → 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}')
@@ -188,7 +428,7 @@ let __completeWasiDisposal = function () {}
188
428
  // that stopped short of destroying the context. See
189
429
  // \`__rollbackWasiInitialization\`.
190
430
  let __retainWasiRollbackForRetry = function () {}
191
- ${currentThreadHosts}
431
+ ${currentThreadHosts}${currentThreadHostTimersAfterThreadCrash}
192
432
  function __isThenable(value) {
193
433
  return (
194
434
  value !== null &&
@@ -653,6 +893,7 @@ function __prepareWasmEnvCleanupWithTurns() {
653
893
  // Publish the closer before yielding: from here until \`finishCleanup\` runs,
654
894
  // a caller that cannot yield is entitled to end this handshake itself.
655
895
  __finishParkedWasmEnvCleanup = finishCleanup
896
+ ${finishWasmEnvCleanupUnlessCrashed}\
656
897
  return (async () => {
657
898
  // Unbounded, exactly like the async-work drain below. The wait ends when
658
899
  // the addon reports its runtime work finished; the turns spent here are
@@ -660,6 +901,7 @@ function __prepareWasmEnvCleanupWithTurns() {
660
901
  const pace = __createWasmRuntimePollPace()
661
902
  for (;;) {
662
903
  await __yieldWasmRuntimePollTurn(pace)
904
+ ${abortDisposalAfterThreadCrash(' ')}\
663
905
  try {
664
906
  if (!workPending()) {
665
907
  return
@@ -670,7 +912,7 @@ function __prepareWasmEnvCleanupWithTurns() {
670
912
  return
671
913
  }
672
914
  }
673
- })().then(finishCleanup, finishCleanup)
915
+ })().then(${finishWasmEnvCleanupAfterPoll}, ${finishWasmEnvCleanupAfterPoll})
674
916
  }
675
917
 
676
918
  // Turns to wait for while the addon still reports queued settlements. Reaching
@@ -994,6 +1236,7 @@ function __drainWasiAsyncWork() {
994
1236
  await new Promise((resolve) => {
995
1237
  __scheduleTimer(resolve, __WASI_ASYNC_WORK_POLL_INTERVAL_MS)
996
1238
  })
1239
+ ${abortDisposalAfterThreadCrash(' ')}\
997
1240
  }
998
1241
  })(),
999
1242
  ).then(
@@ -1089,6 +1332,7 @@ function __finishWasiDisposal() {
1089
1332
  }
1090
1333
 
1091
1334
  function __continueWasiDisposal() {
1335
+ ${abortDisposalAfterThreadCrash(' ')}\
1092
1336
  const destroyResult = __destroyEmnapiContext()
1093
1337
  if (__isThenable(destroyResult)) {
1094
1338
  return Promise.resolve(destroyResult).then(__finishWasiDisposal)
@@ -1097,6 +1341,7 @@ function __continueWasiDisposal() {
1097
1341
  }
1098
1342
 
1099
1343
  function __drainWasmEnvForWasiDisposal() {
1344
+ ${abortDisposalAfterThreadCrash(' ')}\
1100
1345
  const drainResult = __drainWasmEnvCleanup()
1101
1346
  if (__isThenable(drainResult)) {
1102
1347
  return Promise.resolve(drainResult).then(__continueWasiDisposal)
@@ -1105,6 +1350,7 @@ function __drainWasmEnvForWasiDisposal() {
1105
1350
  }
1106
1351
 
1107
1352
  function __cleanUpWasmEnvForWasiDisposal() {
1353
+ ${abortDisposalAfterThreadCrash(' ')}\
1108
1354
  // Run the pre-teardown barrier — yielding the turns its two-phase form asks
1109
1355
  // for, when the addon has one — then let the settlements it queued actually
1110
1356
  // reach JavaScript, and only then destroy the environment. Doing any two of
@@ -1141,6 +1387,7 @@ function __disposeWasiBinding() {
1141
1387
  if (__wasiDisposePromise) {
1142
1388
  return __wasiDisposePromise
1143
1389
  }
1390
+ ${disposeAfterThreadCrash}\
1144
1391
  if (__wasiDisposed) {
1145
1392
  return Promise.resolve()
1146
1393
  }
@@ -1157,6 +1404,7 @@ function __disposeWasiBinding() {
1157
1404
  try {
1158
1405
  result = __startWasiDisposal()
1159
1406
  } catch (error) {
1407
+ ${settleDisposalAfterThreadCrash(' ', 'return disposePromise')}\
1160
1408
  __wasiDisposePromise = undefined
1161
1409
  rejectDispose(error)
1162
1410
  return disposePromise
@@ -1168,6 +1416,7 @@ function __disposeWasiBinding() {
1168
1416
  resolveDispose(value)
1169
1417
  },
1170
1418
  (error) => {
1419
+ ${settleDisposalAfterThreadCrash(' ', 'return')}\
1171
1420
  __wasiDisposePromise = undefined
1172
1421
  rejectDispose(error)
1173
1422
  },
@@ -1203,6 +1452,7 @@ function __finishWasiInitializationRollback(cleanupErrors) {
1203
1452
  }
1204
1453
 
1205
1454
  function __destroyContextForWasiRollback(cleanupErrors) {
1455
+ ${abortDisposalAfterThreadCrash(' ')}\
1206
1456
  let destroyResult
1207
1457
  try {
1208
1458
  destroyResult = __destroyEmnapiContext()
@@ -1271,10 +1521,11 @@ function __retainFailedWasiRollback(cleanupErrors) {
1271
1521
  * goes away. That is the deliberate choice: a hung promise is a silent liveness
1272
1522
  * bug with no upper bound, while the retained bookkeeping is bounded by the page.
1273
1523
  */
1274
- function __rollbackWasiInitialization() {
1524
+ function ${rollbackStepsName}() {
1275
1525
  // The environment teardown this rollback performs, kept nested so it cannot
1276
1526
  // be reached without the async-work drain below running first.
1277
1527
  function __rollbackWasmEnvForWasiInitialization() {
1528
+ ${abortDisposalAfterThreadCrash(' ')}\
1278
1529
  const cleanupErrors = []
1279
1530
  let prepareResult
1280
1531
  try {
@@ -1300,6 +1551,7 @@ function __rollbackWasiInitialization() {
1300
1551
  // did not finish never gets here: it retains instead, exactly as a drain that
1301
1552
  // did not finish does.
1302
1553
  function __drainWasmEnvForWasiRollback(cleanupErrors) {
1554
+ ${abortDisposalAfterThreadCrash(' ')}\
1303
1555
  let drainResult
1304
1556
  try {
1305
1557
  drainResult = __drainWasmEnvCleanup()
@@ -1346,6 +1598,7 @@ function __rollbackWasiInitialization() {
1346
1598
  }
1347
1599
  return __rollbackWasmEnvForWasiInitialization()
1348
1600
  }
1601
+ ${rollbackAfterThreadCrash}\
1349
1602
  `
1350
1603
  }
1351
1604
 
@@ -1499,10 +1752,35 @@ const __workerPoolSize = Math.max(
1499
1752
  type: 'module',
1500
1753
  })
1501
1754
  __wasiWorkers.add(worker)
1755
+ __shareWasiAddonCrashFlag(worker)
1502
1756
  ${workerFsHandler}
1503
1757
  ${workerErrorHandler}
1504
1758
  return worker
1505
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
+ }
1506
1784
  `
1507
1785
  : ''
1508
1786
 
@@ -1542,6 +1820,7 @@ ${threads ? ' shared: true,\n' : ''}\
1542
1820
  ${workerPoolSizeBinding}\
1543
1821
  let __emnapiContext
1544
1822
  ${createEmnapiContextLifecycle(asyncRuntime)}
1823
+ ${addonCrashFlagSharing}\
1545
1824
  let __wasiModule
1546
1825
  let __napiModule
1547
1826
 
@@ -1575,6 +1854,7 @@ ${workerOption}\
1575
1854
  },
1576
1855
  beforeInit({ instance }) {
1577
1856
  __napiInstance = instance
1857
+ ${captureAddonCrashFlag}\
1578
1858
  for (const name of Object.keys(instance.exports)) {
1579
1859
  if (name.startsWith('__napi_register__')) {
1580
1860
  instance.exports[name]()
@@ -3608,9 +3888,11 @@ export const createWasiBinding = (
3608
3888
  } = require('@napi-rs/async-runtime')
3609
3889
  `
3610
3890
  : ''
3891
+ // The threaded loader has the thread crash latch, whose disposal releases
3892
+ // the host timers it tracks. See \`__trackCurrentThreadHostTimers\`.
3611
3893
  const installAsyncRuntimeHosts = asyncRuntime
3612
3894
  ? ` __currentThreadHostsDisposer = __installCurrentThreadHosts(
3613
- __napiModule.exports,
3895
+ ${threads ? '__trackCurrentThreadHostTimers(__napiModule.exports)' : '__napiModule.exports'},
3614
3896
  )
3615
3897
  `
3616
3898
  : ''
@@ -3694,7 +3976,13 @@ function __createWasiWorker(filename) {
3694
3976
  return new Worker(filename, {
3695
3977
  env: process.env,
3696
3978
  execArgv: __workerExecArgv,
3697
- workerData: { hostRoot: __hostRoot, rootDir: __rootDir },
3979
+ workerData: {
3980
+ hostRoot: __hostRoot,
3981
+ rootDir: __rootDir,
3982
+ crashFlag: __wasiThreadCrashFlag,
3983
+ crashReport: __wasiThreadCrashReport,
3984
+ addonCrashFlag: __wasiAddonCrashFlag,
3985
+ },
3698
3986
  })
3699
3987
  } catch (error) {
3700
3988
  if (!error || error.code !== 'ERR_WORKER_INVALID_EXEC_ARGV') {
@@ -3709,6 +3997,306 @@ function __createWasiWorker(filename) {
3709
3997
  }
3710
3998
  }
3711
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
+ }
3712
4300
  `
3713
4301
  : ''
3714
4302
  const workerRuntimeImport = threads
@@ -3755,10 +4343,18 @@ function __createWasiWorker(filename) {
3755
4343
  // with threads there is no JavaScript seam at all, so `__drainWasiAsyncWork`
3756
4344
  // asks the addon instead.
3757
4345
  const emnapiPluginRequire = ` emnapiAsyncWorkPlugin: __emnapiAsyncWorkPlugin,\n emnapiTSFNPlugin: __emnapiTSFNPlugin,\n`
4346
+ const captureAddonCrashFlag = threads
4347
+ ? ' __captureWasiAddonCrashFlag(instance)\n'
4348
+ : ''
3758
4349
  const workerOption = threads
3759
4350
  ? ` onCreateWorker() {
3760
4351
  const worker = __createWasiWorker(__nodePath.join(__dirname, 'wasi-worker.mjs'))
3761
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
+ })
3762
4358
  worker.onmessage = ({ data }) => {
3763
4359
  __wasmCreateOnMessageForFsProxy(__nodeFs)(data)
3764
4360
  }
@@ -3813,6 +4409,7 @@ ${workerRuntimeImport}\
3813
4409
  const { createContext: __emnapiCreateContext } = require('@emnapi/runtime')
3814
4410
  ${asyncRuntimeImport}\
3815
4411
  ${workerExecArgv}\
4412
+ ${threadCrashLatch}\
3816
4413
 
3817
4414
  const __cwd = process.cwd()
3818
4415
  const __rootDir = __nodePath.parse(__cwd).root
@@ -3855,7 +4452,7 @@ if (__nodeFs.existsSync(__wasmDebugFilePath)) {
3855
4452
 
3856
4453
  const __wasmFile = __nodeFs.readFileSync(__wasmFilePath)
3857
4454
  let __emnapiContext
3858
- ${createEmnapiContextLifecycle(asyncRuntime)}
4455
+ ${createEmnapiContextLifecycle(asyncRuntime, threads)}
3859
4456
  const __wasiRollbackRegistrySymbol = Symbol.for('${WASI_ROLLBACK_REGISTRY_SYMBOL}')
3860
4457
  const __wasiRollbackRegistryKey =
3861
4458
  typeof __filename === 'string' ? __filename : __wasmFilePath
@@ -3959,6 +4556,7 @@ function __removeWasiExitListener() {
3959
4556
 
3960
4557
  function __disposeWasiBindingAtExit() {
3961
4558
  __wasiExitListenerRegistered = false
4559
+ ${skipTeardownAfterThreadCrash}\
3962
4560
  // An 'exit' handler cannot yield, so it cannot wait for queued promise
3963
4561
  // settlements the way __startWasiDisposal does — the process is leaving and
3964
4562
  // those promises have no observer left anyway. Run the synchronous teardown
@@ -4068,6 +4666,7 @@ ${workerOption}\
4068
4666
  },
4069
4667
  beforeInit({ instance }) {
4070
4668
  __napiInstance = instance
4669
+ ${captureAddonCrashFlag}\
4071
4670
  for (const name of Object.keys(instance.exports)) {
4072
4671
  if (name.startsWith('__napi_register__')) {
4073
4672
  instance.exports[name]()