@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.
@@ -1,5 +1,8 @@
1
- import { dirname } from 'node:path'
1
+ import { mkdtemp, rm, writeFile } from 'node:fs/promises'
2
+ import { tmpdir } from 'node:os'
3
+ import { dirname, join } from 'node:path'
2
4
  import { fileURLToPath } from 'node:url'
5
+ import { Worker } from 'node:worker_threads'
3
6
 
4
7
  import ava, { type ExecutionContext } from 'ava'
5
8
  import { parseSync } from 'oxc-parser'
@@ -927,7 +930,9 @@ test('Node WASI loader uses an accessible host root on Android', (t) => {
927
930
  t.true(code.includes('[__rootDir]: __hostRoot'))
928
931
  t.true(code.includes('[__hostRoot]: __hostRoot'))
929
932
  t.true(
930
- code.includes('workerData: { hostRoot: __hostRoot, rootDir: __rootDir }'),
933
+ code.includes(
934
+ 'workerData: {\n hostRoot: __hostRoot,\n rootDir: __rootDir,\n',
935
+ ),
931
936
  )
932
937
  t.false(code.includes('[__rootDir]: __rootDir'))
933
938
  })
@@ -984,6 +989,10 @@ for (const { name, code } of wasiLoaderCases) {
984
989
  }
985
990
 
986
991
  const EAGER_ROLLBACK_SIGNATURE = 'function __rollbackWasiInitialization() {'
992
+ // The threaded node loader keeps the rollback's body under this name and makes
993
+ // `__rollbackWasiInitialization` the wrapper that stops it after a crash.
994
+ const LATCHED_ROLLBACK_SIGNATURE =
995
+ 'function __runWasiInitializationRollbackSteps() {'
987
996
  const DEFERRED_ROLLBACK_SIGNATURE = "__lifecycleState = 'failed'"
988
997
  const DRAIN_CALL_BY_ROLLBACK_FLAVOR = {
989
998
  eager: '__drainWasmEnvCleanup',
@@ -992,12 +1001,14 @@ const DRAIN_CALL_BY_ROLLBACK_FLAVOR = {
992
1001
 
993
1002
  /**
994
1003
  * The body of a loader's initialization-failure path: `__rollbackWasiInitialization`
995
- * for the eager loaders, `__createInstance`'s catch for the deferred one.
1004
+ * (its steps, in the threaded node loader) for the eager loaders, `__createInstance`'s catch for the deferred one.
996
1005
  * Sliced rather than searched whole-file, so a drain that only runs on the
997
1006
  * ordinary disposal path cannot satisfy the assertion.
998
1007
  */
999
1008
  function initializationRollbackBody(code: string): string {
1000
- const eagerStart = code.indexOf(EAGER_ROLLBACK_SIGNATURE)
1009
+ const latchedStart = code.indexOf(LATCHED_ROLLBACK_SIGNATURE)
1010
+ const eagerStart =
1011
+ latchedStart !== -1 ? latchedStart : code.indexOf(EAGER_ROLLBACK_SIGNATURE)
1001
1012
  if (eagerStart !== -1) {
1002
1013
  return code.slice(eagerStart, code.indexOf('\n}', eagerStart))
1003
1014
  }
@@ -1299,8 +1310,17 @@ for (const { name, code } of wasiLoaderCases) {
1299
1310
  /if \(__settled\??\) \{\s*return\s*\}|if \(settled\) \{\s*return\s*\}/,
1300
1311
  'whichever primitive loses the race must resolve nothing: one poll per turn',
1301
1312
  )
1313
+ // The threaded node loader routes the poll's end through a crash check
1314
+ // first: after a worker died, `…_finish` would join the dead thread's work.
1315
+ // See `node WASI disposal stops the barrier poll when a worker crashes
1316
+ // mid-flight`.
1317
+ const crashLatched = code.includes('finishCleanupUnlessCrashed')
1302
1318
  t.true(
1303
- code.includes(barrier.finish),
1319
+ code.includes(
1320
+ crashLatched
1321
+ ? '.then(finishCleanupUnlessCrashed, finishCleanupUnlessCrashed)'
1322
+ : barrier.finish,
1323
+ ),
1304
1324
  'finish must run whether the poll ended, timed out or could not run at all',
1305
1325
  )
1306
1326
  // The one caller that cannot yield is the raw `Context.destroy()` the
@@ -1324,7 +1344,8 @@ for (const { name, code } of wasiLoaderCases) {
1324
1344
  )
1325
1345
  t.is(
1326
1346
  code.split(barrier.clearParked).length - 1,
1327
- 1,
1347
+ // Plus the crash abort, which retracts it instead of finishing.
1348
+ crashLatched ? 2 : 1,
1328
1349
  'finishing the handshake must retract that closer exactly once',
1329
1350
  )
1330
1351
  const parkedIndex = code.indexOf(`const ${barrier.parked}`)
@@ -2107,3 +2128,1904 @@ test('createEsmBinding builds native addon loading on portable ESM primitives',
2107
2128
  'ESM loader must not treat URL.pathname as a filesystem path',
2108
2129
  )
2109
2130
  })
2131
+
2132
+ /**
2133
+ * A top-level `function <name>(` of generated code, through its closing brace
2134
+ * at column zero.
2135
+ */
2136
+ function generatedFunction(code: string, name: string): string {
2137
+ const start = code.indexOf(`function ${name}(`)
2138
+ if (start === -1) {
2139
+ return ''
2140
+ }
2141
+ return code.slice(start, code.indexOf('\n}\n', start) + 2)
2142
+ }
2143
+
2144
+ /**
2145
+ * Runs the node loader's 'exit' listener against stubbed teardown steps, with
2146
+ * the crash latch in the given state, and returns the steps it took.
2147
+ */
2148
+ function runWasiExitListener(
2149
+ code: string,
2150
+ crash: { flag?: boolean; errorEvent?: boolean; fatalError?: boolean },
2151
+ ): string[] {
2152
+ const latchStart = code.indexOf('const __wasiThreadCrashFlag = ')
2153
+ const latchState = code.slice(
2154
+ latchStart,
2155
+ code.indexOf('let __wasiThreadCrashed = false\n', latchStart) +
2156
+ 'let __wasiThreadCrashed = false\n'.length,
2157
+ )
2158
+ const steps: string[] = []
2159
+ const run = new Function(
2160
+ 'steps',
2161
+ 'crash',
2162
+ `
2163
+ let __wasiExitListenerRegistered = true
2164
+ ${latchState}
2165
+ function __getWasiThreadManager() {
2166
+ return {
2167
+ terminateWorker() {},
2168
+ _fatalError: crash.fatalError ? new Error('worker died') : undefined,
2169
+ }
2170
+ }
2171
+ function __isThenable(value) {
2172
+ return value !== null && typeof value === 'object' && typeof value.then === 'function'
2173
+ }
2174
+ function __destroyEmnapiContext() {
2175
+ steps.push('destroy')
2176
+ }
2177
+ function __terminateWasiWorkers() {
2178
+ steps.push('terminate')
2179
+ }
2180
+ // Only an asyncRuntime loader calls it.
2181
+ function __releaseCurrentThreadHostTimers() {
2182
+ steps.push('release host timers')
2183
+ }
2184
+ ${generatedFunction(code, '__hasWasiThreadCrashed')}
2185
+ ${generatedFunction(code, '__disposeWasiBindingAtExit')}
2186
+ if (crash.flag) Atomics.store(__wasiThreadCrashFlag, 0, 1)
2187
+ if (crash.errorEvent) __wasiThreadCrashed = true
2188
+ __disposeWasiBindingAtExit()
2189
+ if (__wasiExitListenerRegistered) steps.push('still registered')
2190
+ `,
2191
+ )
2192
+ run(steps, crash)
2193
+ return steps
2194
+ }
2195
+
2196
+ // A wasm thread that died can leave the shared state half-updated: a lock it
2197
+ // held stays held, and the async runtime's shutdown waits for its work to go
2198
+ // idle. The 'exit' teardown re-enters wasm on the JavaScript thread and blocks
2199
+ // there in a raw atomic wait that no signal can interrupt, so the process hangs
2200
+ // for good. After a crash the listener must only stop the workers.
2201
+ test('node WASI loader skips the wasm teardown at exit after a thread crash', (t) => {
2202
+ const code = createWasiBinding('test', '@scope/test')
2203
+ assertValidJS(t, code, 'node cjs')
2204
+ t.true(code.includes('function __hasWasiThreadCrashed() {'))
2205
+
2206
+ // No crash: the teardown is exactly what it was.
2207
+ t.deepEqual(runWasiExitListener(code, {}), ['destroy', 'terminate'])
2208
+
2209
+ // Each of the three signals is enough on its own: the flag the worker sets
2210
+ // before it reports, the worker's 'error' event, and emnapi's fatal error.
2211
+ for (const crash of [
2212
+ { flag: true },
2213
+ { errorEvent: true },
2214
+ { fatalError: true },
2215
+ ]) {
2216
+ t.deepEqual(
2217
+ runWasiExitListener(code, crash),
2218
+ ['terminate'],
2219
+ JSON.stringify(crash),
2220
+ )
2221
+ }
2222
+ })
2223
+
2224
+ /**
2225
+ * Runs the node loader's public disposer, and then its 'exit' listener, against
2226
+ * stubbed wasm steps with the crash latch in the given state. Every stub
2227
+ * records its name, so the steps show exactly what re-entered wasm.
2228
+ *
2229
+ * `__emnapiContext` is a stub shaped like emnapi's Node `Context`: its
2230
+ * `refCounter.refHandle` records 'port ref' / 'port unref', and every other
2231
+ * member records its name, so a step that touched the context shows up too.
2232
+ * `context` picks a context without a counter, one whose counter getter
2233
+ * throws, or no context at all.
2234
+ */
2235
+ async function runWasiPublicDisposer(
2236
+ code: string,
2237
+ crash: {
2238
+ flag?: boolean
2239
+ errorEvent?: boolean
2240
+ fatalError?: boolean
2241
+ terminateThrows?: boolean
2242
+ context?: 'no counter' | 'throwing counter' | 'none'
2243
+ },
2244
+ calls = 1,
2245
+ ): Promise<{
2246
+ steps: string[]
2247
+ results: Array<{ value?: unknown; error?: any }>
2248
+ samePromise: boolean
2249
+ exitError?: unknown
2250
+ count?: number
2251
+ }> {
2252
+ const latchStart = code.indexOf('const __wasiThreadCrashFlag = ')
2253
+ const latchState = code.slice(
2254
+ latchStart,
2255
+ code.indexOf('let __wasiThreadCrashed = false\n', latchStart) +
2256
+ 'let __wasiThreadCrashed = false\n'.length,
2257
+ )
2258
+ const steps: string[] = []
2259
+ const fatalError = new Error('worker died')
2260
+ // Two requests still in flight on the dead thread.
2261
+ const refCounter = {
2262
+ count: 2,
2263
+ refHandle: {
2264
+ ref() {
2265
+ steps.push('port ref')
2266
+ },
2267
+ unref() {
2268
+ steps.push('port unref')
2269
+ },
2270
+ },
2271
+ }
2272
+ let context: object | undefined
2273
+ if (crash.context === 'no counter') {
2274
+ context = {}
2275
+ } else if (crash.context === 'throwing counter') {
2276
+ context = {
2277
+ get refCounter() {
2278
+ throw new Error('private field')
2279
+ },
2280
+ }
2281
+ } else if (crash.context !== 'none') {
2282
+ context = { refCounter }
2283
+ }
2284
+ if (context) {
2285
+ // Anything but the counter port touching the context is a wasm re-entry
2286
+ // (destroy runs the cleanup hooks) or a JS state change this path must not
2287
+ // make; record it.
2288
+ context = new Proxy(context, {
2289
+ get(target, key, receiver) {
2290
+ if (key !== 'refCounter') {
2291
+ steps.push(`context.${String(key)}`)
2292
+ }
2293
+ return Reflect.get(target, key, receiver)
2294
+ },
2295
+ })
2296
+ }
2297
+ const run = new Function(
2298
+ 'steps',
2299
+ 'crash',
2300
+ 'fatalError',
2301
+ 'calls',
2302
+ '__emnapiContext',
2303
+ `
2304
+ let __wasiExitListenerRegistered = true
2305
+ let __wasiDisposed = false
2306
+ let __wasiDisposePromise
2307
+ let __wasiThreadCrashDisposePromise
2308
+ // The loader sets this to __removeWasiExitListener.
2309
+ let __completeWasiDisposal = function () {
2310
+ steps.push('complete')
2311
+ __wasiExitListenerRegistered = false
2312
+ }
2313
+ ${latchState}
2314
+ function __getWasiThreadManager() {
2315
+ return {
2316
+ terminateWorker() {},
2317
+ _fatalError: crash.fatalError ? fatalError : undefined,
2318
+ }
2319
+ }
2320
+ function __isThenable(value) {
2321
+ return value !== null && typeof value === 'object' && typeof value.then === 'function'
2322
+ }
2323
+ function __drainWasiAsyncWork() {
2324
+ steps.push('async work')
2325
+ }
2326
+ function __prepareWasmEnvCleanupWithTurns() {
2327
+ steps.push('prepare')
2328
+ }
2329
+ function __drainWasmEnvCleanup() {
2330
+ steps.push('drain')
2331
+ }
2332
+ function __destroyEmnapiContext() {
2333
+ steps.push('destroy')
2334
+ }
2335
+ function __terminateWasiWorkers() {
2336
+ steps.push('terminate')
2337
+ if (crash.terminateThrows) throw new Error('terminate failed')
2338
+ return Promise.resolve()
2339
+ }
2340
+ // Only an asyncRuntime loader calls it.
2341
+ function __releaseCurrentThreadHostTimers() {
2342
+ steps.push('release host timers')
2343
+ }
2344
+ ${generatedFunction(code, '__createCleanupError')}
2345
+ ${generatedFunction(code, '__attachCleanupErrors')}
2346
+ ${generatedFunction(code, '__hasWasiThreadCrashed')}
2347
+ ${generatedFunction(code, '__readWasiThreadCrashReport')}
2348
+ ${generatedFunction(code, '__recordWasiThreadCrashError')}
2349
+ ${generatedFunction(code, '__getWasiThreadCrashError')}
2350
+ ${generatedFunction(code, '__fillWasiThreadCrashError')}
2351
+ ${generatedFunction(code, '__releaseEmnapiWaitingRequestHandle')}
2352
+ ${generatedFunction(code, '__disposeWasiBindingAfterThreadCrash')}
2353
+ ${generatedFunction(code, '__abortWasiDisposalIfThreadCrashed')}
2354
+ ${generatedFunction(code, '__settleWasiDisposalAfterThreadCrash')}
2355
+ ${generatedFunction(code, '__finishWasiDisposal')}
2356
+ ${generatedFunction(code, '__continueWasiDisposal')}
2357
+ ${generatedFunction(code, '__drainWasmEnvForWasiDisposal')}
2358
+ ${generatedFunction(code, '__cleanUpWasmEnvForWasiDisposal')}
2359
+ ${generatedFunction(code, '__startWasiDisposal')}
2360
+ ${generatedFunction(code, '__disposeWasiBinding')}
2361
+ ${generatedFunction(code, '__disposeWasiBindingAtExit')}
2362
+ if (crash.flag) Atomics.store(__wasiThreadCrashFlag, 0, 1)
2363
+ if (crash.errorEvent) __wasiThreadCrashed = true
2364
+ return (async () => {
2365
+ const promises = []
2366
+ for (let i = 0; i < calls; i += 1) {
2367
+ promises.push(__disposeWasiBinding())
2368
+ }
2369
+ const results = []
2370
+ for (const promise of promises) {
2371
+ try {
2372
+ results.push({ value: await promise })
2373
+ } catch (error) {
2374
+ results.push({ error })
2375
+ }
2376
+ }
2377
+ let exitError
2378
+ try {
2379
+ if (__wasiExitListenerRegistered) __disposeWasiBindingAtExit()
2380
+ } catch (error) {
2381
+ exitError = error
2382
+ }
2383
+ return {
2384
+ results,
2385
+ samePromise: promises.every((promise) => promise === promises[0]),
2386
+ exitError,
2387
+ }
2388
+ })()
2389
+ `,
2390
+ )
2391
+ const outcome = await run(steps, crash, fatalError, calls, context)
2392
+ return { steps, ...outcome, count: refCounter.count }
2393
+ }
2394
+
2395
+ // The published disposer runs the same wasm teardown as the 'exit' listener,
2396
+ // behind an async-work drain. An app that handled the rethrown worker error
2397
+ // and then disposed blocked in the same raw atomic wait. After a crash it only
2398
+ // stops the workers and rejects, since nothing was cleaned up.
2399
+ test('node WASI public disposer skips the wasm teardown after a thread crash', async (t) => {
2400
+ const code = createWasiBinding('test', '@scope/test')
2401
+ assertValidJS(t, code, 'node cjs')
2402
+ t.true(code.includes('let __wasiThreadCrashDisposePromise\n'))
2403
+ t.true(
2404
+ generatedFunction(code, '__disposeWasiBinding').includes(
2405
+ ' if (!__wasiDisposed && __hasWasiThreadCrashed()) {\n return __disposeWasiBindingAfterThreadCrash()\n }\n',
2406
+ ),
2407
+ )
2408
+
2409
+ // No crash: the disposal chain is exactly what it was, and it removes the
2410
+ // 'exit' listener when it completes.
2411
+ const clean = await runWasiPublicDisposer(code, {})
2412
+ t.deepEqual(clean.steps, [
2413
+ 'async work',
2414
+ 'prepare',
2415
+ 'drain',
2416
+ 'destroy',
2417
+ 'terminate',
2418
+ 'complete',
2419
+ ])
2420
+ t.deepEqual(clean.results, [{ value: undefined }])
2421
+
2422
+ for (const crash of [
2423
+ { flag: true },
2424
+ { errorEvent: true },
2425
+ { fatalError: true },
2426
+ ]) {
2427
+ const label = JSON.stringify(crash)
2428
+ // Three calls: every one settles the same way, and the workers are
2429
+ // terminated once by the disposer and once more by the 'exit' listener,
2430
+ // which still takes its short path and does not throw.
2431
+ const crashed = await runWasiPublicDisposer(code, crash, 3)
2432
+ t.deepEqual(crashed.steps, ['port unref', 'terminate', 'terminate'], label)
2433
+ t.true(crashed.samePromise, label)
2434
+ t.is(crashed.exitError, undefined, label)
2435
+ for (const result of crashed.results) {
2436
+ t.true(result.error instanceof Error, label)
2437
+ t.is(
2438
+ result.error.message,
2439
+ 'napi-rs: WASI binding cannot be disposed after a worker thread crashed',
2440
+ label,
2441
+ )
2442
+ }
2443
+ if (crash.fatalError) {
2444
+ t.is(crashed.results[0].error.cause.message, 'worker died')
2445
+ }
2446
+ }
2447
+
2448
+ // A termination failure is attached, not swallowed, and the disposer still
2449
+ // does not fall through to the wasm teardown.
2450
+ const failed = await runWasiPublicDisposer(code, {
2451
+ flag: true,
2452
+ terminateThrows: true,
2453
+ })
2454
+ t.deepEqual(failed.steps, ['port unref', 'terminate', 'terminate'])
2455
+ t.regex(failed.results[0].error.message, /worker thread crashed/)
2456
+ t.is(failed.results[0].error.cause.message, 'terminate failed')
2457
+ t.is(failed.exitError, undefined)
2458
+ })
2459
+
2460
+ // emnapi's Node Context refs a MessagePort (refCounter.refHandle) while async
2461
+ // work or threadsafe-function requests are in flight. The dead thread's requests
2462
+ // never finish, so after the crash disposal rejected nothing else was left
2463
+ // running and the port alone kept the process from exiting. The crash path
2464
+ // unrefs it without touching anything else on the context; the no-crash path
2465
+ // never touches it.
2466
+ test('node WASI crash disposal releases the waiting-request port', async (t) => {
2467
+ const code = createWasiBinding('test', '@scope/test')
2468
+ const release = generatedFunction(code, '__releaseEmnapiWaitingRequestHandle')
2469
+ t.true(release.length > 0)
2470
+ t.true(
2471
+ generatedFunction(code, '__disposeWasiBindingAfterThreadCrash').includes(
2472
+ ' __releaseEmnapiWaitingRequestHandle()\n let workerResult\n',
2473
+ ),
2474
+ 'released before the workers are terminated',
2475
+ )
2476
+ // It must not destroy the context: that runs the cleanup hooks in wasm.
2477
+ t.false(release.includes('destroy'))
2478
+
2479
+ // No crash: the port is left to emnapi's own count.
2480
+ const clean = await runWasiPublicDisposer(code, {})
2481
+ t.false(clean.steps.includes('port unref'))
2482
+ t.false(clean.steps.includes('port ref'))
2483
+ t.is(clean.count, 2)
2484
+
2485
+ // Crash: unrefed exactly once across three disposer calls and the 'exit'
2486
+ // listener, never re-refed, and the count is left above zero so a later
2487
+ // request cannot ref the port again. No other context member is read.
2488
+ const crashed = await runWasiPublicDisposer(code, { flag: true }, 3)
2489
+ t.deepEqual(
2490
+ crashed.steps.filter((step) => step.startsWith('port ')),
2491
+ ['port unref'],
2492
+ )
2493
+ t.false(crashed.steps.some((step) => step.startsWith('context.')))
2494
+ t.is(crashed.count, 2)
2495
+
2496
+ // A context without the counter (a non-Node host, a future emnapi), one
2497
+ // whose field cannot be read, or no context at all: the disposer still
2498
+ // rejects with the crash error and terminates the workers.
2499
+ for (const context of ['no counter', 'throwing counter', 'none'] as const) {
2500
+ const outcome = await runWasiPublicDisposer(code, { flag: true, context })
2501
+ t.deepEqual(outcome.steps, ['terminate', 'terminate'], context)
2502
+ t.regex(outcome.results[0].error.message, /worker thread crashed/, context)
2503
+ t.is(outcome.exitError, undefined, context)
2504
+ }
2505
+ })
2506
+
2507
+ // A CurrentThread sleep arms a referenced host timeout through the
2508
+ // `@napi-rs/async-runtime` timer host. Only `__disposeCurrentThreadHosts`
2509
+ // released it, from `__destroyEmnapiContext`, which the crash paths never
2510
+ // reach: its unregister calls enter wasm. A dispose() that rejected after a
2511
+ // crash left the process alive until the longest sleep ended. The threaded
2512
+ // asyncRuntime loader now clears those timeouts itself on every crash path.
2513
+ const threadedAsyncRuntimeCode = createWasiBinding(
2514
+ 'test',
2515
+ '@scope/test',
2516
+ 4000,
2517
+ 65536,
2518
+ true,
2519
+ 'wasm32-wasi',
2520
+ 'test',
2521
+ true,
2522
+ )
2523
+
2524
+ test('node WASI crash paths release the CurrentThread host timers', async (t) => {
2525
+ const code = threadedAsyncRuntimeCode
2526
+ assertValidJS(t, code, 'node cjs + asyncRuntime')
2527
+ t.true(
2528
+ code.includes(
2529
+ '__currentThreadHostsDisposer = __installCurrentThreadHosts(\n __trackCurrentThreadHostTimers(__napiModule.exports),\n )\n',
2530
+ ),
2531
+ )
2532
+ t.true(
2533
+ generatedFunction(code, '__disposeWasiBindingAfterThreadCrash').includes(
2534
+ ' __releaseEmnapiWaitingRequestHandle()\n __releaseCurrentThreadHostTimers()\n let workerResult\n',
2535
+ ),
2536
+ 'released before the workers are terminated',
2537
+ )
2538
+ const exitListener = generatedFunction(code, '__disposeWasiBindingAtExit')
2539
+ const crashBranch = exitListener.slice(
2540
+ exitListener.indexOf('if (__hasWasiThreadCrashed()) {'),
2541
+ exitListener.indexOf(' return\n }\n'),
2542
+ )
2543
+ t.true(crashBranch.includes(' __releaseCurrentThreadHostTimers()\n'))
2544
+ // JavaScript only: nothing that unregisters, destroys or reaches the addon.
2545
+ const release = generatedFunction(code, '__releaseCurrentThreadHostTimers')
2546
+ t.true(release.length > 0)
2547
+ for (const forbidden of [
2548
+ 'unregister',
2549
+ '__disposeCurrentThreadHosts',
2550
+ '__emnapiContext',
2551
+ '__napiModule',
2552
+ ]) {
2553
+ t.false(release.includes(forbidden), forbidden)
2554
+ }
2555
+
2556
+ // The disposer releases once per crash disposal, and the 'exit' listener's
2557
+ // short path releases again (idempotent); without a crash neither does.
2558
+ const clean = await runWasiPublicDisposer(code, {})
2559
+ t.false(clean.steps.includes('release host timers'))
2560
+ const crashed = await runWasiPublicDisposer(code, { flag: true }, 3)
2561
+ t.deepEqual(crashed.steps, [
2562
+ 'port unref',
2563
+ 'release host timers',
2564
+ 'terminate',
2565
+ 'release host timers',
2566
+ 'terminate',
2567
+ ])
2568
+ t.deepEqual(runWasiExitListener(code, {}), ['destroy', 'terminate'])
2569
+ t.deepEqual(runWasiExitListener(code, { flag: true }), [
2570
+ 'release host timers',
2571
+ 'terminate',
2572
+ ])
2573
+
2574
+ // Only the threaded Node loader has the crash latch.
2575
+ for (const { name, code: other } of [
2576
+ { name: 'node cjs', code: createWasiBinding('test', '@scope/test') },
2577
+ ...asyncRuntimeLoaderCases.filter(({ name }) => name !== 'node cjs'),
2578
+ ]) {
2579
+ t.false(other.includes('__releaseCurrentThreadHostTimers'), name)
2580
+ t.false(other.includes('__trackCurrentThreadHostTimers'), name)
2581
+ }
2582
+ })
2583
+
2584
+ test('node WASI crash release clears the host timeouts without settling them', async (t) => {
2585
+ const code = threadedAsyncRuntimeCode
2586
+ const stateStart = code.indexOf('const __currentThreadHostTimerCancels = ')
2587
+ const state = code.slice(
2588
+ stateStart,
2589
+ code.indexOf(
2590
+ 'let __currentThreadHostTimersReleased = false\n',
2591
+ stateStart,
2592
+ ) + 'let __currentThreadHostTimersReleased = false\n'.length,
2593
+ )
2594
+ t.true(stateStart > 0)
2595
+ const { track, release } = new Function(`
2596
+ ${state}
2597
+ ${generatedFunction(code, '__isThenable')}
2598
+ ${generatedFunction(code, '__scheduleCurrentThreadHostTimer')}
2599
+ ${generatedFunction(code, '__trackCurrentThreadHostTimers')}
2600
+ ${generatedFunction(code, '__releaseCurrentThreadHostTimers')}
2601
+ return {
2602
+ track: __trackCurrentThreadHostTimers,
2603
+ release: __releaseCurrentThreadHostTimers,
2604
+ }
2605
+ `)()
2606
+
2607
+ // A binding the package would reject is passed through untouched.
2608
+ const incomplete = { other: 1 }
2609
+ t.is(track(incomplete), incomplete)
2610
+ const throwing = {
2611
+ get registerTimerHost() {
2612
+ throw new Error('accessor')
2613
+ },
2614
+ }
2615
+ t.is(track(throwing), throwing)
2616
+
2617
+ // What the addon receives from `registerTimerHost`.
2618
+ let registered: any
2619
+ const binding = {
2620
+ reserveCurrentThreadHostRegistration() {},
2621
+ registerTimerHost(...args: any[]) {
2622
+ registered = args
2623
+ return 'registered'
2624
+ },
2625
+ }
2626
+ const view = track(binding)
2627
+ t.is(
2628
+ view.reserveCurrentThreadHostRegistration,
2629
+ binding.reserveCurrentThreadHostRegistration,
2630
+ )
2631
+
2632
+ // The package's timer host, armed timeouts by id. `cancel` clears one and
2633
+ // resolves its promise, as `installCurrentThreadHosts` does.
2634
+ const armed = new Map<
2635
+ number,
2636
+ { resolve: () => void; reject: (e: Error) => void }
2637
+ >()
2638
+ const log: string[] = []
2639
+ let failCancel = false
2640
+ const schedule = (id: number) =>
2641
+ new Promise<void>((resolve, reject) => {
2642
+ log.push(`arm ${id}`)
2643
+ armed.set(id, { resolve, reject })
2644
+ })
2645
+ const cancel = (id: number) => {
2646
+ const timer = armed.get(id)
2647
+ if (!timer) return
2648
+ armed.delete(id)
2649
+ log.push(`clear ${id}`)
2650
+ if (failCancel) {
2651
+ const error = new Error(`cancel ${id} failed`)
2652
+ timer.reject(error)
2653
+ throw error
2654
+ }
2655
+ timer.resolve()
2656
+ }
2657
+ t.is(view.registerTimerHost(1, 2, schedule, cancel), 'registered')
2658
+ t.is(registered.length, 4)
2659
+ t.deepEqual(registered.slice(0, 2), [1, 2])
2660
+ t.not(registered[2], schedule)
2661
+ t.is(registered[3], cancel, 'cancel is handed to the addon as is')
2662
+ const addonSchedule = registered[2]
2663
+
2664
+ // No crash: settlements pass through.
2665
+ const fired = addonSchedule(1, 10)
2666
+ armed.get(1)!.resolve()
2667
+ t.is(await fired, undefined)
2668
+ const failed = addonSchedule(2, 10)
2669
+ armed.get(2)!.reject(new Error('host failed'))
2670
+ await t.throwsAsync(failed, { message: 'host failed' })
2671
+ const cancelled = addonSchedule(3, 10)
2672
+ cancel(3)
2673
+ t.is(await cancelled, undefined)
2674
+
2675
+ // After a crash: every armed timeout is cleared, and the addon's promise
2676
+ // never settles, so nothing is handed back to wasm.
2677
+ const settled: number[] = []
2678
+ const long = addonSchedule(4, 60_000)
2679
+ long.then(
2680
+ () => settled.push(4),
2681
+ () => settled.push(4),
2682
+ )
2683
+ failCancel = true
2684
+ const failing = addonSchedule(5, 60_000)
2685
+ failing.then(
2686
+ () => settled.push(5),
2687
+ () => settled.push(5),
2688
+ )
2689
+ log.length = 0
2690
+ t.notThrows(() => release())
2691
+ t.deepEqual(log, ['clear 4', 'clear 5'])
2692
+ t.false(armed.has(4))
2693
+ t.false(armed.has(5))
2694
+ // Idempotent, and a sleep armed afterwards is never armed.
2695
+ release()
2696
+ const late = addonSchedule(6, 10)
2697
+ late.then(
2698
+ () => settled.push(6),
2699
+ () => settled.push(6),
2700
+ )
2701
+ t.deepEqual(log, ['clear 4', 'clear 5'])
2702
+ await new Promise((resolve) => setImmediate(resolve))
2703
+ t.deepEqual(settled, [])
2704
+ })
2705
+
2706
+ /**
2707
+ * Runs the node loader's real disposal chain — the async-work drain, its
2708
+ * keep-alive interval, the barrier's two-phase poll, the step boundaries and
2709
+ * the public disposer — against a stubbed addon on real timers, so a crash can
2710
+ * land while a poll is waiting. The stubs record every wasm entry and every
2711
+ * keep-alive interval, and the counters report how often each poll read.
2712
+ */
2713
+ function createInflightWasiDisposal(
2714
+ code: string,
2715
+ addon: {
2716
+ asyncWorkPending: number
2717
+ runtimeWorkPending: number
2718
+ onRuntimeWorkRead?: () => void
2719
+ },
2720
+ ) {
2721
+ const latchStart = code.indexOf('const __wasiThreadCrashFlag = ')
2722
+ const latchState = code.slice(
2723
+ latchStart,
2724
+ code.indexOf('let __wasiThreadCrashed = false\n', latchStart) +
2725
+ 'let __wasiThreadCrashed = false\n'.length,
2726
+ )
2727
+ const steps: string[] = []
2728
+ const reads = { asyncWork: 0, runtimeWork: 0 }
2729
+ const intervals = new Set<ReturnType<typeof setInterval>>()
2730
+ // Stands in for the loader's global: timers are real, keep-alive intervals
2731
+ // are recorded.
2732
+ const host = {
2733
+ setTimeout,
2734
+ clearTimeout,
2735
+ WebAssembly,
2736
+ setInterval(callback: () => void, delay: number) {
2737
+ const timer = setInterval(callback, delay)
2738
+ intervals.add(timer)
2739
+ steps.push('interval set')
2740
+ return timer
2741
+ },
2742
+ clearInterval(timer: ReturnType<typeof setInterval>) {
2743
+ clearInterval(timer)
2744
+ if (intervals.delete(timer)) {
2745
+ steps.push('interval cleared')
2746
+ }
2747
+ },
2748
+ }
2749
+ const exports = {
2750
+ napi_wasm_async_work_pending() {
2751
+ reads.asyncWork += 1
2752
+ return addon.asyncWorkPending
2753
+ },
2754
+ napi_wasm_cancel_pending_async_work() {
2755
+ steps.push('cancel')
2756
+ },
2757
+ napi_prepare_wasm_env_cleanup_begin() {
2758
+ steps.push('begin')
2759
+ return 1
2760
+ },
2761
+ napi_prepare_wasm_env_cleanup_finish() {
2762
+ steps.push('finish')
2763
+ },
2764
+ napi_wasm_runtime_work_pending() {
2765
+ reads.runtimeWork += 1
2766
+ addon.onRuntimeWorkRead?.()
2767
+ return addon.runtimeWorkPending
2768
+ },
2769
+ }
2770
+ const context = {
2771
+ refCounter: {
2772
+ refHandle: {
2773
+ ref() {
2774
+ steps.push('port ref')
2775
+ },
2776
+ unref() {
2777
+ steps.push('port unref')
2778
+ },
2779
+ },
2780
+ },
2781
+ }
2782
+ const loader = new Function(
2783
+ 'globalThis',
2784
+ 'steps',
2785
+ '__napiInstance',
2786
+ '__emnapiContext',
2787
+ `
2788
+ let __wasiDisposed = false
2789
+ let __wasiDisposePromise
2790
+ let __wasiThreadCrashDisposePromise
2791
+ let __wasiAsyncWorkDrainPromise
2792
+ let __emnapiWasmEnvCleanupPrepared = false
2793
+ let __emnapiWasmEnvCleanupPreparing = false
2794
+ let __emnapiWasmEnvCleanupYielding = false
2795
+ let __emnapiWasmEnvCleanupRan = false
2796
+ let __finishParkedWasmEnvCleanup
2797
+ let __completeWasiDisposal = function () {
2798
+ steps.push('complete')
2799
+ }
2800
+ let __retainWasiRollbackForRetry = function () {
2801
+ steps.push('retain')
2802
+ }
2803
+ const __wasiRollbackRegistry = new Map()
2804
+ const __wasiRollbackRegistryKey = 'test.cjs'
2805
+ ${latchState}
2806
+ const __WASI_ASYNC_WORK_POLL_INTERVAL_MS = 1
2807
+ function __getWasiThreadManager() {
2808
+ return { terminateWorker() {} }
2809
+ }
2810
+ function __isThenable(value) {
2811
+ return value !== null && typeof value === 'object' && typeof value.then === 'function'
2812
+ }
2813
+ function __scheduleMacrotask(callback) {
2814
+ setImmediate(callback)
2815
+ }
2816
+ function __prepareWasmEnvCleanup() {
2817
+ steps.push('prepare (single call)')
2818
+ }
2819
+ function __createWasmRuntimePollPace() {
2820
+ return {}
2821
+ }
2822
+ function __yieldWasmRuntimePollTurn() {
2823
+ return new Promise((resolve) => globalThis.setTimeout(resolve, 1))
2824
+ }
2825
+ function __drainWasmEnvCleanup() {
2826
+ steps.push('drain')
2827
+ }
2828
+ function __destroyEmnapiContext() {
2829
+ steps.push('destroy')
2830
+ }
2831
+ function __terminateWasiWorkers() {
2832
+ steps.push('terminate')
2833
+ return Promise.resolve()
2834
+ }
2835
+ ${generatedFunction(code, '__createCleanupError')}
2836
+ ${generatedFunction(code, '__attachCleanupErrors')}
2837
+ ${generatedFunction(code, '__hasWasiThreadCrashed')}
2838
+ ${generatedFunction(code, '__readWasiThreadCrashReport')}
2839
+ ${generatedFunction(code, '__recordWasiThreadCrashError')}
2840
+ ${generatedFunction(code, '__getWasiThreadCrashError')}
2841
+ ${generatedFunction(code, '__fillWasiThreadCrashError')}
2842
+ ${generatedFunction(code, '__releaseEmnapiWaitingRequestHandle')}
2843
+ ${generatedFunction(code, '__disposeWasiBindingAfterThreadCrash')}
2844
+ ${generatedFunction(code, '__abortWasiDisposalIfThreadCrashed')}
2845
+ ${generatedFunction(code, '__settleWasiDisposalAfterThreadCrash')}
2846
+ ${generatedFunction(code, '__scheduleTimer')}
2847
+ ${generatedFunction(code, '__keepEventLoopAliveUntil')}
2848
+ ${generatedFunction(code, '__drainWasiAsyncWork')}
2849
+ ${generatedFunction(code, '__prepareWasmEnvCleanupWithTurns')}
2850
+ ${generatedFunction(code, '__finishWasiDisposal')}
2851
+ ${generatedFunction(code, '__continueWasiDisposal')}
2852
+ ${generatedFunction(code, '__drainWasmEnvForWasiDisposal')}
2853
+ ${generatedFunction(code, '__cleanUpWasmEnvForWasiDisposal')}
2854
+ ${generatedFunction(code, '__startWasiDisposal')}
2855
+ ${generatedFunction(code, '__disposeWasiBinding')}
2856
+ ${generatedFunction(code, '__finishWasiInitializationRollback')}
2857
+ ${generatedFunction(code, '__destroyContextForWasiRollback')}
2858
+ ${generatedFunction(code, '__retainFailedWasiRollback')}
2859
+ ${generatedFunction(code, '__runWasiInitializationRollbackSteps')}
2860
+ ${generatedFunction(code, '__rollbackWasiInitialization')}
2861
+ ${generatedFunction(code, '__rollbackWasiInitializationAfterThreadCrash')}
2862
+ ${generatedFunction(code, '__completeWasiInitializationRollback')}
2863
+ ${generatedFunction(code, '__runWasiInitializationRollback')}
2864
+ return {
2865
+ dispose: __disposeWasiBinding,
2866
+ drainAsyncWork: __drainWasiAsyncWork,
2867
+ rollback: __rollbackWasiInitialization,
2868
+ // What the loader's catch does when instantiation fails: start the rollback
2869
+ // and throw the initialization error itself.
2870
+ failInitialization(error) {
2871
+ const record = {
2872
+ active: false,
2873
+ error,
2874
+ promise: undefined,
2875
+ rollback: __rollbackWasiInitialization,
2876
+ }
2877
+ __wasiRollbackRegistry.set(__wasiRollbackRegistryKey, record)
2878
+ __runWasiInitializationRollback(record)
2879
+ return record
2880
+ },
2881
+ registered() {
2882
+ return __wasiRollbackRegistry.has(__wasiRollbackRegistryKey)
2883
+ },
2884
+ // The loader's own 'error' listener.
2885
+ workerError(error, threadId) {
2886
+ __wasiThreadCrashed = true
2887
+ __recordWasiThreadCrashError(error, threadId)
2888
+ },
2889
+ crash() {
2890
+ Atomics.store(__wasiThreadCrashFlag, 0, 1)
2891
+ },
2892
+ parked() {
2893
+ return __finishParkedWasmEnvCleanup !== undefined
2894
+ },
2895
+ }
2896
+ `,
2897
+ )(host, steps, { exports }, context)
2898
+ return {
2899
+ ...(loader as {
2900
+ dispose: () => Promise<unknown>
2901
+ drainAsyncWork: () => Promise<unknown> | undefined
2902
+ rollback: () => unknown
2903
+ failInitialization: (error: unknown) => {
2904
+ active: boolean
2905
+ error: any
2906
+ promise: Promise<void> | undefined
2907
+ }
2908
+ registered: () => boolean
2909
+ workerError: (error: unknown, threadId: number) => void
2910
+ crash: () => void
2911
+ parked: () => boolean
2912
+ }),
2913
+ steps,
2914
+ reads,
2915
+ liveIntervals: () => intervals.size,
2916
+ }
2917
+ }
2918
+
2919
+ async function waitUntil(condition: () => boolean, label: string) {
2920
+ const deadline = Date.now() + 5000
2921
+ while (!condition()) {
2922
+ if (Date.now() > deadline) {
2923
+ throw new Error(`timed out waiting for ${label}`)
2924
+ }
2925
+ await new Promise((resolve) => setTimeout(resolve, 1))
2926
+ }
2927
+ }
2928
+
2929
+ async function settleWithin(promise: Promise<unknown>, ms = 2000) {
2930
+ let timer: ReturnType<typeof setTimeout> | undefined
2931
+ const timeout = new Promise((resolve) => {
2932
+ timer = setTimeout(() => resolve({ pending: true }), ms)
2933
+ })
2934
+ try {
2935
+ return await Promise.race([
2936
+ promise.then(
2937
+ (value) => ({ value }),
2938
+ (error) => ({ error }),
2939
+ ),
2940
+ timeout,
2941
+ ])
2942
+ } finally {
2943
+ clearTimeout(timer)
2944
+ }
2945
+ }
2946
+
2947
+ const CRASH_DISPOSAL_MESSAGE =
2948
+ 'napi-rs: WASI binding cannot be disposed after a worker thread crashed'
2949
+
2950
+ // The entry check only sees a crash that came first. A worker that dies while
2951
+ // dispose() is waiting leaves the async-work drain polling a count the dead
2952
+ // thread never releases, and its 1 ms timer plus the 50 ms keep-alive interval
2953
+ // held the process open for good with the promise pending. The drain now stops
2954
+ // on the next turn and the disposer rejects through the crash disposal.
2955
+ test('node WASI disposal stops the async-work drain when a worker crashes mid-flight', async (t) => {
2956
+ const code = createWasiBinding('test', '@scope/test')
2957
+ const binding = createInflightWasiDisposal(code, {
2958
+ asyncWorkPending: 1,
2959
+ runtimeWorkPending: 0,
2960
+ })
2961
+ const first = binding.dispose()
2962
+ await waitUntil(() => binding.reads.asyncWork >= 5, 'the drain to poll')
2963
+ t.is(binding.liveIntervals(), 1, 'the keep-alive interval is armed')
2964
+ binding.crash()
2965
+ // A caller that arrives before the poll notices joins the same disposal.
2966
+ const joined = binding.dispose()
2967
+ const outcome: any = await settleWithin(first)
2968
+ t.falsy(outcome.pending, 'the original promise settles')
2969
+ t.true(outcome.error instanceof Error)
2970
+ t.is(outcome.error.message, CRASH_DISPOSAL_MESSAGE)
2971
+ t.is(joined, first)
2972
+ // Every later call gets the same promise, and it stays rejected.
2973
+ const later = binding.dispose()
2974
+ t.is(later, first)
2975
+ t.is(((await settleWithin(later)) as any).error, outcome.error)
2976
+
2977
+ t.is(binding.liveIntervals(), 0, 'the keep-alive interval is cleared')
2978
+ const readsAfter = binding.reads.asyncWork
2979
+ await new Promise((resolve) => setTimeout(resolve, 20))
2980
+ t.is(binding.reads.asyncWork, readsAfter, 'the drain stopped polling')
2981
+ // Nothing re-entered the barrier, the context or the addon after the crash;
2982
+ // the port is released and the workers terminated exactly once.
2983
+ t.deepEqual(binding.steps, [
2984
+ 'cancel',
2985
+ 'interval set',
2986
+ 'interval cleared',
2987
+ 'port unref',
2988
+ 'terminate',
2989
+ ])
2990
+ })
2991
+
2992
+ // Same race one step later: the drain passed, the barrier's two-phase form
2993
+ // began, and its poll waits for runtime work the dead thread still counts.
2994
+ // `…_finish` would join that work and block this thread for good, so the
2995
+ // barrier is left parked and never finished.
2996
+ test('node WASI disposal stops the barrier poll when a worker crashes mid-flight', async (t) => {
2997
+ const code = createWasiBinding('test', '@scope/test')
2998
+ for (const workDrainsWithCrash of [false, true]) {
2999
+ const label = `work drains in the crash turn: ${workDrainsWithCrash}`
3000
+ const addon: Parameters<typeof createInflightWasiDisposal>[1] = {
3001
+ asyncWorkPending: 0,
3002
+ runtimeWorkPending: 1,
3003
+ }
3004
+ const binding = createInflightWasiDisposal(code, addon)
3005
+ const first = binding.dispose()
3006
+ await waitUntil(() => binding.reads.runtimeWork >= 5, 'the barrier to poll')
3007
+ t.true(binding.parked(), label)
3008
+ if (workDrainsWithCrash) {
3009
+ // The thread dies while the poll is reading, and the count it reads is
3010
+ // zero: the poll returns normally and would go straight on to
3011
+ // `…_finish`.
3012
+ await new Promise<void>((resolve) => {
3013
+ addon.onRuntimeWorkRead = () => {
3014
+ addon.onRuntimeWorkRead = undefined
3015
+ binding.crash()
3016
+ addon.runtimeWorkPending = 0
3017
+ resolve()
3018
+ }
3019
+ })
3020
+ } else {
3021
+ binding.crash()
3022
+ }
3023
+ const outcome: any = await settleWithin(first)
3024
+ t.falsy(outcome.pending, label)
3025
+ t.is(outcome.error?.message, CRASH_DISPOSAL_MESSAGE, label)
3026
+ t.is(binding.dispose(), first, label)
3027
+ t.false(binding.parked(), label)
3028
+ const readsAfter = binding.reads.runtimeWork
3029
+ await new Promise((resolve) => setTimeout(resolve, 20))
3030
+ t.is(binding.reads.runtimeWork, readsAfter, label)
3031
+ t.is(binding.liveIntervals(), 0, label)
3032
+ t.deepEqual(binding.steps, ['begin', 'port unref', 'terminate'], label)
3033
+ }
3034
+ })
3035
+
3036
+ // Without a crash the same chain runs every step exactly as before and
3037
+ // resolves; the keep-alive interval lives only as long as the drain.
3038
+ test('node WASI disposal without a crash runs the whole chain on real timers', async (t) => {
3039
+ const code = createWasiBinding('test', '@scope/test')
3040
+ const addon = { asyncWorkPending: 1, runtimeWorkPending: 1 }
3041
+ const binding = createInflightWasiDisposal(code, addon)
3042
+ const first = binding.dispose()
3043
+ await waitUntil(() => binding.reads.asyncWork >= 3, 'the drain to poll')
3044
+ addon.asyncWorkPending = 0
3045
+ await waitUntil(() => binding.reads.runtimeWork >= 3, 'the barrier to poll')
3046
+ addon.runtimeWorkPending = 0
3047
+ t.deepEqual(await settleWithin(first), { value: undefined })
3048
+ t.deepEqual(binding.steps, [
3049
+ 'cancel',
3050
+ 'interval set',
3051
+ 'interval cleared',
3052
+ 'begin',
3053
+ 'finish',
3054
+ 'drain',
3055
+ 'destroy',
3056
+ 'terminate',
3057
+ 'complete',
3058
+ ])
3059
+ t.is(binding.liveIntervals(), 0)
3060
+ })
3061
+
3062
+ // The crash check fires only while a public disposal or the initialization
3063
+ // rollback is in flight. A poll started by neither keeps polling.
3064
+ test('node WASI crash abort leaves a poll outside disposal and rollback alone', async (t) => {
3065
+ const code = createWasiBinding('test', '@scope/test')
3066
+ const addon = { asyncWorkPending: 1, runtimeWorkPending: 0 }
3067
+ const binding = createInflightWasiDisposal(code, addon)
3068
+ const drain = binding.drainAsyncWork()
3069
+ t.truthy(drain)
3070
+ binding.crash()
3071
+ const readsAtCrash = binding.reads.asyncWork
3072
+ await waitUntil(
3073
+ () => binding.reads.asyncWork >= readsAtCrash + 5,
3074
+ 'the drain to keep polling',
3075
+ )
3076
+ addon.asyncWorkPending = 0
3077
+ t.deepEqual(await settleWithin(drain!), { value: undefined })
3078
+ t.false(binding.steps.includes('terminate'))
3079
+ t.false(binding.steps.includes('port unref'))
3080
+ })
3081
+
3082
+ // The initialization rollback runs the same drain as dispose(). A worker that
3083
+ // died while it polled left the count above zero, and the rollback polled it
3084
+ // forever with its timers holding the process open. It now stops, skips the
3085
+ // barrier and the destroy, and lets the initialization error through with the
3086
+ // crash attached.
3087
+ test('node WASI initialization rollback stops its async-work drain when a worker crashes', async (t) => {
3088
+ const code = createWasiBinding('test', '@scope/test')
3089
+ const binding = createInflightWasiDisposal(code, {
3090
+ asyncWorkPending: 1,
3091
+ runtimeWorkPending: 0,
3092
+ })
3093
+ const initError = new Error('instantiate failed')
3094
+ const record = binding.failInitialization(initError)
3095
+ const rollback = record.promise
3096
+ t.truthy(rollback, 'the rollback waits for the drain')
3097
+ await waitUntil(() => binding.reads.asyncWork >= 5, 'the rollback to poll')
3098
+ t.is(binding.liveIntervals(), 1)
3099
+ binding.crash()
3100
+ t.falsy(((await settleWithin(rollback!)) as any).pending)
3101
+
3102
+ // The error the loader threw is the initialization error, and the crash is
3103
+ // its cause.
3104
+ t.is(record.error, initError)
3105
+ t.true(record.error.cause instanceof Error)
3106
+ t.is(record.error.cause.message, CRASH_DISPOSAL_MESSAGE)
3107
+ t.false(record.active)
3108
+ t.true(binding.registered(), 'kept: the context was not destroyed')
3109
+
3110
+ t.is(binding.liveIntervals(), 0, 'the keep-alive interval is cleared')
3111
+ const readsAfter = binding.reads.asyncWork
3112
+ await new Promise((resolve) => setTimeout(resolve, 20))
3113
+ t.is(binding.reads.asyncWork, readsAfter, 'the drain stopped polling')
3114
+ // No barrier, no settlement drain, no destroy: the port is released and the
3115
+ // workers are terminated once.
3116
+ t.deepEqual(binding.steps, [
3117
+ 'cancel',
3118
+ 'interval set',
3119
+ 'interval cleared',
3120
+ 'retain',
3121
+ 'port unref',
3122
+ 'terminate',
3123
+ ])
3124
+
3125
+ // The worker's 'error' event arrives after the rollback ended: the crash
3126
+ // error it already attached gets the worker's error as its cause.
3127
+ t.is(record.error.cause.cause, undefined)
3128
+ const workerError = new Error('trap in worker')
3129
+ binding.workerError(workerError, 7)
3130
+ t.is(record.error.cause.cause, workerError)
3131
+ t.is(record.error.cause.workerThreadId, 7)
3132
+ })
3133
+
3134
+ // Same race one step later: the barrier began and its poll waits for runtime
3135
+ // work the dead thread still counts. `…_finish` would join that work, so the
3136
+ // barrier stays parked and is never finished.
3137
+ test('node WASI initialization rollback stops the barrier poll when a worker crashes', async (t) => {
3138
+ const code = createWasiBinding('test', '@scope/test')
3139
+ const binding = createInflightWasiDisposal(code, {
3140
+ asyncWorkPending: 0,
3141
+ runtimeWorkPending: 1,
3142
+ })
3143
+ const initError = new Error('instantiate failed')
3144
+ const record = binding.failInitialization(initError)
3145
+ const rollback = record.promise
3146
+ t.truthy(rollback)
3147
+ await waitUntil(() => binding.reads.runtimeWork >= 5, 'the barrier to poll')
3148
+ t.true(binding.parked())
3149
+ binding.crash()
3150
+ t.falsy(((await settleWithin(rollback!)) as any).pending)
3151
+ t.is(record.error, initError)
3152
+ t.is(record.error.cause?.message, CRASH_DISPOSAL_MESSAGE)
3153
+ t.false(binding.parked())
3154
+ const readsAfter = binding.reads.runtimeWork
3155
+ await new Promise((resolve) => setTimeout(resolve, 20))
3156
+ t.is(binding.reads.runtimeWork, readsAfter)
3157
+ t.deepEqual(binding.steps, ['begin', 'retain', 'port unref', 'terminate'])
3158
+ })
3159
+
3160
+ // A thread that died before instantiation failed: the rollback does not enter
3161
+ // wasm at all, and it stays synchronous, so the error the loader throws right
3162
+ // away already carries the crash.
3163
+ test('node WASI initialization rollback after an earlier crash skips wasm', (t) => {
3164
+ const code = createWasiBinding('test', '@scope/test')
3165
+ const binding = createInflightWasiDisposal(code, {
3166
+ asyncWorkPending: 1,
3167
+ runtimeWorkPending: 1,
3168
+ })
3169
+ binding.crash()
3170
+ const initError = new Error('instantiate failed')
3171
+ const record = binding.failInitialization(initError)
3172
+ t.is(record.promise, undefined, 'settled synchronously')
3173
+ t.is(record.error, initError)
3174
+ t.is(record.error.cause?.message, CRASH_DISPOSAL_MESSAGE)
3175
+ t.is(binding.reads.asyncWork, 0)
3176
+ t.is(binding.reads.runtimeWork, 0)
3177
+ t.deepEqual(binding.steps, ['port unref', 'terminate'])
3178
+
3179
+ // An initialization error that already has a cause keeps it; the crash is
3180
+ // listed with the cleanup errors instead. The crash error is shared, and the
3181
+ // workers are not terminated a second time.
3182
+ const withCause = new Error('instantiate failed', {
3183
+ cause: new Error('link error'),
3184
+ })
3185
+ const second = binding.failInitialization(withCause)
3186
+ t.is(second.error, withCause)
3187
+ t.is(second.error.cause.message, 'link error')
3188
+ t.is(second.error.cleanupErrors[0], record.error.cause)
3189
+ t.deepEqual(binding.steps, ['port unref', 'terminate'])
3190
+ })
3191
+
3192
+ // Without a crash the rollback runs every step exactly as before, resolves
3193
+ // with no cleanup errors and leaves the initialization error untouched.
3194
+ test('node WASI initialization rollback without a crash is unchanged', async (t) => {
3195
+ const code = createWasiBinding('test', '@scope/test')
3196
+ const addon = { asyncWorkPending: 1, runtimeWorkPending: 1 }
3197
+ const binding = createInflightWasiDisposal(code, addon)
3198
+ const initError = new Error('instantiate failed')
3199
+ const record = binding.failInitialization(initError)
3200
+ const rollback = record.promise
3201
+ t.truthy(rollback)
3202
+ await waitUntil(() => binding.reads.asyncWork >= 3, 'the drain to poll')
3203
+ addon.asyncWorkPending = 0
3204
+ await waitUntil(() => binding.reads.runtimeWork >= 3, 'the barrier to poll')
3205
+ addon.runtimeWorkPending = 0
3206
+ t.deepEqual(await settleWithin(rollback!), { value: undefined })
3207
+ t.is(record.error, initError)
3208
+ t.is(record.error.cause, undefined)
3209
+ t.is(record.error.cleanupErrors, undefined)
3210
+ t.false(binding.registered(), 'a clean rollback is not kept')
3211
+ t.deepEqual(binding.steps, [
3212
+ 'cancel',
3213
+ 'interval set',
3214
+ 'interval cleared',
3215
+ 'begin',
3216
+ 'finish',
3217
+ 'drain',
3218
+ 'destroy',
3219
+ 'terminate',
3220
+ ])
3221
+ t.is(binding.liveIntervals(), 0)
3222
+ })
3223
+
3224
+ // The shared flag is raised before this thread has processed the worker's
3225
+ // 'error' event, and once the workers are terminated emnapi ignores that event,
3226
+ // so `_fatalError` was often still unset when the crash error was built and
3227
+ // the cause was lost. The loader's own listener keeps the first error, and the
3228
+ // crash error picks it up whenever it arrives.
3229
+ test('node WASI crash error takes its cause from the worker error listener', async (t) => {
3230
+ const code = createWasiBinding('test', '@scope/test')
3231
+
3232
+ // The event lands while the crash disposal is terminating the workers.
3233
+ {
3234
+ const binding = createInflightWasiDisposal(code, {
3235
+ asyncWorkPending: 1,
3236
+ runtimeWorkPending: 0,
3237
+ })
3238
+ const first = binding.dispose()
3239
+ await waitUntil(() => binding.reads.asyncWork >= 3, 'the drain to poll')
3240
+ binding.crash()
3241
+ const workerError = new Error('trap in worker')
3242
+ binding.workerError(workerError, 3)
3243
+ // Only the first error is kept.
3244
+ binding.workerError(new Error('second worker'), 4)
3245
+ const outcome: any = await settleWithin(first)
3246
+ t.is(outcome.error?.message, CRASH_DISPOSAL_MESSAGE)
3247
+ t.is(outcome.error.cause, workerError)
3248
+ t.is(outcome.error.workerThreadId, 3)
3249
+ }
3250
+
3251
+ // The event lands after the disposal already rejected: the one shared crash
3252
+ // error is completed in place, so every holder of it sees the cause.
3253
+ {
3254
+ const binding = createInflightWasiDisposal(code, {
3255
+ asyncWorkPending: 1,
3256
+ runtimeWorkPending: 0,
3257
+ })
3258
+ const first = binding.dispose()
3259
+ await waitUntil(() => binding.reads.asyncWork >= 3, 'the drain to poll')
3260
+ binding.crash()
3261
+ const outcome: any = await settleWithin(first)
3262
+ t.is(outcome.error?.message, CRASH_DISPOSAL_MESSAGE)
3263
+ t.is(outcome.error.cause, undefined)
3264
+ const workerError = new Error('trap in worker')
3265
+ binding.workerError(workerError, 9)
3266
+ t.is(outcome.error.cause, workerError)
3267
+ t.is(outcome.error.workerThreadId, 9)
3268
+ const later: any = await settleWithin(binding.dispose())
3269
+ t.is(later.error, outcome.error)
3270
+ }
3271
+ })
3272
+
3273
+ test('WASI loaders without the crash latch keep their disposal polls unchanged', (t) => {
3274
+ const loaders = {
3275
+ 'threadless node': createWasiBinding(
3276
+ 'test',
3277
+ '@scope/test',
3278
+ 4000,
3279
+ 65536,
3280
+ false,
3281
+ ),
3282
+ 'threaded browser': createWasiBrowserBinding('test-wasi'),
3283
+ 'threadless browser': createWasiBrowserBinding(
3284
+ 'test-wasi',
3285
+ 4000,
3286
+ 65536,
3287
+ false,
3288
+ false,
3289
+ false,
3290
+ false,
3291
+ false,
3292
+ ),
3293
+ }
3294
+ for (const [label, code] of Object.entries(loaders)) {
3295
+ t.false(code.includes('__abortWasiDisposalIfThreadCrashed'), label)
3296
+ t.false(code.includes('__settleWasiDisposalAfterThreadCrash'), label)
3297
+ t.false(code.includes('finishCleanupUnlessCrashed'), label)
3298
+ t.false(code.includes('__wasiInitializationRollbackActive'), label)
3299
+ t.false(code.includes('__runWasiInitializationRollbackSteps'), label)
3300
+ t.false(
3301
+ code.includes('__rollbackWasiInitializationAfterThreadCrash'),
3302
+ label,
3303
+ )
3304
+ t.false(code.includes('__getWasiThreadCrashError'), label)
3305
+ t.false(code.includes('__recordWasiThreadCrashError'), label)
3306
+ t.false(code.includes('__readWasiThreadCrashReport'), label)
3307
+ t.true(code.includes('function __rollbackWasiInitialization() {\n'), label)
3308
+ t.true(
3309
+ generatedFunction(code, '__prepareWasmEnvCleanupWithTurns').includes(
3310
+ ' })().then(finishCleanup, finishCleanup)\n',
3311
+ ),
3312
+ label,
3313
+ )
3314
+ }
3315
+ // The threaded node loader checks the latch in every disposal poll and step,
3316
+ // and in the rollback's steps.
3317
+ const threaded = createWasiBinding('test', '@scope/test')
3318
+ for (const name of [
3319
+ '__drainWasiAsyncWork',
3320
+ '__prepareWasmEnvCleanupWithTurns',
3321
+ '__cleanUpWasmEnvForWasiDisposal',
3322
+ '__drainWasmEnvForWasiDisposal',
3323
+ '__continueWasiDisposal',
3324
+ '__destroyContextForWasiRollback',
3325
+ ]) {
3326
+ t.true(
3327
+ generatedFunction(threaded, name).includes(
3328
+ '__abortWasiDisposalIfThreadCrashed()',
3329
+ ),
3330
+ name,
3331
+ )
3332
+ }
3333
+ const steps = generatedFunction(
3334
+ threaded,
3335
+ '__runWasiInitializationRollbackSteps',
3336
+ )
3337
+ t.is(
3338
+ steps.split(' __abortWasiDisposalIfThreadCrashed()\n').length - 1,
3339
+ 2,
3340
+ 'before the barrier and before the settlement drain',
3341
+ )
3342
+ })
3343
+
3344
+ test('node WASI loader shares the crash flag with its pool workers', (t) => {
3345
+ const code = createWasiBinding('test', '@scope/test')
3346
+ const createWorker = generatedFunction(code, '__createWasiWorker')
3347
+ t.true(createWorker.includes('crashFlag: __wasiThreadCrashFlag,'))
3348
+ t.true(createWorker.includes('crashReport: __wasiThreadCrashReport,'))
3349
+ const onCreateWorker = code.slice(
3350
+ code.indexOf('onCreateWorker() {'),
3351
+ code.indexOf('return worker\n', code.indexOf('onCreateWorker() {')),
3352
+ )
3353
+ t.true(
3354
+ onCreateWorker.includes("worker.on('error', (error) => {"),
3355
+ 'the loader has to see a worker error even when the worker never set the flag',
3356
+ )
3357
+ t.true(onCreateWorker.includes('__wasiThreadCrashed = true'))
3358
+ t.true(
3359
+ onCreateWorker.includes(
3360
+ '__recordWasiThreadCrashError(error, worker.threadId)',
3361
+ ),
3362
+ 'and keeps the error it carries for the crash error',
3363
+ )
3364
+ })
3365
+
3366
+ // The threadless flavor has no pool workers and must not reference the latch:
3367
+ // a name the exit listener cannot resolve would throw on every process exit.
3368
+ test('threadless node WASI loader keeps its exit teardown unconditional', (t) => {
3369
+ const code = createWasiBinding('test', '@scope/test', 4000, 65536, false)
3370
+ t.false(code.includes('__hasWasiThreadCrashed'))
3371
+ t.false(code.includes('__wasiThreadCrash'))
3372
+ t.false(code.includes('SharedArrayBuffer'))
3373
+ t.true(generatedFunction(code, '__disposeWasiBindingAtExit').length > 0)
3374
+ t.false(code.includes('__disposeWasiBindingAfterThreadCrash'))
3375
+ t.false(code.includes('__releaseEmnapiWaitingRequestHandle'))
3376
+ t.true(
3377
+ generatedFunction(code, '__disposeWasiBinding').startsWith(
3378
+ 'function __disposeWasiBinding() {\n if (__wasiDisposePromise) {\n',
3379
+ ),
3380
+ )
3381
+ })
3382
+
3383
+ // The browser loader has no 'exit' teardown and no crash latch; its public
3384
+ // disposer is unchanged.
3385
+ test('browser WASI loader public disposer has no crash latch', (t) => {
3386
+ for (const threads of [true, false]) {
3387
+ const code = createWasiBrowserBinding(
3388
+ 'test-wasi',
3389
+ 4000,
3390
+ 65536,
3391
+ false,
3392
+ false,
3393
+ false,
3394
+ false,
3395
+ threads,
3396
+ )
3397
+ t.false(code.includes('__hasWasiThreadCrashed'), `threads=${threads}`)
3398
+ t.false(
3399
+ code.includes('__disposeWasiBindingAfterThreadCrash'),
3400
+ `threads=${threads}`,
3401
+ )
3402
+ }
3403
+ })
3404
+
3405
+ /**
3406
+ * The column-zero `if` of the Node worker that wraps emnapi's
3407
+ * `beforeReportError`, with the helpers it calls: a function body over
3408
+ * `workerData`, `handler` and `threadId`.
3409
+ */
3410
+ function wasiWorkerCrashHook(): string {
3411
+ const start =
3412
+ WASI_WORKER_TEMPLATE.indexOf(
3413
+ '\nif (workerData && workerData.crashFlag instanceof Int32Array) {\n',
3414
+ ) + 1
3415
+ return (
3416
+ WASI_WORKER_TEMPLATE.slice(
3417
+ start,
3418
+ WASI_WORKER_TEMPLATE.indexOf('\n}\n', start) + 2,
3419
+ ) +
3420
+ generatedFunction(WASI_WORKER_TEMPLATE, '__raiseWasiThreadCrashFlags') +
3421
+ generatedFunction(WASI_WORKER_TEMPLATE, '__writeCrashReport')
3422
+ )
3423
+ }
3424
+
3425
+ test('WASI worker sets the crash flag before emnapi reports the error', (t) => {
3426
+ assertValidJS(t, WASI_WORKER_TEMPLATE, 'Node WASI worker')
3427
+ const start = WASI_WORKER_TEMPLATE.indexOf(
3428
+ '\nif (workerData && workerData.crashFlag instanceof Int32Array) {',
3429
+ )
3430
+ t.true(start > 0)
3431
+ t.true(
3432
+ start < WASI_WORKER_TEMPLATE.indexOf('globalThis.onmessage = function'),
3433
+ 'the hook has to be in place before the first message is handled',
3434
+ )
3435
+ const latch = wasiWorkerCrashHook()
3436
+
3437
+ const run = (workerData: { crashFlag?: Int32Array } & object) => {
3438
+ const reports: string[] = []
3439
+ const handler = {
3440
+ // emnapi's own hook, which lets the main thread start exiting.
3441
+ beforeReportError(this: unknown, error: Error, type: string) {
3442
+ const flag = workerData.crashFlag
3443
+ ? Atomics.load(workerData.crashFlag, 0)
3444
+ : 'none'
3445
+ reports.push(
3446
+ `${error.message} ${type} flag=${flag} ${this === handler}`,
3447
+ )
3448
+ },
3449
+ }
3450
+ const original = handler.beforeReportError
3451
+ new Function('workerData', 'handler', 'threadId', latch)(
3452
+ workerData,
3453
+ handler,
3454
+ 1,
3455
+ )
3456
+ return { handler, original, reports }
3457
+ }
3458
+
3459
+ const crashFlag = new Int32Array(new SharedArrayBuffer(4))
3460
+ const crashed = run({ crashFlag })
3461
+ t.not(crashed.handler.beforeReportError, crashed.original)
3462
+ crashed.handler.beforeReportError(new Error('trap'), 'start')
3463
+ // Set before emnapi's hook runs, and emnapi's hook still runs on the handler.
3464
+ t.deepEqual(crashed.reports, ['trap start flag=1 true'])
3465
+ t.is(Atomics.load(crashFlag, 0), 1)
3466
+
3467
+ // A loader that predates the flag passes none: the hook stays emnapi's own.
3468
+ const legacy = run({ hostRoot: '/', rootDir: '/' } as object)
3469
+ t.is(legacy.handler.beforeReportError, legacy.original)
3470
+ })
3471
+
3472
+ // The loader thread can be inside a wasm cleanup call that waits on the dead
3473
+ // thread. The addon's flag makes those waits trap; the loader must already see
3474
+ // its own flag when the trap reaches it.
3475
+ test('WASI worker raises the addon crash flag after the loader flag', (t) => {
3476
+ const latch = wasiWorkerCrashHook()
3477
+ const crash = (
3478
+ instance: unknown,
3479
+ workerData: { crashFlag: Int32Array; addonCrashFlag?: unknown },
3480
+ ) => {
3481
+ const calls: string[] = []
3482
+ const handler = {
3483
+ instance,
3484
+ beforeReportError() {
3485
+ calls.push(`emnapi flag=${Atomics.load(workerData.crashFlag, 0)}`)
3486
+ },
3487
+ }
3488
+ new Function('workerData', 'handler', 'threadId', latch)(
3489
+ workerData,
3490
+ handler,
3491
+ 1,
3492
+ )
3493
+ ;(handler.beforeReportError as (...args: unknown[]) => void)(
3494
+ new Error('trap'),
3495
+ 'start',
3496
+ )
3497
+ return calls
3498
+ }
3499
+
3500
+ // The loader's view of the addon's flag: one word of the shared wasm memory,
3501
+ // at an offset. A worker that failed while loading has no instance.
3502
+ const memory = new WebAssembly.Memory({
3503
+ initial: 1,
3504
+ maximum: 1,
3505
+ shared: true,
3506
+ })
3507
+ const word = new Int32Array(memory.buffer, 64, 1)
3508
+ const crashFlag = new Int32Array(new SharedArrayBuffer(4))
3509
+ const calls: string[] = []
3510
+ const workerData = {
3511
+ crashFlag,
3512
+ get addonCrashFlag() {
3513
+ calls.push(`view read, loader flag=${Atomics.load(crashFlag, 0)}`)
3514
+ return word
3515
+ },
3516
+ }
3517
+ calls.push(...crash(undefined, workerData))
3518
+ t.deepEqual(calls, ['view read, loader flag=1', 'emnapi flag=1'])
3519
+ t.is(Atomics.load(new Int32Array(memory.buffer), 16), 1)
3520
+ t.is(Atomics.load(new Int32Array(memory.buffer), 15), 0)
3521
+ t.is(Atomics.load(new Int32Array(memory.buffer), 17), 0)
3522
+
3523
+ // With a view the export is not needed: it is not called.
3524
+ const exported: string[] = []
3525
+ const withInstance = crash(
3526
+ {
3527
+ exports: {
3528
+ napi_wasm_thread_crashed() {
3529
+ exported.push('napi')
3530
+ },
3531
+ },
3532
+ },
3533
+ {
3534
+ crashFlag: new Int32Array(new SharedArrayBuffer(4)),
3535
+ addonCrashFlag: new Int32Array(new SharedArrayBuffer(4)),
3536
+ },
3537
+ )
3538
+ t.deepEqual(withInstance, ['emnapi flag=1'])
3539
+ t.deepEqual(exported, [])
3540
+
3541
+ // No view (an addon built with an older napi): the export, when the worker
3542
+ // has an instance, after the loader flag.
3543
+ const noViewFlag = new Int32Array(new SharedArrayBuffer(4))
3544
+ const noView: string[] = []
3545
+ noView.push(
3546
+ ...crash(
3547
+ {
3548
+ exports: {
3549
+ napi_wasm_thread_crashed() {
3550
+ noView.push(`napi flag=${Atomics.load(noViewFlag, 0)}`)
3551
+ },
3552
+ },
3553
+ },
3554
+ { crashFlag: noViewFlag },
3555
+ ),
3556
+ )
3557
+ t.deepEqual(noView, ['napi flag=1', 'emnapi flag=1'])
3558
+
3559
+ // A worker that never loaded, an addon without the export, an export that
3560
+ // throws, a view that is not an Int32Array: emnapi's hook still runs.
3561
+ for (const [instance, addonCrashFlag] of [
3562
+ [undefined, undefined],
3563
+ [{ exports: {} }, undefined],
3564
+ [
3565
+ {
3566
+ exports: {
3567
+ napi_wasm_thread_crashed() {
3568
+ throw new Error('boom')
3569
+ },
3570
+ },
3571
+ },
3572
+ undefined,
3573
+ ],
3574
+ [undefined, new Uint8Array(new SharedArrayBuffer(4))],
3575
+ ]) {
3576
+ t.deepEqual(
3577
+ crash(instance, {
3578
+ crashFlag: new Int32Array(new SharedArrayBuffer(4)),
3579
+ addonCrashFlag,
3580
+ }),
3581
+ ['emnapi flag=1'],
3582
+ )
3583
+ }
3584
+ })
3585
+
3586
+ /**
3587
+ * A shared wasm memory with a view of one word at `offset`, the way the loaders
3588
+ * build the addon's crash flag view.
3589
+ */
3590
+ function addonCrashFlagView(offset = 64) {
3591
+ const memory = new WebAssembly.Memory({
3592
+ initial: 1,
3593
+ maximum: 1,
3594
+ shared: true,
3595
+ })
3596
+ return { memory, word: new Int32Array(memory.buffer, offset, 1) }
3597
+ }
3598
+
3599
+ // A worker whose setup throws before the crash hook exists (here: the runtime
3600
+ // package cannot be resolved) never gets to emnapi's beforeReportError. Its
3601
+ // thread spawn has already returned, so the loader may be waiting on it inside
3602
+ // wasm: it has to raise both flags itself, then fail as before.
3603
+ test('WASI worker raises both crash flags when its setup throws', (t) => {
3604
+ const anchor = '\nlet handler\ntry {\n'
3605
+ const start = WASI_WORKER_TEMPLATE.indexOf(anchor) + 1
3606
+ t.true(start > 0)
3607
+ const catchStart = WASI_WORKER_TEMPLATE.indexOf('} catch (error) {\n', start)
3608
+ const setup =
3609
+ WASI_WORKER_TEMPLATE.slice(
3610
+ start,
3611
+ WASI_WORKER_TEMPLATE.indexOf('\n}\n', catchStart) + 2,
3612
+ ) +
3613
+ generatedFunction(WASI_WORKER_TEMPLATE, '__raiseWasiThreadCrashFlags') +
3614
+ generatedFunction(WASI_WORKER_TEMPLATE, '__writeCrashReport')
3615
+ t.true(
3616
+ setup.indexOf("require('@napi-rs/wasm-runtime')") <
3617
+ setup.indexOf('handler = new MessageHandler('),
3618
+ )
3619
+ const missing = new Error("Cannot find module '@napi-rs/wasm-runtime'")
3620
+ const run = (workerData: object | undefined) =>
3621
+ new Function('require', 'workerData', 'threadId', setup)(
3622
+ () => {
3623
+ throw missing
3624
+ },
3625
+ workerData,
3626
+ 3,
3627
+ )
3628
+
3629
+ const { memory, word } = addonCrashFlagView()
3630
+ const crashFlag = new Int32Array(new SharedArrayBuffer(4))
3631
+ const crashReport = new SharedArrayBuffer(4096)
3632
+ const order: number[] = []
3633
+ const workerData = {
3634
+ crashFlag,
3635
+ crashReport,
3636
+ get addonCrashFlag() {
3637
+ order.push(Atomics.load(crashFlag, 0))
3638
+ return word
3639
+ },
3640
+ }
3641
+ t.is(
3642
+ t.throws(() => run(workerData)),
3643
+ missing,
3644
+ )
3645
+ t.is(Atomics.load(crashFlag, 0), 1)
3646
+ t.deepEqual(order, [1], 'the loader flag goes up before the addon flag')
3647
+ t.is(Atomics.load(new Int32Array(memory.buffer), 16), 1)
3648
+ const header = new Int32Array(crashReport, 0, 3)
3649
+ t.is(Atomics.load(header, 0), 2)
3650
+ t.is(Atomics.load(header, 2), 3)
3651
+
3652
+ // A loader that predates the flags: the error is only rethrown.
3653
+ t.is(
3654
+ t.throws(() => run({ hostRoot: '/', rootDir: '/' })),
3655
+ missing,
3656
+ )
3657
+ t.is(
3658
+ t.throws(() => run(undefined)),
3659
+ missing,
3660
+ )
3661
+ })
3662
+
3663
+ // The same through a real worker thread: the generated worker, written where
3664
+ // '@napi-rs/wasm-runtime' cannot be resolved, gets the view in workerData — a
3665
+ // structured clone that keeps sharing the wasm memory — and raises the word.
3666
+ test('WASI worker that fails to load raises the flags from a real thread', async (t) => {
3667
+ const directory = await mkdtemp(join(tmpdir(), 'napi-rs-wasi-worker-'))
3668
+ t.teardown(() => rm(directory, { recursive: true, force: true }))
3669
+ const workerPath = join(directory, 'wasi-worker.mjs')
3670
+ await writeFile(workerPath, WASI_WORKER_TEMPLATE)
3671
+ const { memory, word } = addonCrashFlagView(128)
3672
+ const crashFlag = new Int32Array(new SharedArrayBuffer(4))
3673
+ const crashReport = new SharedArrayBuffer(4096)
3674
+ const worker = new Worker(workerPath, {
3675
+ workerData: { crashFlag, crashReport, addonCrashFlag: word },
3676
+ })
3677
+ const error = await new Promise<Error>((resolve, reject) => {
3678
+ worker.once('error', resolve)
3679
+ worker.once('exit', (code) =>
3680
+ reject(new Error(`the worker exited with ${code} and no error`)),
3681
+ )
3682
+ })
3683
+ t.regex(error.message, /@napi-rs\/wasm-runtime/)
3684
+ t.is(Atomics.load(crashFlag, 0), 1)
3685
+ t.is(Atomics.load(new Int32Array(memory.buffer), 32), 1)
3686
+ t.is(Atomics.load(new Int32Array(crashReport, 0, 1), 0), 2)
3687
+ })
3688
+
3689
+ /**
3690
+ * The Node loader's view capture and worker creation, over a real shared wasm
3691
+ * memory and a stub `Worker` that keeps its options.
3692
+ */
3693
+ function createNodeLoaderAddonCrashFlag() {
3694
+ const code = createWasiBinding('test', '@scope/test')
3695
+ const created: Array<{ filename: string; options: any }> = []
3696
+ class StubWorker {
3697
+ constructor(filename: string, options: unknown) {
3698
+ created.push({ filename, options })
3699
+ }
3700
+ }
3701
+ const loader = new Function(
3702
+ 'Worker',
3703
+ `
3704
+ const __sharedMemory = new WebAssembly.Memory({ initial: 1, maximum: 2, shared: true })
3705
+ const __hostRoot = '/'
3706
+ const __rootDir = '/'
3707
+ const __wasiThreadCrashFlag = new Int32Array(new SharedArrayBuffer(4))
3708
+ const __wasiThreadCrashReport = new SharedArrayBuffer(16)
3709
+ let __wasiAddonCrashFlag
3710
+ ${generatedFunction(code, '__captureWasiAddonCrashFlag')}
3711
+ ${generatedFunction(code, '__getWasiWorkerExecArgv')}
3712
+ ${generatedFunction(code, '__createWasiWorker')}
3713
+ return {
3714
+ memory: __sharedMemory,
3715
+ capture: __captureWasiAddonCrashFlag,
3716
+ createWorker: __createWasiWorker,
3717
+ view: () => __wasiAddonCrashFlag,
3718
+ }
3719
+ `,
3720
+ )(StubWorker) as {
3721
+ memory: WebAssembly.Memory
3722
+ capture: (instance: unknown) => void
3723
+ createWorker: (filename: string) => unknown
3724
+ view: () => Int32Array | undefined
3725
+ }
3726
+ return { code, created, ...loader }
3727
+ }
3728
+
3729
+ test('node WASI loader reads the addon crash flag address before registration', (t) => {
3730
+ const { code } = createNodeLoaderAddonCrashFlag()
3731
+ const beforeInit = code.slice(code.indexOf(' beforeInit({ instance }) {'))
3732
+ const capture = beforeInit.indexOf('__captureWasiAddonCrashFlag(instance)')
3733
+ t.true(capture > 0)
3734
+ t.true(capture < beforeInit.indexOf("name.startsWith('__napi_register__')"))
3735
+ t.true(
3736
+ generatedFunction(code, '__createWasiWorker').includes(
3737
+ 'addonCrashFlag: __wasiAddonCrashFlag,',
3738
+ ),
3739
+ )
3740
+ // The threadless loader has no workers and reads nothing.
3741
+ const threadless = createWasiBinding(
3742
+ 'test',
3743
+ '@scope/test',
3744
+ 4000,
3745
+ 65536,
3746
+ false,
3747
+ )
3748
+ t.false(threadless.includes('__captureWasiAddonCrashFlag'))
3749
+ t.false(threadless.includes('napi_wasm_thread_crash_flag_address'))
3750
+ })
3751
+
3752
+ test('node WASI loader passes each pool worker a view of the addon crash flag', (t) => {
3753
+ const loader = createNodeLoaderAddonCrashFlag()
3754
+ // Before the capture (or for an addon built with an older napi): no view.
3755
+ loader.createWorker('/w.mjs')
3756
+ t.is(loader.created[0].options.workerData.addonCrashFlag, undefined)
3757
+
3758
+ loader.capture({
3759
+ exports: { napi_wasm_thread_crash_flag_address: () => 1024 },
3760
+ })
3761
+ const view = loader.view()
3762
+ t.true(view instanceof Int32Array)
3763
+ t.is(view!.buffer, loader.memory.buffer)
3764
+ t.is(view!.byteOffset, 1024)
3765
+ t.is(view!.length, 1)
3766
+ loader.createWorker('/w.mjs')
3767
+ t.is(loader.created[1].options.workerData.addonCrashFlag, view)
3768
+ t.is(
3769
+ loader.created[1].options.workerData.crashFlag instanceof Int32Array,
3770
+ true,
3771
+ )
3772
+ // The worker's store lands in the word the addon reads.
3773
+ Atomics.store(view!, 0, 1)
3774
+ t.is(Atomics.load(new Int32Array(loader.memory.buffer), 256), 1)
3775
+
3776
+ // Addresses the loader cannot trust are ignored.
3777
+ for (const exports of [
3778
+ {},
3779
+ { napi_wasm_thread_crash_flag_address: 1024 },
3780
+ { napi_wasm_thread_crash_flag_address: () => 0 },
3781
+ { napi_wasm_thread_crash_flag_address: () => 1026 },
3782
+ { napi_wasm_thread_crash_flag_address: () => 65536 },
3783
+ { napi_wasm_thread_crash_flag_address: () => -4 },
3784
+ {
3785
+ napi_wasm_thread_crash_flag_address() {
3786
+ throw new Error('boom')
3787
+ },
3788
+ },
3789
+ ]) {
3790
+ const fresh = createNodeLoaderAddonCrashFlag()
3791
+ t.notThrows(() => fresh.capture({ exports }))
3792
+ t.is(fresh.view(), undefined)
3793
+ }
3794
+ })
3795
+
3796
+ // The browser pool is created before the wasm is instantiated, so its workers
3797
+ // get the view by message once the instance exists; a worker created later
3798
+ // gets it right away. A browser Worker has no workerData.
3799
+ test('browser WASI loader posts the addon crash flag view to every pool worker', (t) => {
3800
+ const code = createWasiBrowserBinding(
3801
+ 'test-wasi',
3802
+ 4000,
3803
+ 65536,
3804
+ false,
3805
+ false,
3806
+ false,
3807
+ false,
3808
+ true,
3809
+ )
3810
+ assertValidJS(t, code, 'browser threads')
3811
+ const beforeInitStart = code.indexOf(
3812
+ ' beforeInit({ instance }) {\n __napiInstance = instance\n',
3813
+ )
3814
+ t.true(beforeInitStart > 0)
3815
+ const beforeInitBody = code.slice(
3816
+ code.indexOf(' __napiInstance = instance\n', beforeInitStart),
3817
+ code.indexOf(
3818
+ ' for (const name of Object.keys(instance.exports)) {',
3819
+ beforeInitStart,
3820
+ ),
3821
+ )
3822
+ t.true(beforeInitBody.includes('__captureWasiAddonCrashFlag(instance)'))
3823
+ t.true(
3824
+ code.includes(
3825
+ ' __wasiWorkers.add(worker)\n __shareWasiAddonCrashFlag(worker)\n',
3826
+ ),
3827
+ )
3828
+
3829
+ const posted: Array<[string, unknown]> = []
3830
+ const run = new Function(
3831
+ 'posted',
3832
+ `
3833
+ const __sharedMemory = new WebAssembly.Memory({ initial: 1, maximum: 1, shared: true })
3834
+ const __wasiWorkers = new Set()
3835
+ let __napiInstance
3836
+ ${generatedFunction(code, '__captureWasiAddonCrashFlag')}
3837
+ ${generatedFunction(code, '__shareWasiAddonCrashFlag')}
3838
+ let __wasiAddonCrashFlag
3839
+ function onCreateWorker(name) {
3840
+ const worker = {
3841
+ postMessage(message) {
3842
+ posted.push([name, message])
3843
+ },
3844
+ }
3845
+ __wasiWorkers.add(worker)
3846
+ __shareWasiAddonCrashFlag(worker)
3847
+ return worker
3848
+ }
3849
+ function beforeInit({ instance }) {
3850
+ ${beforeInitBody}
3851
+ }
3852
+ onCreateWorker('pool-1')
3853
+ onCreateWorker('pool-2')
3854
+ beforeInit({ instance: { exports: { napi_wasm_thread_crash_flag_address: () => 512 } } })
3855
+ onCreateWorker('later')
3856
+ return { memory: __sharedMemory, view: __wasiAddonCrashFlag }
3857
+ `,
3858
+ )
3859
+ const { memory, view } = run(posted)
3860
+ t.true(view instanceof Int32Array)
3861
+ t.is(view.buffer, memory.buffer)
3862
+ t.is(view.byteOffset, 512)
3863
+ t.deepEqual(
3864
+ posted.map(([name]) => name),
3865
+ ['pool-1', 'pool-2', 'later'],
3866
+ )
3867
+ for (const [, message] of posted) {
3868
+ t.deepEqual(Object.keys(message as object), ['__napiRsAddonCrashFlag'])
3869
+ t.is(
3870
+ (message as { __napiRsAddonCrashFlag: unknown }).__napiRsAddonCrashFlag,
3871
+ view,
3872
+ )
3873
+ }
3874
+
3875
+ // The threadless browser loader has no workers.
3876
+ const threadless = createWasiBrowserBinding(
3877
+ 'test-wasi',
3878
+ 4000,
3879
+ 65536,
3880
+ false,
3881
+ false,
3882
+ false,
3883
+ false,
3884
+ false,
3885
+ )
3886
+ t.false(threadless.includes('__captureWasiAddonCrashFlag'))
3887
+ t.false(threadless.includes('__shareWasiAddonCrashFlag'))
3888
+ })
3889
+
3890
+ test('browser WASI worker raises the addon crash flag it was posted', (t) => {
3891
+ for (const [fs, errorEvent] of [
3892
+ [false, false],
3893
+ [true, true],
3894
+ ]) {
3895
+ const code = createWasiBrowserWorkerBinding(fs, errorEvent)
3896
+ const start = code.indexOf('let __addonCrashFlag\n')
3897
+ t.true(start > 0)
3898
+ const bridge = code.slice(start)
3899
+ const calls: string[] = []
3900
+ const { memory, word } = addonCrashFlagView(256)
3901
+ const handler: {
3902
+ instance?: unknown
3903
+ beforeReportError: (...args: unknown[]) => unknown
3904
+ handle: (event: { data: unknown }) => void
3905
+ } = {
3906
+ instance: undefined,
3907
+ beforeReportError(this: unknown, error: unknown) {
3908
+ calls.push(
3909
+ `emnapi ${(error as Error).message} word=${Atomics.load(word, 0)} ${this === handler}`,
3910
+ )
3911
+ },
3912
+ handle(event) {
3913
+ calls.push(`handle ${JSON.stringify(event.data)}`)
3914
+ },
3915
+ }
3916
+ const globals: { onmessage?: (event: { data: unknown }) => void } = {}
3917
+ new Function('handler', 'globalThis', bridge)(handler, globals)
3918
+
3919
+ // No view yet: emnapi's hook still runs, nothing is raised.
3920
+ handler.beforeReportError(new Error('early'), 'load')
3921
+ // The view is kept, not handed to emnapi; emnapi's messages still are.
3922
+ globals.onmessage!({ data: { __napiRsAddonCrashFlag: word } })
3923
+ globals.onmessage!({ data: { __emnapi__: { type: 'load' } } })
3924
+ // A wasm thread that died, with or without an instance.
3925
+ handler.beforeReportError(new Error('trap'), 'start')
3926
+ t.deepEqual(calls, [
3927
+ 'emnapi early word=0 true',
3928
+ 'handle {"__emnapi__":{"type":"load"}}',
3929
+ 'emnapi trap word=1 true',
3930
+ ])
3931
+ t.is(Atomics.load(new Int32Array(memory.buffer), 64), 1)
3932
+ }
3933
+ })
3934
+
3935
+ /**
3936
+ * Wires the worker's crash hook to the loader's crash error over one shared
3937
+ * report, the way `__createWasiWorker` passes it, and returns both ends.
3938
+ */
3939
+ function createWasiCrashReportPair() {
3940
+ const code = createWasiBinding('test', '@scope/test')
3941
+ const latchStart = code.indexOf('const __wasiThreadCrashFlag = ')
3942
+ const latchState = code.slice(
3943
+ latchStart,
3944
+ code.indexOf('let __wasiThreadCrashed = false\n', latchStart) +
3945
+ 'let __wasiThreadCrashed = false\n'.length,
3946
+ )
3947
+ const loader = new Function(`
3948
+ ${latchState}
3949
+ function __getWasiThreadManager() {
3950
+ return {}
3951
+ }
3952
+ ${generatedFunction(code, '__readWasiThreadCrashReport')}
3953
+ ${generatedFunction(code, '__recordWasiThreadCrashError')}
3954
+ ${generatedFunction(code, '__getWasiThreadCrashError')}
3955
+ ${generatedFunction(code, '__fillWasiThreadCrashError')}
3956
+ return {
3957
+ workerData: {
3958
+ crashFlag: __wasiThreadCrashFlag,
3959
+ crashReport: __wasiThreadCrashReport,
3960
+ },
3961
+ crashError: __getWasiThreadCrashError,
3962
+ workerError: __recordWasiThreadCrashError,
3963
+ }
3964
+ `)() as {
3965
+ workerData: { crashFlag: Int32Array; crashReport: SharedArrayBuffer }
3966
+ crashError: () => any
3967
+ workerError: (error: unknown, threadId: number) => void
3968
+ }
3969
+ const hook = wasiWorkerCrashHook()
3970
+ const reportStates: number[] = []
3971
+ const crashWorker = (error: unknown, threadId: number) => {
3972
+ const handler = {
3973
+ beforeReportError() {
3974
+ reportStates.push(
3975
+ Atomics.load(new Int32Array(loader.workerData.crashReport, 0, 1), 0),
3976
+ )
3977
+ },
3978
+ }
3979
+ new Function('workerData', 'handler', 'threadId', hook)(
3980
+ loader.workerData,
3981
+ handler,
3982
+ threadId,
3983
+ )
3984
+ ;(handler.beforeReportError as (...args: unknown[]) => void)(error, 'start')
3985
+ }
3986
+ return { ...loader, crashWorker, reportStates }
3987
+ }
3988
+
3989
+ // The 'error' event is dropped when the loader terminates the worker first, so
3990
+ // the crash error lost its cause in most crash runs. The worker now writes its
3991
+ // error into shared memory before it raises the flag, and the loader rebuilds
3992
+ // it from there without waiting for any event.
3993
+ test('WASI worker hands its error to the loader through the shared crash report', (t) => {
3994
+ const pair = createWasiCrashReportPair()
3995
+ const trap = new WebAssembly.RuntimeError('forced trap')
3996
+ pair.crashWorker(trap, 5)
3997
+ // Complete before emnapi's hook runs, and so before the flag is seen.
3998
+ t.deepEqual(pair.reportStates, [2])
3999
+ t.is(Atomics.load(pair.workerData.crashFlag, 0), 1)
4000
+ // A second crash does not overwrite the first report.
4001
+ pair.crashWorker(new Error('second worker'), 6)
4002
+ const crashError = pair.crashError()
4003
+ t.is(crashError.message, CRASH_DISPOSAL_MESSAGE)
4004
+ t.true(crashError.cause instanceof Error)
4005
+ t.is(crashError.cause.name, 'RuntimeError')
4006
+ t.is(crashError.cause.message, 'forced trap')
4007
+ t.is(crashError.cause.stack, trap.stack)
4008
+ t.is(crashError.workerThreadId, 5)
4009
+ // One shared error: a later event does not replace the cause.
4010
+ pair.workerError(new Error('late event'), 7)
4011
+ t.is(pair.crashError(), crashError)
4012
+ t.is(crashError.cause.message, 'forced trap')
4013
+
4014
+ // The listener's error, when it came first, is the original object.
4015
+ const withEvent = createWasiCrashReportPair()
4016
+ withEvent.crashWorker(trap, 5)
4017
+ const eventError = new Error('from the error event')
4018
+ withEvent.workerError(eventError, 5)
4019
+ t.is(withEvent.crashError().cause, eventError)
4020
+
4021
+ // A value that is not an error, and a message too large for the report.
4022
+ const thrownString = createWasiCrashReportPair()
4023
+ thrownString.crashWorker('boom', 3)
4024
+ t.is(thrownString.crashError().cause.message, 'boom')
4025
+ const huge = createWasiCrashReportPair()
4026
+ huge.crashWorker(new Error('x'.repeat(10_000)), 4)
4027
+ const hugeCause = huge.crashError().cause
4028
+ t.true(hugeCause.message.length > 0)
4029
+ t.true(hugeCause.message.length < 10_000)
4030
+ t.is(huge.crashError().workerThreadId, 4)
4031
+ })