@napi-rs/cli 3.10.5 → 3.10.7

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}`)
@@ -1429,17 +1450,25 @@ const NESTED_DESTROY_NO_OP = ` if (isPreparingEnvCleanup?.()) {
1429
1450
  }
1430
1451
  prepareEnvCleanup?.()`
1431
1452
 
1453
+ // The threaded node loader hands emnapi a gated `setImmediate`, see
1454
+ // `__wasiSetImmediate`; every other loader creates the context as before.
1455
+ const PLAIN_CONTEXT_OPTIONS = '{ autoDestroy: false }'
1456
+ const THREADED_NODE_CONTEXT_OPTIONS =
1457
+ '{ autoDestroy: false, features: { setImmediate: __wasiSetImmediate } }'
1458
+
1432
1459
  const wrappedContextCreationCases: Array<{
1433
1460
  name: string
1434
1461
  code: string
1435
1462
  prepare: string
1436
1463
  guard: (typeof preparingBarrierGuards)[keyof typeof preparingBarrierGuards]
1464
+ contextOptions?: string
1437
1465
  }> = [
1438
1466
  {
1439
1467
  name: 'node cjs',
1440
1468
  code: createWasiBinding('test', '@scope/test'),
1441
1469
  prepare: '__prepareWasmEnvCleanup',
1442
1470
  guard: preparingBarrierGuards.shared,
1471
+ contextOptions: THREADED_NODE_CONTEXT_OPTIONS,
1443
1472
  },
1444
1473
  {
1445
1474
  name: 'node cjs threadless',
@@ -1461,7 +1490,13 @@ const wrappedContextCreationCases: Array<{
1461
1490
  },
1462
1491
  ]
1463
1492
 
1464
- for (const { name, code, prepare, guard } of wrappedContextCreationCases) {
1493
+ for (const {
1494
+ name,
1495
+ code,
1496
+ prepare,
1497
+ guard,
1498
+ contextOptions = PLAIN_CONTEXT_OPTIONS,
1499
+ } of wrappedContextCreationCases) {
1465
1500
  test(`WASI loader runs the barrier on a raw context.destroy(): ${name}`, (t) => {
1466
1501
  assertValidJS(t, code, name)
1467
1502
  t.is(
@@ -1472,16 +1507,17 @@ for (const { name, code, prepare, guard } of wrappedContextCreationCases) {
1472
1507
  // No unwrapped context may escape: there is exactly one createContext call
1473
1508
  // and it is the wrapper's argument.
1474
1509
  t.is(
1475
- code.split('__emnapiCreateContext({ autoDestroy: false })').length - 1,
1510
+ code.split('__emnapiCreateContext(').length - 1,
1476
1511
  1,
1477
1512
  'loader must create exactly one emnapi context',
1478
1513
  )
1514
+ t.true(code.includes(`__emnapiCreateContext(${contextOptions})`))
1479
1515
  t.true(
1480
1516
  code
1481
1517
  .replace(/\s+/g, ' ')
1482
1518
  .includes(
1483
1519
  '__emnapiContext = __wrapEmnapiContextDestroyForSettlement( ' +
1484
- `__emnapiCreateContext({ autoDestroy: false }), ${prepare}, ${guard.probe}, )`,
1520
+ `__emnapiCreateContext(${contextOptions}), ${prepare}, ${guard.probe}, )`,
1485
1521
  ),
1486
1522
  'the createContext result must be wrapped before anything can reach it',
1487
1523
  )
@@ -2107,3 +2143,2576 @@ test('createEsmBinding builds native addon loading on portable ESM primitives',
2107
2143
  'ESM loader must not treat URL.pathname as a filesystem path',
2108
2144
  )
2109
2145
  })
2146
+
2147
+ /**
2148
+ * A top-level `function <name>(` of generated code, through its closing brace
2149
+ * at column zero.
2150
+ */
2151
+ function generatedFunction(code: string, name: string): string {
2152
+ const start = code.indexOf(`function ${name}(`)
2153
+ if (start === -1) {
2154
+ return ''
2155
+ }
2156
+ return code.slice(start, code.indexOf('\n}\n', start) + 2)
2157
+ }
2158
+
2159
+ /**
2160
+ * Runs the node loader's 'exit' listener against stubbed teardown steps, with
2161
+ * the crash latch in the given state, and returns the steps it took.
2162
+ */
2163
+ function runWasiExitListener(
2164
+ code: string,
2165
+ crash: { flag?: boolean; errorEvent?: boolean; fatalError?: boolean },
2166
+ ): string[] {
2167
+ const latchStart = code.indexOf('const __wasiThreadCrashFlag = ')
2168
+ const latchState = code.slice(
2169
+ latchStart,
2170
+ code.indexOf('let __wasiThreadCrashed = false\n', latchStart) +
2171
+ 'let __wasiThreadCrashed = false\n'.length,
2172
+ )
2173
+ const steps: string[] = []
2174
+ const run = new Function(
2175
+ 'steps',
2176
+ 'crash',
2177
+ `
2178
+ let __wasiExitListenerRegistered = true
2179
+ ${latchState}
2180
+ function __getWasiThreadManager() {
2181
+ return {
2182
+ terminateWorker() {},
2183
+ _fatalError: crash.fatalError ? new Error('worker died') : undefined,
2184
+ }
2185
+ }
2186
+ function __isThenable(value) {
2187
+ return value !== null && typeof value === 'object' && typeof value.then === 'function'
2188
+ }
2189
+ function __destroyEmnapiContext() {
2190
+ steps.push('destroy')
2191
+ }
2192
+ function __terminateWasiWorkers() {
2193
+ steps.push('terminate')
2194
+ }
2195
+ // Only an asyncRuntime loader calls it.
2196
+ function __releaseCurrentThreadHostTimers() {
2197
+ steps.push('release host timers')
2198
+ }
2199
+ ${generatedFunction(code, '__hasWasiThreadCrashed')}
2200
+ ${generatedFunction(code, '__disposeWasiBindingAtExit')}
2201
+ if (crash.flag) Atomics.store(__wasiThreadCrashFlag, 0, 1)
2202
+ if (crash.errorEvent) __wasiThreadCrashed = true
2203
+ __disposeWasiBindingAtExit()
2204
+ if (__wasiExitListenerRegistered) steps.push('still registered')
2205
+ `,
2206
+ )
2207
+ run(steps, crash)
2208
+ return steps
2209
+ }
2210
+
2211
+ // A wasm thread that died can leave the shared state half-updated: a lock it
2212
+ // held stays held, and the async runtime's shutdown waits for its work to go
2213
+ // idle. The 'exit' teardown re-enters wasm on the JavaScript thread and blocks
2214
+ // there in a raw atomic wait that no signal can interrupt, so the process hangs
2215
+ // for good. After a crash the listener must only stop the workers.
2216
+ test('node WASI loader skips the wasm teardown at exit after a thread crash', (t) => {
2217
+ const code = createWasiBinding('test', '@scope/test')
2218
+ assertValidJS(t, code, 'node cjs')
2219
+ t.true(code.includes('function __hasWasiThreadCrashed() {'))
2220
+
2221
+ // No crash: the teardown is exactly what it was.
2222
+ t.deepEqual(runWasiExitListener(code, {}), ['destroy', 'terminate'])
2223
+
2224
+ // Each of the three signals is enough on its own: the flag the worker sets
2225
+ // before it reports, the worker's 'error' event, and emnapi's fatal error.
2226
+ for (const crash of [
2227
+ { flag: true },
2228
+ { errorEvent: true },
2229
+ { fatalError: true },
2230
+ ]) {
2231
+ t.deepEqual(
2232
+ runWasiExitListener(code, crash),
2233
+ ['terminate'],
2234
+ JSON.stringify(crash),
2235
+ )
2236
+ }
2237
+ })
2238
+
2239
+ /**
2240
+ * Runs the node loader's public disposer, and then its 'exit' listener, against
2241
+ * stubbed wasm steps with the crash latch in the given state. Every stub
2242
+ * records its name, so the steps show exactly what re-entered wasm.
2243
+ *
2244
+ * `__emnapiContext` is a stub shaped like emnapi's Node `Context`: its
2245
+ * `refCounter.refHandle` records 'port ref' / 'port unref', and every other
2246
+ * member records its name, so a step that touched the context shows up too.
2247
+ * `context` picks a context without a counter, one whose counter getter
2248
+ * throws, or no context at all.
2249
+ */
2250
+ async function runWasiPublicDisposer(
2251
+ code: string,
2252
+ crash: {
2253
+ flag?: boolean
2254
+ errorEvent?: boolean
2255
+ fatalError?: boolean
2256
+ terminateThrows?: boolean
2257
+ context?: 'no counter' | 'throwing counter' | 'none'
2258
+ },
2259
+ calls = 1,
2260
+ ): Promise<{
2261
+ steps: string[]
2262
+ results: Array<{ value?: unknown; error?: any }>
2263
+ samePromise: boolean
2264
+ exitError?: unknown
2265
+ count?: number
2266
+ }> {
2267
+ const latchStart = code.indexOf('const __wasiThreadCrashFlag = ')
2268
+ const latchState = code.slice(
2269
+ latchStart,
2270
+ code.indexOf('let __wasiThreadCrashed = false\n', latchStart) +
2271
+ 'let __wasiThreadCrashed = false\n'.length,
2272
+ )
2273
+ const steps: string[] = []
2274
+ const fatalError = new Error('worker died')
2275
+ // Two requests still in flight on the dead thread.
2276
+ const refCounter = {
2277
+ count: 2,
2278
+ refHandle: {
2279
+ ref() {
2280
+ steps.push('port ref')
2281
+ },
2282
+ unref() {
2283
+ steps.push('port unref')
2284
+ },
2285
+ },
2286
+ }
2287
+ let context: object | undefined
2288
+ if (crash.context === 'no counter') {
2289
+ context = {}
2290
+ } else if (crash.context === 'throwing counter') {
2291
+ context = {
2292
+ get refCounter() {
2293
+ throw new Error('private field')
2294
+ },
2295
+ }
2296
+ } else if (crash.context !== 'none') {
2297
+ context = { refCounter }
2298
+ }
2299
+ if (context) {
2300
+ // Anything but the counter port touching the context is a wasm re-entry
2301
+ // (destroy runs the cleanup hooks) or a JS state change this path must not
2302
+ // make; record it.
2303
+ context = new Proxy(context, {
2304
+ get(target, key, receiver) {
2305
+ if (key !== 'refCounter') {
2306
+ steps.push(`context.${String(key)}`)
2307
+ }
2308
+ return Reflect.get(target, key, receiver)
2309
+ },
2310
+ })
2311
+ }
2312
+ const run = new Function(
2313
+ 'steps',
2314
+ 'crash',
2315
+ 'fatalError',
2316
+ 'calls',
2317
+ '__emnapiContext',
2318
+ `
2319
+ let __wasiExitListenerRegistered = true
2320
+ let __wasiDisposed = false
2321
+ let __wasiDisposePromise
2322
+ let __wasiThreadCrashDisposePromise
2323
+ let __wasiReentryClosed = false
2324
+ // The loader sets this to __removeWasiExitListener.
2325
+ let __completeWasiDisposal = function () {
2326
+ steps.push('complete')
2327
+ __wasiExitListenerRegistered = false
2328
+ }
2329
+ ${latchState}
2330
+ function __getWasiThreadManager() {
2331
+ return {
2332
+ terminateWorker() {},
2333
+ _fatalError: crash.fatalError ? fatalError : undefined,
2334
+ }
2335
+ }
2336
+ function __isThenable(value) {
2337
+ return value !== null && typeof value === 'object' && typeof value.then === 'function'
2338
+ }
2339
+ function __drainWasiAsyncWork() {
2340
+ steps.push('async work')
2341
+ }
2342
+ function __prepareWasmEnvCleanupWithTurns() {
2343
+ steps.push('prepare')
2344
+ }
2345
+ function __drainWasmEnvCleanup() {
2346
+ steps.push('drain')
2347
+ }
2348
+ function __destroyEmnapiContext() {
2349
+ steps.push('destroy')
2350
+ }
2351
+ function __terminateWasiWorkers() {
2352
+ steps.push('terminate')
2353
+ if (crash.terminateThrows) throw new Error('terminate failed')
2354
+ return Promise.resolve()
2355
+ }
2356
+ // Only an asyncRuntime loader calls it.
2357
+ function __releaseCurrentThreadHostTimers() {
2358
+ steps.push('release host timers')
2359
+ }
2360
+ ${generatedFunction(code, '__createCleanupError')}
2361
+ ${generatedFunction(code, '__attachCleanupErrors')}
2362
+ ${generatedFunction(code, '__hasWasiThreadCrashed')}
2363
+ ${generatedFunction(code, '__readWasiThreadCrashReport')}
2364
+ ${generatedFunction(code, '__recordWasiThreadCrashError')}
2365
+ ${generatedFunction(code, '__getWasiThreadCrashError')}
2366
+ ${generatedFunction(code, '__fillWasiThreadCrashError')}
2367
+ ${generatedFunction(code, '__releaseEmnapiWaitingRequestHandle')}
2368
+ ${generatedFunction(code, '__disposeWasiBindingAfterThreadCrash')}
2369
+ ${generatedFunction(code, '__abortWasiDisposalIfThreadCrashed')}
2370
+ ${generatedFunction(code, '__settleWasiDisposalAfterThreadCrash')}
2371
+ ${generatedFunction(code, '__finishWasiDisposal')}
2372
+ ${generatedFunction(code, '__continueWasiDisposal')}
2373
+ ${generatedFunction(code, '__drainWasmEnvForWasiDisposal')}
2374
+ ${generatedFunction(code, '__cleanUpWasmEnvForWasiDisposal')}
2375
+ ${generatedFunction(code, '__startWasiDisposal')}
2376
+ ${generatedFunction(code, '__disposeWasiBinding')}
2377
+ ${generatedFunction(code, '__disposeWasiBindingAtExit')}
2378
+ if (crash.flag) Atomics.store(__wasiThreadCrashFlag, 0, 1)
2379
+ if (crash.errorEvent) __wasiThreadCrashed = true
2380
+ return (async () => {
2381
+ const promises = []
2382
+ for (let i = 0; i < calls; i += 1) {
2383
+ promises.push(__disposeWasiBinding())
2384
+ }
2385
+ const results = []
2386
+ for (const promise of promises) {
2387
+ try {
2388
+ results.push({ value: await promise })
2389
+ } catch (error) {
2390
+ results.push({ error })
2391
+ }
2392
+ }
2393
+ let exitError
2394
+ try {
2395
+ if (__wasiExitListenerRegistered) __disposeWasiBindingAtExit()
2396
+ } catch (error) {
2397
+ exitError = error
2398
+ }
2399
+ return {
2400
+ results,
2401
+ samePromise: promises.every((promise) => promise === promises[0]),
2402
+ exitError,
2403
+ }
2404
+ })()
2405
+ `,
2406
+ )
2407
+ const outcome = await run(steps, crash, fatalError, calls, context)
2408
+ return { steps, ...outcome, count: refCounter.count }
2409
+ }
2410
+
2411
+ // The published disposer runs the same wasm teardown as the 'exit' listener,
2412
+ // behind an async-work drain. An app that handled the rethrown worker error
2413
+ // and then disposed blocked in the same raw atomic wait. After a crash it only
2414
+ // stops the workers and rejects, since nothing was cleaned up.
2415
+ test('node WASI public disposer skips the wasm teardown after a thread crash', async (t) => {
2416
+ const code = createWasiBinding('test', '@scope/test')
2417
+ assertValidJS(t, code, 'node cjs')
2418
+ t.true(code.includes('let __wasiThreadCrashDisposePromise\n'))
2419
+ t.true(
2420
+ generatedFunction(code, '__disposeWasiBinding').includes(
2421
+ ' if (!__wasiDisposed && __hasWasiThreadCrashed()) {\n return __disposeWasiBindingAfterThreadCrash()\n }\n',
2422
+ ),
2423
+ )
2424
+
2425
+ // No crash: the disposal chain is exactly what it was, and it removes the
2426
+ // 'exit' listener when it completes.
2427
+ const clean = await runWasiPublicDisposer(code, {})
2428
+ t.deepEqual(clean.steps, [
2429
+ 'async work',
2430
+ 'prepare',
2431
+ 'drain',
2432
+ 'destroy',
2433
+ 'terminate',
2434
+ 'complete',
2435
+ ])
2436
+ t.deepEqual(clean.results, [{ value: undefined }])
2437
+
2438
+ for (const crash of [
2439
+ { flag: true },
2440
+ { errorEvent: true },
2441
+ { fatalError: true },
2442
+ ]) {
2443
+ const label = JSON.stringify(crash)
2444
+ // Three calls: every one settles the same way, and the workers are
2445
+ // terminated once by the disposer and once more by the 'exit' listener,
2446
+ // which still takes its short path and does not throw.
2447
+ const crashed = await runWasiPublicDisposer(code, crash, 3)
2448
+ t.deepEqual(crashed.steps, ['port unref', 'terminate', 'terminate'], label)
2449
+ t.true(crashed.samePromise, label)
2450
+ t.is(crashed.exitError, undefined, label)
2451
+ for (const result of crashed.results) {
2452
+ t.true(result.error instanceof Error, label)
2453
+ t.is(
2454
+ result.error.message,
2455
+ 'napi-rs: WASI binding cannot be disposed after a worker thread crashed',
2456
+ label,
2457
+ )
2458
+ }
2459
+ if (crash.fatalError) {
2460
+ t.is(crashed.results[0].error.cause.message, 'worker died')
2461
+ }
2462
+ }
2463
+
2464
+ // A termination failure is attached, not swallowed, and the disposer still
2465
+ // does not fall through to the wasm teardown.
2466
+ const failed = await runWasiPublicDisposer(code, {
2467
+ flag: true,
2468
+ terminateThrows: true,
2469
+ })
2470
+ t.deepEqual(failed.steps, ['port unref', 'terminate', 'terminate'])
2471
+ t.regex(failed.results[0].error.message, /worker thread crashed/)
2472
+ t.is(failed.results[0].error.cause.message, 'terminate failed')
2473
+ t.is(failed.exitError, undefined)
2474
+ })
2475
+
2476
+ // emnapi's Node Context refs a MessagePort (refCounter.refHandle) while async
2477
+ // work or threadsafe-function requests are in flight. The dead thread's requests
2478
+ // never finish, so after the crash disposal rejected nothing else was left
2479
+ // running and the port alone kept the process from exiting. The crash path
2480
+ // unrefs it without touching anything else on the context; the no-crash path
2481
+ // never touches it.
2482
+ test('node WASI crash disposal releases the waiting-request port', async (t) => {
2483
+ const code = createWasiBinding('test', '@scope/test')
2484
+ const release = generatedFunction(code, '__releaseEmnapiWaitingRequestHandle')
2485
+ t.true(release.length > 0)
2486
+ t.true(
2487
+ generatedFunction(code, '__disposeWasiBindingAfterThreadCrash').includes(
2488
+ ' __releaseEmnapiWaitingRequestHandle()\n // No call into wasm after this: see `__wasiSetImmediate`.\n __wasiReentryClosed = true\n let workerResult\n',
2489
+ ),
2490
+ 'released before the workers are terminated',
2491
+ )
2492
+ // It must not destroy the context: that runs the cleanup hooks in wasm.
2493
+ t.false(release.includes('destroy'))
2494
+
2495
+ // No crash: the port is left to emnapi's own count.
2496
+ const clean = await runWasiPublicDisposer(code, {})
2497
+ t.false(clean.steps.includes('port unref'))
2498
+ t.false(clean.steps.includes('port ref'))
2499
+ t.is(clean.count, 2)
2500
+
2501
+ // Crash: unrefed exactly once across three disposer calls and the 'exit'
2502
+ // listener, never re-refed, and the count is left above zero so a later
2503
+ // request cannot ref the port again. No other context member is read.
2504
+ const crashed = await runWasiPublicDisposer(code, { flag: true }, 3)
2505
+ t.deepEqual(
2506
+ crashed.steps.filter((step) => step.startsWith('port ')),
2507
+ ['port unref'],
2508
+ )
2509
+ t.false(crashed.steps.some((step) => step.startsWith('context.')))
2510
+ t.is(crashed.count, 2)
2511
+
2512
+ // A context without the counter (a non-Node host, a future emnapi), one
2513
+ // whose field cannot be read, or no context at all: the disposer still
2514
+ // rejects with the crash error and terminates the workers.
2515
+ for (const context of ['no counter', 'throwing counter', 'none'] as const) {
2516
+ const outcome = await runWasiPublicDisposer(code, { flag: true, context })
2517
+ t.deepEqual(outcome.steps, ['terminate', 'terminate'], context)
2518
+ t.regex(outcome.results[0].error.message, /worker thread crashed/, context)
2519
+ t.is(outcome.exitError, undefined, context)
2520
+ }
2521
+ })
2522
+
2523
+ // A CurrentThread sleep arms a referenced host timeout through the
2524
+ // `@napi-rs/async-runtime` timer host. Only `__disposeCurrentThreadHosts`
2525
+ // released it, from `__destroyEmnapiContext`, which the crash paths never
2526
+ // reach: its unregister calls enter wasm. A dispose() that rejected after a
2527
+ // crash left the process alive until the longest sleep ended. The threaded
2528
+ // asyncRuntime loader now clears those timeouts itself on every crash path.
2529
+ const threadedAsyncRuntimeCode = createWasiBinding(
2530
+ 'test',
2531
+ '@scope/test',
2532
+ 4000,
2533
+ 65536,
2534
+ true,
2535
+ 'wasm32-wasi',
2536
+ 'test',
2537
+ true,
2538
+ )
2539
+
2540
+ test('node WASI crash paths release the CurrentThread host timers', async (t) => {
2541
+ const code = threadedAsyncRuntimeCode
2542
+ assertValidJS(t, code, 'node cjs + asyncRuntime')
2543
+ t.true(
2544
+ code.includes(
2545
+ '__currentThreadHostsDisposer = __installCurrentThreadHosts(\n __trackCurrentThreadHostTimers(__napiModule.exports),\n )\n',
2546
+ ),
2547
+ )
2548
+ t.true(
2549
+ generatedFunction(code, '__disposeWasiBindingAfterThreadCrash').includes(
2550
+ ' __releaseEmnapiWaitingRequestHandle()\n __releaseCurrentThreadHostTimers()\n // No call into wasm after this: see `__wasiSetImmediate`.\n __wasiReentryClosed = true\n let workerResult\n',
2551
+ ),
2552
+ 'released before the workers are terminated',
2553
+ )
2554
+ const exitListener = generatedFunction(code, '__disposeWasiBindingAtExit')
2555
+ const crashBranch = exitListener.slice(
2556
+ exitListener.indexOf('if (__hasWasiThreadCrashed()) {'),
2557
+ exitListener.indexOf(' return\n }\n'),
2558
+ )
2559
+ t.true(crashBranch.includes(' __releaseCurrentThreadHostTimers()\n'))
2560
+ // JavaScript only: nothing that unregisters, destroys or reaches the addon.
2561
+ const release = generatedFunction(code, '__releaseCurrentThreadHostTimers')
2562
+ t.true(release.length > 0)
2563
+ for (const forbidden of [
2564
+ 'unregister',
2565
+ '__disposeCurrentThreadHosts',
2566
+ '__emnapiContext',
2567
+ '__napiModule',
2568
+ ]) {
2569
+ t.false(release.includes(forbidden), forbidden)
2570
+ }
2571
+
2572
+ // The disposer releases once per crash disposal, and the 'exit' listener's
2573
+ // short path releases again (idempotent); without a crash neither does.
2574
+ const clean = await runWasiPublicDisposer(code, {})
2575
+ t.false(clean.steps.includes('release host timers'))
2576
+ const crashed = await runWasiPublicDisposer(code, { flag: true }, 3)
2577
+ t.deepEqual(crashed.steps, [
2578
+ 'port unref',
2579
+ 'release host timers',
2580
+ 'terminate',
2581
+ 'release host timers',
2582
+ 'terminate',
2583
+ ])
2584
+ t.deepEqual(runWasiExitListener(code, {}), ['destroy', 'terminate'])
2585
+ t.deepEqual(runWasiExitListener(code, { flag: true }), [
2586
+ 'release host timers',
2587
+ 'terminate',
2588
+ ])
2589
+
2590
+ // Only the threaded Node loader has the crash latch.
2591
+ for (const { name, code: other } of [
2592
+ { name: 'node cjs', code: createWasiBinding('test', '@scope/test') },
2593
+ ...asyncRuntimeLoaderCases.filter(({ name }) => name !== 'node cjs'),
2594
+ ]) {
2595
+ t.false(other.includes('__releaseCurrentThreadHostTimers'), name)
2596
+ t.false(other.includes('__trackCurrentThreadHostTimers'), name)
2597
+ }
2598
+ })
2599
+
2600
+ // emnapi defers its calls back into wasm (`_emnapi_set_immediate` for libuv
2601
+ // handle closes and threadsafe-function finalizers, threadsafe-function
2602
+ // dispatch, the finalizer queue) through the context's `features.setImmediate`.
2603
+ // One queued before the crash disposal ran after it, on the main thread, while a
2604
+ // terminated pool worker still held napi's heap-sync allocator lock: the free in
2605
+ // the finalizer spun on that lock forever. The threaded loader gates that
2606
+ // callback; the crash disposal closes the gate before it terminates the
2607
+ // workers.
2608
+ test('node WASI crash disposal drops emnapi deferred calls into wasm', async (t) => {
2609
+ for (const [label, code] of [
2610
+ ['node cjs', createWasiBinding('test', '@scope/test')],
2611
+ ['node cjs + asyncRuntime', threadedAsyncRuntimeCode],
2612
+ ] as const) {
2613
+ const latchStart = code.indexOf('const __wasiThreadCrashFlag = ')
2614
+ const latchState = code.slice(
2615
+ latchStart,
2616
+ code.indexOf('let __wasiThreadCrashed = false\n', latchStart) +
2617
+ 'let __wasiThreadCrashed = false\n'.length,
2618
+ )
2619
+ const gate = generatedFunction(code, '__wasiSetImmediate')
2620
+ t.true(gate.length > 0, label)
2621
+ const steps: string[] = []
2622
+ const queued: Array<() => void> = []
2623
+ // A stubbed host `setImmediate`: the test decides when each turn fires.
2624
+ const hostSetImmediate = (callback: () => void) => {
2625
+ queued.push(callback)
2626
+ return { handle: queued.length }
2627
+ }
2628
+ const binding = new Function(
2629
+ 'setImmediate',
2630
+ 'steps',
2631
+ `
2632
+ let __emnapiContext
2633
+ let __wasiThreadCrashDisposePromise
2634
+ let __wasiReentryClosed = false
2635
+ ${latchState}
2636
+ function __getWasiThreadManager() {
2637
+ return undefined
2638
+ }
2639
+ function __terminateWasiWorkers() {
2640
+ steps.push('terminate (gate closed: ' + __wasiReentryClosed + ')')
2641
+ return Promise.resolve()
2642
+ }
2643
+ function __releaseCurrentThreadHostTimers() {}
2644
+ ${generatedFunction(code, '__createCleanupError')}
2645
+ ${generatedFunction(code, '__attachCleanupErrors')}
2646
+ ${generatedFunction(code, '__hasWasiThreadCrashed')}
2647
+ ${generatedFunction(code, '__readWasiThreadCrashReport')}
2648
+ ${generatedFunction(code, '__recordWasiThreadCrashError')}
2649
+ ${generatedFunction(code, '__getWasiThreadCrashError')}
2650
+ ${generatedFunction(code, '__fillWasiThreadCrashError')}
2651
+ ${generatedFunction(code, '__releaseEmnapiWaitingRequestHandle')}
2652
+ ${generatedFunction(code, '__disposeWasiBindingAfterThreadCrash')}
2653
+ ${gate}
2654
+ return {
2655
+ setImmediate: __wasiSetImmediate,
2656
+ dispose: __disposeWasiBindingAfterThreadCrash,
2657
+ crash() {
2658
+ Atomics.store(__wasiThreadCrashFlag, 0, 1)
2659
+ },
2660
+ }
2661
+ `,
2662
+ )(hostSetImmediate, steps) as {
2663
+ setImmediate: (callback: () => void) => unknown
2664
+ dispose: () => Promise<unknown>
2665
+ crash: () => void
2666
+ }
2667
+
2668
+ // The host's handle is passed through to emnapi.
2669
+ t.deepEqual(
2670
+ binding.setImmediate(() => steps.push('call fired before disposal')),
2671
+ { handle: 1 },
2672
+ label,
2673
+ )
2674
+ binding.setImmediate(() => steps.push('call queued before disposal'))
2675
+ t.is(queued.length, 2, label)
2676
+ // The first turn fires before the crash disposal: it still reaches wasm.
2677
+ queued[0]()
2678
+
2679
+ binding.crash()
2680
+ const disposal = binding.dispose()
2681
+ // Queued after the disposal started: dropped as well.
2682
+ binding.setImmediate(() => steps.push('call queued after disposal'))
2683
+ // The second and third turns fire after the disposal: dropped.
2684
+ queued[1]()
2685
+ queued[2]()
2686
+
2687
+ const error = await disposal.then(
2688
+ () => undefined,
2689
+ (reason: unknown) => reason,
2690
+ )
2691
+ t.true(error instanceof Error, label)
2692
+ t.regex((error as Error).message, /worker thread crashed/, label)
2693
+ t.deepEqual(
2694
+ steps,
2695
+ ['call fired before disposal', 'terminate (gate closed: true)'],
2696
+ label,
2697
+ )
2698
+ }
2699
+
2700
+ // The threadless loaders have no pool workers to terminate, so no gate.
2701
+ for (const [label, code] of [
2702
+ [
2703
+ 'node cjs threadless',
2704
+ createWasiBinding('test', '@scope/test', 4000, 65536, false),
2705
+ ],
2706
+ [
2707
+ 'node cjs threadless + asyncRuntime',
2708
+ createWasiBinding(
2709
+ 'test',
2710
+ '@scope/test',
2711
+ 4000,
2712
+ 65536,
2713
+ false,
2714
+ 'wasm32-wasip1',
2715
+ 'test',
2716
+ true,
2717
+ ),
2718
+ ],
2719
+ ] as const) {
2720
+ t.false(code.includes('__wasiSetImmediate'), label)
2721
+ t.false(code.includes('__wasiReentryClosed'), label)
2722
+ t.true(
2723
+ code.includes('__emnapiCreateContext({ autoDestroy: false })'),
2724
+ label,
2725
+ )
2726
+ }
2727
+ })
2728
+
2729
+ test('node WASI crash release clears the host timeouts without settling them', async (t) => {
2730
+ const code = threadedAsyncRuntimeCode
2731
+ const stateStart = code.indexOf('const __currentThreadHostTimerCancels = ')
2732
+ const state = code.slice(
2733
+ stateStart,
2734
+ code.indexOf(
2735
+ 'let __currentThreadHostTimersReleased = false\n',
2736
+ stateStart,
2737
+ ) + 'let __currentThreadHostTimersReleased = false\n'.length,
2738
+ )
2739
+ t.true(stateStart > 0)
2740
+ const { track, release } = new Function(`
2741
+ ${state}
2742
+ ${generatedFunction(code, '__isThenable')}
2743
+ ${generatedFunction(code, '__scheduleCurrentThreadHostTimer')}
2744
+ ${generatedFunction(code, '__trackCurrentThreadHostTimers')}
2745
+ ${generatedFunction(code, '__releaseCurrentThreadHostTimers')}
2746
+ return {
2747
+ track: __trackCurrentThreadHostTimers,
2748
+ release: __releaseCurrentThreadHostTimers,
2749
+ }
2750
+ `)()
2751
+
2752
+ // A binding the package would reject is passed through untouched.
2753
+ const incomplete = { other: 1 }
2754
+ t.is(track(incomplete), incomplete)
2755
+ const throwing = {
2756
+ get registerTimerHost() {
2757
+ throw new Error('accessor')
2758
+ },
2759
+ }
2760
+ t.is(track(throwing), throwing)
2761
+
2762
+ // What the addon receives from `registerTimerHost`.
2763
+ let registered: any
2764
+ const binding = {
2765
+ reserveCurrentThreadHostRegistration() {},
2766
+ registerTimerHost(...args: any[]) {
2767
+ registered = args
2768
+ return 'registered'
2769
+ },
2770
+ }
2771
+ const view = track(binding)
2772
+ t.is(
2773
+ view.reserveCurrentThreadHostRegistration,
2774
+ binding.reserveCurrentThreadHostRegistration,
2775
+ )
2776
+
2777
+ // The package's timer host, armed timeouts by id. `cancel` clears one and
2778
+ // resolves its promise, as `installCurrentThreadHosts` does.
2779
+ const armed = new Map<
2780
+ number,
2781
+ { resolve: () => void; reject: (e: Error) => void }
2782
+ >()
2783
+ const log: string[] = []
2784
+ let failCancel = false
2785
+ const schedule = (id: number) =>
2786
+ new Promise<void>((resolve, reject) => {
2787
+ log.push(`arm ${id}`)
2788
+ armed.set(id, { resolve, reject })
2789
+ })
2790
+ const cancel = (id: number) => {
2791
+ const timer = armed.get(id)
2792
+ if (!timer) return
2793
+ armed.delete(id)
2794
+ log.push(`clear ${id}`)
2795
+ if (failCancel) {
2796
+ const error = new Error(`cancel ${id} failed`)
2797
+ timer.reject(error)
2798
+ throw error
2799
+ }
2800
+ timer.resolve()
2801
+ }
2802
+ t.is(view.registerTimerHost(1, 2, schedule, cancel), 'registered')
2803
+ t.is(registered.length, 4)
2804
+ t.deepEqual(registered.slice(0, 2), [1, 2])
2805
+ t.not(registered[2], schedule)
2806
+ t.is(registered[3], cancel, 'cancel is handed to the addon as is')
2807
+ const addonSchedule = registered[2]
2808
+
2809
+ // No crash: settlements pass through.
2810
+ const fired = addonSchedule(1, 10)
2811
+ armed.get(1)!.resolve()
2812
+ t.is(await fired, undefined)
2813
+ const failed = addonSchedule(2, 10)
2814
+ armed.get(2)!.reject(new Error('host failed'))
2815
+ await t.throwsAsync(failed, { message: 'host failed' })
2816
+ const cancelled = addonSchedule(3, 10)
2817
+ cancel(3)
2818
+ t.is(await cancelled, undefined)
2819
+
2820
+ // After a crash: every armed timeout is cleared, and the addon's promise
2821
+ // never settles, so nothing is handed back to wasm.
2822
+ const settled: number[] = []
2823
+ const long = addonSchedule(4, 60_000)
2824
+ long.then(
2825
+ () => settled.push(4),
2826
+ () => settled.push(4),
2827
+ )
2828
+ failCancel = true
2829
+ const failing = addonSchedule(5, 60_000)
2830
+ failing.then(
2831
+ () => settled.push(5),
2832
+ () => settled.push(5),
2833
+ )
2834
+ log.length = 0
2835
+ t.notThrows(() => release())
2836
+ t.deepEqual(log, ['clear 4', 'clear 5'])
2837
+ t.false(armed.has(4))
2838
+ t.false(armed.has(5))
2839
+ // Idempotent, and a sleep armed afterwards is never armed.
2840
+ release()
2841
+ const late = addonSchedule(6, 10)
2842
+ late.then(
2843
+ () => settled.push(6),
2844
+ () => settled.push(6),
2845
+ )
2846
+ t.deepEqual(log, ['clear 4', 'clear 5'])
2847
+ await new Promise((resolve) => setImmediate(resolve))
2848
+ t.deepEqual(settled, [])
2849
+ })
2850
+
2851
+ /**
2852
+ * Runs the node loader's real disposal chain — the async-work drain, its
2853
+ * keep-alive interval, the barrier's two-phase poll, the step boundaries and
2854
+ * the public disposer — against a stubbed addon on real timers, so a crash can
2855
+ * land while a poll is waiting. The stubs record every wasm entry and every
2856
+ * keep-alive interval, and the counters report how often each poll read.
2857
+ */
2858
+ function createInflightWasiDisposal(
2859
+ code: string,
2860
+ addon: {
2861
+ asyncWorkPending: number
2862
+ runtimeWorkPending: number
2863
+ onRuntimeWorkRead?: () => void
2864
+ },
2865
+ ) {
2866
+ const latchStart = code.indexOf('const __wasiThreadCrashFlag = ')
2867
+ const latchState = code.slice(
2868
+ latchStart,
2869
+ code.indexOf('let __wasiThreadCrashed = false\n', latchStart) +
2870
+ 'let __wasiThreadCrashed = false\n'.length,
2871
+ )
2872
+ const steps: string[] = []
2873
+ const reads = { asyncWork: 0, runtimeWork: 0 }
2874
+ const intervals = new Set<ReturnType<typeof setInterval>>()
2875
+ // Stands in for the loader's global: timers are real, keep-alive intervals
2876
+ // are recorded.
2877
+ const host = {
2878
+ setTimeout,
2879
+ clearTimeout,
2880
+ WebAssembly,
2881
+ setInterval(callback: () => void, delay: number) {
2882
+ const timer = setInterval(callback, delay)
2883
+ intervals.add(timer)
2884
+ steps.push('interval set')
2885
+ return timer
2886
+ },
2887
+ clearInterval(timer: ReturnType<typeof setInterval>) {
2888
+ clearInterval(timer)
2889
+ if (intervals.delete(timer)) {
2890
+ steps.push('interval cleared')
2891
+ }
2892
+ },
2893
+ }
2894
+ const exports = {
2895
+ napi_wasm_async_work_pending() {
2896
+ reads.asyncWork += 1
2897
+ return addon.asyncWorkPending
2898
+ },
2899
+ napi_wasm_cancel_pending_async_work() {
2900
+ steps.push('cancel')
2901
+ },
2902
+ napi_prepare_wasm_env_cleanup_begin() {
2903
+ steps.push('begin')
2904
+ return 1
2905
+ },
2906
+ napi_prepare_wasm_env_cleanup_finish() {
2907
+ steps.push('finish')
2908
+ },
2909
+ napi_wasm_runtime_work_pending() {
2910
+ reads.runtimeWork += 1
2911
+ addon.onRuntimeWorkRead?.()
2912
+ return addon.runtimeWorkPending
2913
+ },
2914
+ }
2915
+ const context = {
2916
+ refCounter: {
2917
+ refHandle: {
2918
+ ref() {
2919
+ steps.push('port ref')
2920
+ },
2921
+ unref() {
2922
+ steps.push('port unref')
2923
+ },
2924
+ },
2925
+ },
2926
+ }
2927
+ const loader = new Function(
2928
+ 'globalThis',
2929
+ 'steps',
2930
+ '__napiInstance',
2931
+ '__emnapiContext',
2932
+ `
2933
+ let __wasiDisposed = false
2934
+ let __wasiDisposePromise
2935
+ let __wasiThreadCrashDisposePromise
2936
+ let __wasiReentryClosed = false
2937
+ let __wasiAsyncWorkDrainPromise
2938
+ let __emnapiWasmEnvCleanupPrepared = false
2939
+ let __emnapiWasmEnvCleanupPreparing = false
2940
+ let __emnapiWasmEnvCleanupYielding = false
2941
+ let __emnapiWasmEnvCleanupRan = false
2942
+ let __finishParkedWasmEnvCleanup
2943
+ let __completeWasiDisposal = function () {
2944
+ steps.push('complete')
2945
+ }
2946
+ let __retainWasiRollbackForRetry = function () {
2947
+ steps.push('retain')
2948
+ }
2949
+ const __wasiRollbackRegistry = new Map()
2950
+ const __wasiRollbackRegistryKey = 'test.cjs'
2951
+ ${latchState}
2952
+ const __WASI_ASYNC_WORK_POLL_INTERVAL_MS = 1
2953
+ function __getWasiThreadManager() {
2954
+ return { terminateWorker() {} }
2955
+ }
2956
+ function __isThenable(value) {
2957
+ return value !== null && typeof value === 'object' && typeof value.then === 'function'
2958
+ }
2959
+ function __scheduleMacrotask(callback) {
2960
+ setImmediate(callback)
2961
+ }
2962
+ function __prepareWasmEnvCleanup() {
2963
+ steps.push('prepare (single call)')
2964
+ }
2965
+ function __createWasmRuntimePollPace() {
2966
+ return {}
2967
+ }
2968
+ function __yieldWasmRuntimePollTurn() {
2969
+ return new Promise((resolve) => globalThis.setTimeout(resolve, 1))
2970
+ }
2971
+ function __drainWasmEnvCleanup() {
2972
+ steps.push('drain')
2973
+ }
2974
+ function __destroyEmnapiContext() {
2975
+ steps.push('destroy')
2976
+ }
2977
+ function __terminateWasiWorkers() {
2978
+ steps.push('terminate')
2979
+ return Promise.resolve()
2980
+ }
2981
+ ${generatedFunction(code, '__createCleanupError')}
2982
+ ${generatedFunction(code, '__attachCleanupErrors')}
2983
+ ${generatedFunction(code, '__hasWasiThreadCrashed')}
2984
+ ${generatedFunction(code, '__readWasiThreadCrashReport')}
2985
+ ${generatedFunction(code, '__recordWasiThreadCrashError')}
2986
+ ${generatedFunction(code, '__getWasiThreadCrashError')}
2987
+ ${generatedFunction(code, '__fillWasiThreadCrashError')}
2988
+ ${generatedFunction(code, '__releaseEmnapiWaitingRequestHandle')}
2989
+ ${generatedFunction(code, '__disposeWasiBindingAfterThreadCrash')}
2990
+ ${generatedFunction(code, '__abortWasiDisposalIfThreadCrashed')}
2991
+ ${generatedFunction(code, '__settleWasiDisposalAfterThreadCrash')}
2992
+ ${generatedFunction(code, '__scheduleTimer')}
2993
+ ${generatedFunction(code, '__keepEventLoopAliveUntil')}
2994
+ ${generatedFunction(code, '__drainWasiAsyncWork')}
2995
+ ${generatedFunction(code, '__prepareWasmEnvCleanupWithTurns')}
2996
+ ${generatedFunction(code, '__finishWasiDisposal')}
2997
+ ${generatedFunction(code, '__continueWasiDisposal')}
2998
+ ${generatedFunction(code, '__drainWasmEnvForWasiDisposal')}
2999
+ ${generatedFunction(code, '__cleanUpWasmEnvForWasiDisposal')}
3000
+ ${generatedFunction(code, '__startWasiDisposal')}
3001
+ ${generatedFunction(code, '__disposeWasiBinding')}
3002
+ ${generatedFunction(code, '__finishWasiInitializationRollback')}
3003
+ ${generatedFunction(code, '__destroyContextForWasiRollback')}
3004
+ ${generatedFunction(code, '__retainFailedWasiRollback')}
3005
+ ${generatedFunction(code, '__runWasiInitializationRollbackSteps')}
3006
+ ${generatedFunction(code, '__rollbackWasiInitialization')}
3007
+ ${generatedFunction(code, '__rollbackWasiInitializationAfterThreadCrash')}
3008
+ ${generatedFunction(code, '__completeWasiInitializationRollback')}
3009
+ ${generatedFunction(code, '__runWasiInitializationRollback')}
3010
+ return {
3011
+ dispose: __disposeWasiBinding,
3012
+ drainAsyncWork: __drainWasiAsyncWork,
3013
+ rollback: __rollbackWasiInitialization,
3014
+ // What the loader's catch does when instantiation fails: start the rollback
3015
+ // and throw the initialization error itself.
3016
+ failInitialization(error) {
3017
+ const record = {
3018
+ active: false,
3019
+ error,
3020
+ promise: undefined,
3021
+ rollback: __rollbackWasiInitialization,
3022
+ }
3023
+ __wasiRollbackRegistry.set(__wasiRollbackRegistryKey, record)
3024
+ __runWasiInitializationRollback(record)
3025
+ return record
3026
+ },
3027
+ registered() {
3028
+ return __wasiRollbackRegistry.has(__wasiRollbackRegistryKey)
3029
+ },
3030
+ // The loader's own 'error' listener.
3031
+ workerError(error, threadId) {
3032
+ __wasiThreadCrashed = true
3033
+ __recordWasiThreadCrashError(error, threadId)
3034
+ },
3035
+ crash() {
3036
+ Atomics.store(__wasiThreadCrashFlag, 0, 1)
3037
+ },
3038
+ parked() {
3039
+ return __finishParkedWasmEnvCleanup !== undefined
3040
+ },
3041
+ }
3042
+ `,
3043
+ )(host, steps, { exports }, context)
3044
+ return {
3045
+ ...(loader as {
3046
+ dispose: () => Promise<unknown>
3047
+ drainAsyncWork: () => Promise<unknown> | undefined
3048
+ rollback: () => unknown
3049
+ failInitialization: (error: unknown) => {
3050
+ active: boolean
3051
+ error: any
3052
+ promise: Promise<void> | undefined
3053
+ }
3054
+ registered: () => boolean
3055
+ workerError: (error: unknown, threadId: number) => void
3056
+ crash: () => void
3057
+ parked: () => boolean
3058
+ }),
3059
+ steps,
3060
+ reads,
3061
+ liveIntervals: () => intervals.size,
3062
+ }
3063
+ }
3064
+
3065
+ async function waitUntil(condition: () => boolean, label: string) {
3066
+ const deadline = Date.now() + 5000
3067
+ while (!condition()) {
3068
+ if (Date.now() > deadline) {
3069
+ throw new Error(`timed out waiting for ${label}`)
3070
+ }
3071
+ await new Promise((resolve) => setTimeout(resolve, 1))
3072
+ }
3073
+ }
3074
+
3075
+ async function settleWithin(promise: Promise<unknown>, ms = 2000) {
3076
+ let timer: ReturnType<typeof setTimeout> | undefined
3077
+ const timeout = new Promise((resolve) => {
3078
+ timer = setTimeout(() => resolve({ pending: true }), ms)
3079
+ })
3080
+ try {
3081
+ return await Promise.race([
3082
+ promise.then(
3083
+ (value) => ({ value }),
3084
+ (error) => ({ error }),
3085
+ ),
3086
+ timeout,
3087
+ ])
3088
+ } finally {
3089
+ clearTimeout(timer)
3090
+ }
3091
+ }
3092
+
3093
+ const CRASH_DISPOSAL_MESSAGE =
3094
+ 'napi-rs: WASI binding cannot be disposed after a worker thread crashed'
3095
+
3096
+ // The entry check only sees a crash that came first. A worker that dies while
3097
+ // dispose() is waiting leaves the async-work drain polling a count the dead
3098
+ // thread never releases, and its 1 ms timer plus the 50 ms keep-alive interval
3099
+ // held the process open for good with the promise pending. The drain now stops
3100
+ // on the next turn and the disposer rejects through the crash disposal.
3101
+ test('node WASI disposal stops the async-work drain when a worker crashes mid-flight', async (t) => {
3102
+ const code = createWasiBinding('test', '@scope/test')
3103
+ const binding = createInflightWasiDisposal(code, {
3104
+ asyncWorkPending: 1,
3105
+ runtimeWorkPending: 0,
3106
+ })
3107
+ const first = binding.dispose()
3108
+ await waitUntil(() => binding.reads.asyncWork >= 5, 'the drain to poll')
3109
+ t.is(binding.liveIntervals(), 1, 'the keep-alive interval is armed')
3110
+ binding.crash()
3111
+ // A caller that arrives before the poll notices joins the same disposal.
3112
+ const joined = binding.dispose()
3113
+ const outcome: any = await settleWithin(first)
3114
+ t.falsy(outcome.pending, 'the original promise settles')
3115
+ t.true(outcome.error instanceof Error)
3116
+ t.is(outcome.error.message, CRASH_DISPOSAL_MESSAGE)
3117
+ t.is(joined, first)
3118
+ // Every later call gets the same promise, and it stays rejected.
3119
+ const later = binding.dispose()
3120
+ t.is(later, first)
3121
+ t.is(((await settleWithin(later)) as any).error, outcome.error)
3122
+
3123
+ t.is(binding.liveIntervals(), 0, 'the keep-alive interval is cleared')
3124
+ const readsAfter = binding.reads.asyncWork
3125
+ await new Promise((resolve) => setTimeout(resolve, 20))
3126
+ t.is(binding.reads.asyncWork, readsAfter, 'the drain stopped polling')
3127
+ // Nothing re-entered the barrier, the context or the addon after the crash;
3128
+ // the port is released and the workers terminated exactly once.
3129
+ t.deepEqual(binding.steps, [
3130
+ 'cancel',
3131
+ 'interval set',
3132
+ 'interval cleared',
3133
+ 'port unref',
3134
+ 'terminate',
3135
+ ])
3136
+ })
3137
+
3138
+ // Same race one step later: the drain passed, the barrier's two-phase form
3139
+ // began, and its poll waits for runtime work the dead thread still counts.
3140
+ // `…_finish` would join that work and block this thread for good, so the
3141
+ // barrier is left parked and never finished.
3142
+ test('node WASI disposal stops the barrier poll when a worker crashes mid-flight', async (t) => {
3143
+ const code = createWasiBinding('test', '@scope/test')
3144
+ for (const workDrainsWithCrash of [false, true]) {
3145
+ const label = `work drains in the crash turn: ${workDrainsWithCrash}`
3146
+ const addon: Parameters<typeof createInflightWasiDisposal>[1] = {
3147
+ asyncWorkPending: 0,
3148
+ runtimeWorkPending: 1,
3149
+ }
3150
+ const binding = createInflightWasiDisposal(code, addon)
3151
+ const first = binding.dispose()
3152
+ await waitUntil(() => binding.reads.runtimeWork >= 5, 'the barrier to poll')
3153
+ t.true(binding.parked(), label)
3154
+ if (workDrainsWithCrash) {
3155
+ // The thread dies while the poll is reading, and the count it reads is
3156
+ // zero: the poll returns normally and would go straight on to
3157
+ // `…_finish`.
3158
+ await new Promise<void>((resolve) => {
3159
+ addon.onRuntimeWorkRead = () => {
3160
+ addon.onRuntimeWorkRead = undefined
3161
+ binding.crash()
3162
+ addon.runtimeWorkPending = 0
3163
+ resolve()
3164
+ }
3165
+ })
3166
+ } else {
3167
+ binding.crash()
3168
+ }
3169
+ const outcome: any = await settleWithin(first)
3170
+ t.falsy(outcome.pending, label)
3171
+ t.is(outcome.error?.message, CRASH_DISPOSAL_MESSAGE, label)
3172
+ t.is(binding.dispose(), first, label)
3173
+ t.false(binding.parked(), label)
3174
+ const readsAfter = binding.reads.runtimeWork
3175
+ await new Promise((resolve) => setTimeout(resolve, 20))
3176
+ t.is(binding.reads.runtimeWork, readsAfter, label)
3177
+ t.is(binding.liveIntervals(), 0, label)
3178
+ t.deepEqual(binding.steps, ['begin', 'port unref', 'terminate'], label)
3179
+ }
3180
+ })
3181
+
3182
+ // Without a crash the same chain runs every step exactly as before and
3183
+ // resolves; the keep-alive interval lives only as long as the drain.
3184
+ test('node WASI disposal without a crash runs the whole chain on real timers', async (t) => {
3185
+ const code = createWasiBinding('test', '@scope/test')
3186
+ const addon = { asyncWorkPending: 1, runtimeWorkPending: 1 }
3187
+ const binding = createInflightWasiDisposal(code, addon)
3188
+ const first = binding.dispose()
3189
+ await waitUntil(() => binding.reads.asyncWork >= 3, 'the drain to poll')
3190
+ addon.asyncWorkPending = 0
3191
+ await waitUntil(() => binding.reads.runtimeWork >= 3, 'the barrier to poll')
3192
+ addon.runtimeWorkPending = 0
3193
+ t.deepEqual(await settleWithin(first), { value: undefined })
3194
+ t.deepEqual(binding.steps, [
3195
+ 'cancel',
3196
+ 'interval set',
3197
+ 'interval cleared',
3198
+ 'begin',
3199
+ 'finish',
3200
+ 'drain',
3201
+ 'destroy',
3202
+ 'terminate',
3203
+ 'complete',
3204
+ ])
3205
+ t.is(binding.liveIntervals(), 0)
3206
+ })
3207
+
3208
+ // The crash check fires only while a public disposal or the initialization
3209
+ // rollback is in flight. A poll started by neither keeps polling.
3210
+ test('node WASI crash abort leaves a poll outside disposal and rollback alone', async (t) => {
3211
+ const code = createWasiBinding('test', '@scope/test')
3212
+ const addon = { asyncWorkPending: 1, runtimeWorkPending: 0 }
3213
+ const binding = createInflightWasiDisposal(code, addon)
3214
+ const drain = binding.drainAsyncWork()
3215
+ t.truthy(drain)
3216
+ binding.crash()
3217
+ const readsAtCrash = binding.reads.asyncWork
3218
+ await waitUntil(
3219
+ () => binding.reads.asyncWork >= readsAtCrash + 5,
3220
+ 'the drain to keep polling',
3221
+ )
3222
+ addon.asyncWorkPending = 0
3223
+ t.deepEqual(await settleWithin(drain!), { value: undefined })
3224
+ t.false(binding.steps.includes('terminate'))
3225
+ t.false(binding.steps.includes('port unref'))
3226
+ })
3227
+
3228
+ // The initialization rollback runs the same drain as dispose(). A worker that
3229
+ // died while it polled left the count above zero, and the rollback polled it
3230
+ // forever with its timers holding the process open. It now stops, skips the
3231
+ // barrier and the destroy, and lets the initialization error through with the
3232
+ // crash attached.
3233
+ test('node WASI initialization rollback stops its async-work drain when a worker crashes', async (t) => {
3234
+ const code = createWasiBinding('test', '@scope/test')
3235
+ const binding = createInflightWasiDisposal(code, {
3236
+ asyncWorkPending: 1,
3237
+ runtimeWorkPending: 0,
3238
+ })
3239
+ const initError = new Error('instantiate failed')
3240
+ const record = binding.failInitialization(initError)
3241
+ const rollback = record.promise
3242
+ t.truthy(rollback, 'the rollback waits for the drain')
3243
+ await waitUntil(() => binding.reads.asyncWork >= 5, 'the rollback to poll')
3244
+ t.is(binding.liveIntervals(), 1)
3245
+ binding.crash()
3246
+ t.falsy(((await settleWithin(rollback!)) as any).pending)
3247
+
3248
+ // The error the loader threw is the initialization error, and the crash is
3249
+ // its cause.
3250
+ t.is(record.error, initError)
3251
+ t.true(record.error.cause instanceof Error)
3252
+ t.is(record.error.cause.message, CRASH_DISPOSAL_MESSAGE)
3253
+ t.false(record.active)
3254
+ t.true(binding.registered(), 'kept: the context was not destroyed')
3255
+
3256
+ t.is(binding.liveIntervals(), 0, 'the keep-alive interval is cleared')
3257
+ const readsAfter = binding.reads.asyncWork
3258
+ await new Promise((resolve) => setTimeout(resolve, 20))
3259
+ t.is(binding.reads.asyncWork, readsAfter, 'the drain stopped polling')
3260
+ // No barrier, no settlement drain, no destroy: the port is released and the
3261
+ // workers are terminated once.
3262
+ t.deepEqual(binding.steps, [
3263
+ 'cancel',
3264
+ 'interval set',
3265
+ 'interval cleared',
3266
+ 'retain',
3267
+ 'port unref',
3268
+ 'terminate',
3269
+ ])
3270
+
3271
+ // The worker's 'error' event arrives after the rollback ended: the crash
3272
+ // error it already attached gets the worker's error as its cause.
3273
+ t.is(record.error.cause.cause, undefined)
3274
+ const workerError = new Error('trap in worker')
3275
+ binding.workerError(workerError, 7)
3276
+ t.is(record.error.cause.cause, workerError)
3277
+ t.is(record.error.cause.workerThreadId, 7)
3278
+ })
3279
+
3280
+ // Same race one step later: the barrier began and its poll waits for runtime
3281
+ // work the dead thread still counts. `…_finish` would join that work, so the
3282
+ // barrier stays parked and is never finished.
3283
+ test('node WASI initialization rollback stops the barrier poll when a worker crashes', async (t) => {
3284
+ const code = createWasiBinding('test', '@scope/test')
3285
+ const binding = createInflightWasiDisposal(code, {
3286
+ asyncWorkPending: 0,
3287
+ runtimeWorkPending: 1,
3288
+ })
3289
+ const initError = new Error('instantiate failed')
3290
+ const record = binding.failInitialization(initError)
3291
+ const rollback = record.promise
3292
+ t.truthy(rollback)
3293
+ await waitUntil(() => binding.reads.runtimeWork >= 5, 'the barrier to poll')
3294
+ t.true(binding.parked())
3295
+ binding.crash()
3296
+ t.falsy(((await settleWithin(rollback!)) as any).pending)
3297
+ t.is(record.error, initError)
3298
+ t.is(record.error.cause?.message, CRASH_DISPOSAL_MESSAGE)
3299
+ t.false(binding.parked())
3300
+ const readsAfter = binding.reads.runtimeWork
3301
+ await new Promise((resolve) => setTimeout(resolve, 20))
3302
+ t.is(binding.reads.runtimeWork, readsAfter)
3303
+ t.deepEqual(binding.steps, ['begin', 'retain', 'port unref', 'terminate'])
3304
+ })
3305
+
3306
+ // A thread that died before instantiation failed: the rollback does not enter
3307
+ // wasm at all, and it stays synchronous, so the error the loader throws right
3308
+ // away already carries the crash.
3309
+ test('node WASI initialization rollback after an earlier crash skips wasm', (t) => {
3310
+ const code = createWasiBinding('test', '@scope/test')
3311
+ const binding = createInflightWasiDisposal(code, {
3312
+ asyncWorkPending: 1,
3313
+ runtimeWorkPending: 1,
3314
+ })
3315
+ binding.crash()
3316
+ const initError = new Error('instantiate failed')
3317
+ const record = binding.failInitialization(initError)
3318
+ t.is(record.promise, undefined, 'settled synchronously')
3319
+ t.is(record.error, initError)
3320
+ t.is(record.error.cause?.message, CRASH_DISPOSAL_MESSAGE)
3321
+ t.is(binding.reads.asyncWork, 0)
3322
+ t.is(binding.reads.runtimeWork, 0)
3323
+ t.deepEqual(binding.steps, ['port unref', 'terminate'])
3324
+
3325
+ // An initialization error that already has a cause keeps it; the crash is
3326
+ // listed with the cleanup errors instead. The crash error is shared, and the
3327
+ // workers are not terminated a second time.
3328
+ const withCause = new Error('instantiate failed', {
3329
+ cause: new Error('link error'),
3330
+ })
3331
+ const second = binding.failInitialization(withCause)
3332
+ t.is(second.error, withCause)
3333
+ t.is(second.error.cause.message, 'link error')
3334
+ t.is(second.error.cleanupErrors[0], record.error.cause)
3335
+ t.deepEqual(binding.steps, ['port unref', 'terminate'])
3336
+ })
3337
+
3338
+ // Without a crash the rollback runs every step exactly as before, resolves
3339
+ // with no cleanup errors and leaves the initialization error untouched.
3340
+ test('node WASI initialization rollback without a crash is unchanged', async (t) => {
3341
+ const code = createWasiBinding('test', '@scope/test')
3342
+ const addon = { asyncWorkPending: 1, runtimeWorkPending: 1 }
3343
+ const binding = createInflightWasiDisposal(code, addon)
3344
+ const initError = new Error('instantiate failed')
3345
+ const record = binding.failInitialization(initError)
3346
+ const rollback = record.promise
3347
+ t.truthy(rollback)
3348
+ await waitUntil(() => binding.reads.asyncWork >= 3, 'the drain to poll')
3349
+ addon.asyncWorkPending = 0
3350
+ await waitUntil(() => binding.reads.runtimeWork >= 3, 'the barrier to poll')
3351
+ addon.runtimeWorkPending = 0
3352
+ t.deepEqual(await settleWithin(rollback!), { value: undefined })
3353
+ t.is(record.error, initError)
3354
+ t.is(record.error.cause, undefined)
3355
+ t.is(record.error.cleanupErrors, undefined)
3356
+ t.false(binding.registered(), 'a clean rollback is not kept')
3357
+ t.deepEqual(binding.steps, [
3358
+ 'cancel',
3359
+ 'interval set',
3360
+ 'interval cleared',
3361
+ 'begin',
3362
+ 'finish',
3363
+ 'drain',
3364
+ 'destroy',
3365
+ 'terminate',
3366
+ ])
3367
+ t.is(binding.liveIntervals(), 0)
3368
+ })
3369
+
3370
+ // The shared flag is raised before this thread has processed the worker's
3371
+ // 'error' event, and once the workers are terminated emnapi ignores that event,
3372
+ // so `_fatalError` was often still unset when the crash error was built and
3373
+ // the cause was lost. The loader's own listener keeps the first error, and the
3374
+ // crash error picks it up whenever it arrives.
3375
+ test('node WASI crash error takes its cause from the worker error listener', async (t) => {
3376
+ const code = createWasiBinding('test', '@scope/test')
3377
+
3378
+ // The event lands while the crash disposal is terminating the workers.
3379
+ {
3380
+ const binding = createInflightWasiDisposal(code, {
3381
+ asyncWorkPending: 1,
3382
+ runtimeWorkPending: 0,
3383
+ })
3384
+ const first = binding.dispose()
3385
+ await waitUntil(() => binding.reads.asyncWork >= 3, 'the drain to poll')
3386
+ binding.crash()
3387
+ const workerError = new Error('trap in worker')
3388
+ binding.workerError(workerError, 3)
3389
+ // Only the first error is kept.
3390
+ binding.workerError(new Error('second worker'), 4)
3391
+ const outcome: any = await settleWithin(first)
3392
+ t.is(outcome.error?.message, CRASH_DISPOSAL_MESSAGE)
3393
+ t.is(outcome.error.cause, workerError)
3394
+ t.is(outcome.error.workerThreadId, 3)
3395
+ }
3396
+
3397
+ // The event lands after the disposal already rejected: the one shared crash
3398
+ // error is completed in place, so every holder of it sees the cause.
3399
+ {
3400
+ const binding = createInflightWasiDisposal(code, {
3401
+ asyncWorkPending: 1,
3402
+ runtimeWorkPending: 0,
3403
+ })
3404
+ const first = binding.dispose()
3405
+ await waitUntil(() => binding.reads.asyncWork >= 3, 'the drain to poll')
3406
+ binding.crash()
3407
+ const outcome: any = await settleWithin(first)
3408
+ t.is(outcome.error?.message, CRASH_DISPOSAL_MESSAGE)
3409
+ t.is(outcome.error.cause, undefined)
3410
+ const workerError = new Error('trap in worker')
3411
+ binding.workerError(workerError, 9)
3412
+ t.is(outcome.error.cause, workerError)
3413
+ t.is(outcome.error.workerThreadId, 9)
3414
+ const later: any = await settleWithin(binding.dispose())
3415
+ t.is(later.error, outcome.error)
3416
+ }
3417
+ })
3418
+
3419
+ test('WASI loaders without the crash latch keep their disposal polls unchanged', (t) => {
3420
+ const loaders = {
3421
+ 'threadless node': createWasiBinding(
3422
+ 'test',
3423
+ '@scope/test',
3424
+ 4000,
3425
+ 65536,
3426
+ false,
3427
+ ),
3428
+ 'threaded browser': createWasiBrowserBinding('test-wasi'),
3429
+ 'threadless browser': createWasiBrowserBinding(
3430
+ 'test-wasi',
3431
+ 4000,
3432
+ 65536,
3433
+ false,
3434
+ false,
3435
+ false,
3436
+ false,
3437
+ false,
3438
+ ),
3439
+ }
3440
+ for (const [label, code] of Object.entries(loaders)) {
3441
+ t.false(code.includes('__abortWasiDisposalIfThreadCrashed'), label)
3442
+ t.false(code.includes('__settleWasiDisposalAfterThreadCrash'), label)
3443
+ t.false(code.includes('finishCleanupUnlessCrashed'), label)
3444
+ t.false(code.includes('__wasiInitializationRollbackActive'), label)
3445
+ t.false(code.includes('__runWasiInitializationRollbackSteps'), label)
3446
+ t.false(
3447
+ code.includes('__rollbackWasiInitializationAfterThreadCrash'),
3448
+ label,
3449
+ )
3450
+ t.false(code.includes('__getWasiThreadCrashError'), label)
3451
+ t.false(code.includes('__recordWasiThreadCrashError'), label)
3452
+ t.false(code.includes('__readWasiThreadCrashReport'), label)
3453
+ t.true(code.includes('function __rollbackWasiInitialization() {\n'), label)
3454
+ t.true(
3455
+ generatedFunction(code, '__prepareWasmEnvCleanupWithTurns').includes(
3456
+ ' })().then(finishCleanup, finishCleanup)\n',
3457
+ ),
3458
+ label,
3459
+ )
3460
+ }
3461
+ // The threaded node loader checks the latch in every disposal poll and step,
3462
+ // and in the rollback's steps.
3463
+ const threaded = createWasiBinding('test', '@scope/test')
3464
+ for (const name of [
3465
+ '__drainWasiAsyncWork',
3466
+ '__prepareWasmEnvCleanupWithTurns',
3467
+ '__cleanUpWasmEnvForWasiDisposal',
3468
+ '__drainWasmEnvForWasiDisposal',
3469
+ '__continueWasiDisposal',
3470
+ '__destroyContextForWasiRollback',
3471
+ ]) {
3472
+ t.true(
3473
+ generatedFunction(threaded, name).includes(
3474
+ '__abortWasiDisposalIfThreadCrashed()',
3475
+ ),
3476
+ name,
3477
+ )
3478
+ }
3479
+ const steps = generatedFunction(
3480
+ threaded,
3481
+ '__runWasiInitializationRollbackSteps',
3482
+ )
3483
+ t.is(
3484
+ steps.split(' __abortWasiDisposalIfThreadCrashed()\n').length - 1,
3485
+ 2,
3486
+ 'before the barrier and before the settlement drain',
3487
+ )
3488
+ })
3489
+
3490
+ test('node WASI loader shares the crash flag with its pool workers', (t) => {
3491
+ const code = createWasiBinding('test', '@scope/test')
3492
+ const createWorker = generatedFunction(code, '__createWasiWorker')
3493
+ t.true(createWorker.includes('crashFlag: __wasiThreadCrashFlag,'))
3494
+ t.true(createWorker.includes('crashReport: __wasiThreadCrashReport,'))
3495
+ const onCreateWorker = code.slice(
3496
+ code.indexOf('onCreateWorker() {'),
3497
+ code.indexOf('return worker\n', code.indexOf('onCreateWorker() {')),
3498
+ )
3499
+ t.true(
3500
+ onCreateWorker.includes("worker.on('error', (error) => {"),
3501
+ 'the loader has to see a worker error even when the worker never set the flag',
3502
+ )
3503
+ t.true(onCreateWorker.includes('__wasiThreadCrashed = true'))
3504
+ t.true(
3505
+ onCreateWorker.includes(
3506
+ '__recordWasiThreadCrashError(error, worker.threadId)',
3507
+ ),
3508
+ 'and keeps the error it carries for the crash error',
3509
+ )
3510
+ })
3511
+
3512
+ // The threadless flavor has no pool workers and must not reference the latch:
3513
+ // a name the exit listener cannot resolve would throw on every process exit.
3514
+ test('threadless node WASI loader keeps its exit teardown unconditional', (t) => {
3515
+ const code = createWasiBinding('test', '@scope/test', 4000, 65536, false)
3516
+ t.false(code.includes('__hasWasiThreadCrashed'))
3517
+ t.false(code.includes('__wasiThreadCrash'))
3518
+ t.false(code.includes('SharedArrayBuffer'))
3519
+ t.true(generatedFunction(code, '__disposeWasiBindingAtExit').length > 0)
3520
+ t.false(code.includes('__disposeWasiBindingAfterThreadCrash'))
3521
+ t.false(code.includes('__releaseEmnapiWaitingRequestHandle'))
3522
+ t.true(
3523
+ generatedFunction(code, '__disposeWasiBinding').startsWith(
3524
+ 'function __disposeWasiBinding() {\n if (__wasiDisposePromise) {\n',
3525
+ ),
3526
+ )
3527
+ })
3528
+
3529
+ // The browser loader has no 'exit' teardown and no crash latch; its public
3530
+ // disposer is unchanged.
3531
+ test('browser WASI loader public disposer has no crash latch', (t) => {
3532
+ for (const threads of [true, false]) {
3533
+ const code = createWasiBrowserBinding(
3534
+ 'test-wasi',
3535
+ 4000,
3536
+ 65536,
3537
+ false,
3538
+ false,
3539
+ false,
3540
+ false,
3541
+ threads,
3542
+ )
3543
+ t.false(code.includes('__hasWasiThreadCrashed'), `threads=${threads}`)
3544
+ t.false(
3545
+ code.includes('__disposeWasiBindingAfterThreadCrash'),
3546
+ `threads=${threads}`,
3547
+ )
3548
+ }
3549
+ })
3550
+
3551
+ /**
3552
+ * The column-zero `if` of the Node worker that wraps emnapi's
3553
+ * `beforeReportError`, with the helpers it calls: a function body over
3554
+ * `workerData`, `handler` and `threadId`.
3555
+ */
3556
+ function wasiWorkerCrashHook(): string {
3557
+ const start =
3558
+ WASI_WORKER_TEMPLATE.indexOf(
3559
+ '\nif (workerData && workerData.crashFlag instanceof Int32Array) {\n',
3560
+ ) + 1
3561
+ return (
3562
+ WASI_WORKER_TEMPLATE.slice(
3563
+ start,
3564
+ WASI_WORKER_TEMPLATE.indexOf('\n}\n', start) + 2,
3565
+ ) +
3566
+ generatedFunction(WASI_WORKER_TEMPLATE, '__raiseWasiThreadCrashFlags') +
3567
+ generatedFunction(WASI_WORKER_TEMPLATE, '__writeCrashReport')
3568
+ )
3569
+ }
3570
+
3571
+ test('WASI worker sets the crash flag before emnapi reports the error', (t) => {
3572
+ assertValidJS(t, WASI_WORKER_TEMPLATE, 'Node WASI worker')
3573
+ const start = WASI_WORKER_TEMPLATE.indexOf(
3574
+ '\nif (workerData && workerData.crashFlag instanceof Int32Array) {',
3575
+ )
3576
+ t.true(start > 0)
3577
+ t.true(
3578
+ start < WASI_WORKER_TEMPLATE.indexOf('globalThis.onmessage = function'),
3579
+ 'the hook has to be in place before the first message is handled',
3580
+ )
3581
+ const latch = wasiWorkerCrashHook()
3582
+
3583
+ const run = (workerData: { crashFlag?: Int32Array } & object) => {
3584
+ const reports: string[] = []
3585
+ const handler = {
3586
+ // emnapi's own hook, which lets the main thread start exiting.
3587
+ beforeReportError(this: unknown, error: Error, type: string) {
3588
+ const flag = workerData.crashFlag
3589
+ ? Atomics.load(workerData.crashFlag, 0)
3590
+ : 'none'
3591
+ reports.push(
3592
+ `${error.message} ${type} flag=${flag} ${this === handler}`,
3593
+ )
3594
+ },
3595
+ }
3596
+ const original = handler.beforeReportError
3597
+ new Function('workerData', 'handler', 'threadId', latch)(
3598
+ workerData,
3599
+ handler,
3600
+ 1,
3601
+ )
3602
+ return { handler, original, reports }
3603
+ }
3604
+
3605
+ const crashFlag = new Int32Array(new SharedArrayBuffer(4))
3606
+ const crashed = run({ crashFlag })
3607
+ t.not(crashed.handler.beforeReportError, crashed.original)
3608
+ crashed.handler.beforeReportError(new Error('trap'), 'start')
3609
+ // Set before emnapi's hook runs, and emnapi's hook still runs on the handler.
3610
+ t.deepEqual(crashed.reports, ['trap start flag=1 true'])
3611
+ t.is(Atomics.load(crashFlag, 0), 1)
3612
+
3613
+ // A loader that predates the flag passes none: the hook stays emnapi's own.
3614
+ const legacy = run({ hostRoot: '/', rootDir: '/' } as object)
3615
+ t.is(legacy.handler.beforeReportError, legacy.original)
3616
+ })
3617
+
3618
+ // The loader thread can be inside a wasm cleanup call that waits on the dead
3619
+ // thread. The addon's flag makes those waits trap; the loader must already see
3620
+ // its own flag when the trap reaches it.
3621
+ test('WASI worker raises the addon crash flag after the loader flag', (t) => {
3622
+ const latch = wasiWorkerCrashHook()
3623
+ const crash = (
3624
+ instance: unknown,
3625
+ workerData: { crashFlag: Int32Array; addonCrashFlag?: unknown },
3626
+ ) => {
3627
+ const calls: string[] = []
3628
+ const handler = {
3629
+ instance,
3630
+ beforeReportError() {
3631
+ calls.push(`emnapi flag=${Atomics.load(workerData.crashFlag, 0)}`)
3632
+ },
3633
+ }
3634
+ new Function('workerData', 'handler', 'threadId', latch)(
3635
+ workerData,
3636
+ handler,
3637
+ 1,
3638
+ )
3639
+ ;(handler.beforeReportError as (...args: unknown[]) => void)(
3640
+ new Error('trap'),
3641
+ 'start',
3642
+ )
3643
+ return calls
3644
+ }
3645
+
3646
+ // The loader's view of the addon's flag: one word of the shared wasm memory,
3647
+ // at an offset. A worker that failed while loading has no instance.
3648
+ const memory = new WebAssembly.Memory({
3649
+ initial: 1,
3650
+ maximum: 1,
3651
+ shared: true,
3652
+ })
3653
+ const word = new Int32Array(memory.buffer, 64, 1)
3654
+ const crashFlag = new Int32Array(new SharedArrayBuffer(4))
3655
+ const calls: string[] = []
3656
+ const workerData = {
3657
+ crashFlag,
3658
+ get addonCrashFlag() {
3659
+ calls.push(`view read, loader flag=${Atomics.load(crashFlag, 0)}`)
3660
+ return word
3661
+ },
3662
+ }
3663
+ calls.push(...crash(undefined, workerData))
3664
+ t.deepEqual(calls, ['view read, loader flag=1', 'emnapi flag=1'])
3665
+ t.is(Atomics.load(new Int32Array(memory.buffer), 16), 1)
3666
+ t.is(Atomics.load(new Int32Array(memory.buffer), 15), 0)
3667
+ t.is(Atomics.load(new Int32Array(memory.buffer), 17), 0)
3668
+
3669
+ // With a view the export is not needed: it is not called.
3670
+ const exported: string[] = []
3671
+ const withInstance = crash(
3672
+ {
3673
+ exports: {
3674
+ napi_wasm_thread_crashed() {
3675
+ exported.push('napi')
3676
+ },
3677
+ },
3678
+ },
3679
+ {
3680
+ crashFlag: new Int32Array(new SharedArrayBuffer(4)),
3681
+ addonCrashFlag: new Int32Array(new SharedArrayBuffer(4)),
3682
+ },
3683
+ )
3684
+ t.deepEqual(withInstance, ['emnapi flag=1'])
3685
+ t.deepEqual(exported, [])
3686
+
3687
+ // No view (an addon built with an older napi): the export, when the worker
3688
+ // has an instance, after the loader flag.
3689
+ const noViewFlag = new Int32Array(new SharedArrayBuffer(4))
3690
+ const noView: string[] = []
3691
+ noView.push(
3692
+ ...crash(
3693
+ {
3694
+ exports: {
3695
+ napi_wasm_thread_crashed() {
3696
+ noView.push(`napi flag=${Atomics.load(noViewFlag, 0)}`)
3697
+ },
3698
+ },
3699
+ },
3700
+ { crashFlag: noViewFlag },
3701
+ ),
3702
+ )
3703
+ t.deepEqual(noView, ['napi flag=1', 'emnapi flag=1'])
3704
+
3705
+ // A worker that never loaded, an addon without the export, an export that
3706
+ // throws, a view that is not an Int32Array: emnapi's hook still runs.
3707
+ for (const [instance, addonCrashFlag] of [
3708
+ [undefined, undefined],
3709
+ [{ exports: {} }, undefined],
3710
+ [
3711
+ {
3712
+ exports: {
3713
+ napi_wasm_thread_crashed() {
3714
+ throw new Error('boom')
3715
+ },
3716
+ },
3717
+ },
3718
+ undefined,
3719
+ ],
3720
+ [undefined, new Uint8Array(new SharedArrayBuffer(4))],
3721
+ ]) {
3722
+ t.deepEqual(
3723
+ crash(instance, {
3724
+ crashFlag: new Int32Array(new SharedArrayBuffer(4)),
3725
+ addonCrashFlag,
3726
+ }),
3727
+ ['emnapi flag=1'],
3728
+ )
3729
+ }
3730
+ })
3731
+
3732
+ /**
3733
+ * A shared wasm memory with a view of one word at `offset`, the way the loaders
3734
+ * build the addon's crash flag view.
3735
+ */
3736
+ function addonCrashFlagView(offset = 64) {
3737
+ const memory = new WebAssembly.Memory({
3738
+ initial: 1,
3739
+ maximum: 1,
3740
+ shared: true,
3741
+ })
3742
+ return { memory, word: new Int32Array(memory.buffer, offset, 1) }
3743
+ }
3744
+
3745
+ // A worker whose setup throws before the crash hook exists (here: the runtime
3746
+ // package cannot be resolved) never gets to emnapi's beforeReportError. Its
3747
+ // thread spawn has already returned, so the loader may be waiting on it inside
3748
+ // wasm: it has to raise both flags itself, then fail as before.
3749
+ test('WASI worker raises both crash flags when its setup throws', (t) => {
3750
+ const anchor = '\nlet handler\ntry {\n'
3751
+ const start = WASI_WORKER_TEMPLATE.indexOf(anchor) + 1
3752
+ t.true(start > 0)
3753
+ const catchStart = WASI_WORKER_TEMPLATE.indexOf('} catch (error) {\n', start)
3754
+ const setup =
3755
+ WASI_WORKER_TEMPLATE.slice(
3756
+ start,
3757
+ WASI_WORKER_TEMPLATE.indexOf('\n}\n', catchStart) + 2,
3758
+ ) +
3759
+ generatedFunction(WASI_WORKER_TEMPLATE, '__raiseWasiThreadCrashFlags') +
3760
+ generatedFunction(WASI_WORKER_TEMPLATE, '__writeCrashReport')
3761
+ t.true(
3762
+ setup.indexOf("require('@napi-rs/wasm-runtime')") <
3763
+ setup.indexOf('handler = new MessageHandler('),
3764
+ )
3765
+ const missing = new Error("Cannot find module '@napi-rs/wasm-runtime'")
3766
+ const run = (workerData: object | undefined) =>
3767
+ new Function('require', 'workerData', 'threadId', setup)(
3768
+ () => {
3769
+ throw missing
3770
+ },
3771
+ workerData,
3772
+ 3,
3773
+ )
3774
+
3775
+ const { memory, word } = addonCrashFlagView()
3776
+ const crashFlag = new Int32Array(new SharedArrayBuffer(4))
3777
+ const crashReport = new SharedArrayBuffer(4096)
3778
+ const order: number[] = []
3779
+ const workerData = {
3780
+ crashFlag,
3781
+ crashReport,
3782
+ get addonCrashFlag() {
3783
+ order.push(Atomics.load(crashFlag, 0))
3784
+ return word
3785
+ },
3786
+ }
3787
+ t.is(
3788
+ t.throws(() => run(workerData)),
3789
+ missing,
3790
+ )
3791
+ t.is(Atomics.load(crashFlag, 0), 1)
3792
+ t.deepEqual(order, [1], 'the loader flag goes up before the addon flag')
3793
+ t.is(Atomics.load(new Int32Array(memory.buffer), 16), 1)
3794
+ const header = new Int32Array(crashReport, 0, 3)
3795
+ t.is(Atomics.load(header, 0), 2)
3796
+ t.is(Atomics.load(header, 2), 3)
3797
+
3798
+ // A loader that predates the flags: the error is only rethrown.
3799
+ t.is(
3800
+ t.throws(() => run({ hostRoot: '/', rootDir: '/' })),
3801
+ missing,
3802
+ )
3803
+ t.is(
3804
+ t.throws(() => run(undefined)),
3805
+ missing,
3806
+ )
3807
+ })
3808
+
3809
+ // The same through a real worker thread: the generated worker, written where
3810
+ // '@napi-rs/wasm-runtime' cannot be resolved, gets the view in workerData — a
3811
+ // structured clone that keeps sharing the wasm memory — and raises the word.
3812
+ test('WASI worker that fails to load raises the flags from a real thread', async (t) => {
3813
+ const directory = await mkdtemp(join(tmpdir(), 'napi-rs-wasi-worker-'))
3814
+ t.teardown(() => rm(directory, { recursive: true, force: true }))
3815
+ const workerPath = join(directory, 'wasi-worker.mjs')
3816
+ await writeFile(workerPath, WASI_WORKER_TEMPLATE)
3817
+ const { memory, word } = addonCrashFlagView(128)
3818
+ const crashFlag = new Int32Array(new SharedArrayBuffer(4))
3819
+ const crashReport = new SharedArrayBuffer(4096)
3820
+ const worker = new Worker(workerPath, {
3821
+ workerData: { crashFlag, crashReport, addonCrashFlag: word },
3822
+ })
3823
+ const error = await new Promise<Error>((resolve, reject) => {
3824
+ worker.once('error', resolve)
3825
+ worker.once('exit', (code) =>
3826
+ reject(new Error(`the worker exited with ${code} and no error`)),
3827
+ )
3828
+ })
3829
+ t.regex(error.message, /@napi-rs\/wasm-runtime/)
3830
+ t.is(Atomics.load(crashFlag, 0), 1)
3831
+ t.is(Atomics.load(new Int32Array(memory.buffer), 32), 1)
3832
+ t.is(Atomics.load(new Int32Array(crashReport, 0, 1), 0), 2)
3833
+ })
3834
+
3835
+ /**
3836
+ * The Node loader's view capture and worker creation, over a real shared wasm
3837
+ * memory and a stub `Worker` that keeps its options.
3838
+ */
3839
+ function createNodeLoaderAddonCrashFlag() {
3840
+ const code = createWasiBinding('test', '@scope/test')
3841
+ const created: Array<{ filename: string; options: any }> = []
3842
+ class StubWorker {
3843
+ constructor(filename: string, options: unknown) {
3844
+ created.push({ filename, options })
3845
+ }
3846
+ }
3847
+ const loader = new Function(
3848
+ 'Worker',
3849
+ `
3850
+ const __sharedMemory = new WebAssembly.Memory({ initial: 1, maximum: 2, shared: true })
3851
+ const __hostRoot = '/'
3852
+ const __rootDir = '/'
3853
+ const __wasiThreadCrashFlag = new Int32Array(new SharedArrayBuffer(4))
3854
+ const __wasiThreadCrashReport = new SharedArrayBuffer(16)
3855
+ let __wasiAddonCrashFlag
3856
+ ${generatedFunction(code, '__captureWasiAddonCrashFlag')}
3857
+ ${generatedFunction(code, '__getWasiWorkerExecArgv')}
3858
+ ${generatedFunction(code, '__createWasiWorker')}
3859
+ return {
3860
+ memory: __sharedMemory,
3861
+ capture: __captureWasiAddonCrashFlag,
3862
+ createWorker: __createWasiWorker,
3863
+ view: () => __wasiAddonCrashFlag,
3864
+ }
3865
+ `,
3866
+ )(StubWorker) as {
3867
+ memory: WebAssembly.Memory
3868
+ capture: (instance: unknown) => void
3869
+ createWorker: (filename: string) => unknown
3870
+ view: () => Int32Array | undefined
3871
+ }
3872
+ return { code, created, ...loader }
3873
+ }
3874
+
3875
+ test('node WASI loader reads the addon crash flag address before registration', (t) => {
3876
+ const { code } = createNodeLoaderAddonCrashFlag()
3877
+ const beforeInit = code.slice(code.indexOf(' beforeInit({ instance }) {'))
3878
+ const capture = beforeInit.indexOf('__captureWasiAddonCrashFlag(instance)')
3879
+ t.true(capture > 0)
3880
+ t.true(capture < beforeInit.indexOf("name.startsWith('__napi_register__')"))
3881
+ t.true(
3882
+ generatedFunction(code, '__createWasiWorker').includes(
3883
+ 'addonCrashFlag: __wasiAddonCrashFlag,',
3884
+ ),
3885
+ )
3886
+ // The threadless loader has no workers and reads nothing.
3887
+ const threadless = createWasiBinding(
3888
+ 'test',
3889
+ '@scope/test',
3890
+ 4000,
3891
+ 65536,
3892
+ false,
3893
+ )
3894
+ t.false(threadless.includes('__captureWasiAddonCrashFlag'))
3895
+ t.false(threadless.includes('napi_wasm_thread_crash_flag_address'))
3896
+ })
3897
+
3898
+ test('node WASI loader passes each pool worker a view of the addon crash flag', (t) => {
3899
+ const loader = createNodeLoaderAddonCrashFlag()
3900
+ // Before the capture (or for an addon built with an older napi): no view.
3901
+ loader.createWorker('/w.mjs')
3902
+ t.is(loader.created[0].options.workerData.addonCrashFlag, undefined)
3903
+
3904
+ loader.capture({
3905
+ exports: { napi_wasm_thread_crash_flag_address: () => 1024 },
3906
+ })
3907
+ const view = loader.view()
3908
+ t.true(view instanceof Int32Array)
3909
+ t.is(view!.buffer, loader.memory.buffer)
3910
+ t.is(view!.byteOffset, 1024)
3911
+ t.is(view!.length, 1)
3912
+ loader.createWorker('/w.mjs')
3913
+ t.is(loader.created[1].options.workerData.addonCrashFlag, view)
3914
+ t.is(
3915
+ loader.created[1].options.workerData.crashFlag instanceof Int32Array,
3916
+ true,
3917
+ )
3918
+ // The worker's store lands in the word the addon reads.
3919
+ Atomics.store(view!, 0, 1)
3920
+ t.is(Atomics.load(new Int32Array(loader.memory.buffer), 256), 1)
3921
+
3922
+ // Addresses the loader cannot trust are ignored.
3923
+ for (const exports of [
3924
+ {},
3925
+ { napi_wasm_thread_crash_flag_address: 1024 },
3926
+ { napi_wasm_thread_crash_flag_address: () => 0 },
3927
+ { napi_wasm_thread_crash_flag_address: () => 1026 },
3928
+ { napi_wasm_thread_crash_flag_address: () => 65536 },
3929
+ { napi_wasm_thread_crash_flag_address: () => -4 },
3930
+ {
3931
+ napi_wasm_thread_crash_flag_address() {
3932
+ throw new Error('boom')
3933
+ },
3934
+ },
3935
+ ]) {
3936
+ const fresh = createNodeLoaderAddonCrashFlag()
3937
+ t.notThrows(() => fresh.capture({ exports }))
3938
+ t.is(fresh.view(), undefined)
3939
+ }
3940
+ })
3941
+
3942
+ // The browser pool is created before the wasm is instantiated, so its workers
3943
+ // get the view by message once the instance exists; a worker created later
3944
+ // gets it right away. A browser Worker has no workerData.
3945
+ test('browser WASI loader posts the addon crash flag view to every pool worker', (t) => {
3946
+ const code = createWasiBrowserBinding(
3947
+ 'test-wasi',
3948
+ 4000,
3949
+ 65536,
3950
+ false,
3951
+ false,
3952
+ false,
3953
+ false,
3954
+ true,
3955
+ )
3956
+ assertValidJS(t, code, 'browser threads')
3957
+ const beforeInitStart = code.indexOf(
3958
+ ' beforeInit({ instance }) {\n __napiInstance = instance\n',
3959
+ )
3960
+ t.true(beforeInitStart > 0)
3961
+ const beforeInitBody = code.slice(
3962
+ code.indexOf(' __napiInstance = instance\n', beforeInitStart),
3963
+ code.indexOf(
3964
+ ' for (const name of Object.keys(instance.exports)) {',
3965
+ beforeInitStart,
3966
+ ),
3967
+ )
3968
+ t.true(beforeInitBody.includes('__captureWasiAddonCrashFlag(instance)'))
3969
+ t.true(
3970
+ code.includes(
3971
+ ' __wasiWorkers.add(worker)\n __shareWasiAddonCrashFlag(worker)\n',
3972
+ ),
3973
+ )
3974
+
3975
+ const posted: Array<[string, unknown]> = []
3976
+ const run = new Function(
3977
+ 'posted',
3978
+ `
3979
+ const __sharedMemory = new WebAssembly.Memory({ initial: 1, maximum: 1, shared: true })
3980
+ const __wasiWorkers = new Set()
3981
+ let __napiInstance
3982
+ ${generatedFunction(code, '__captureWasiAddonCrashFlag')}
3983
+ ${generatedFunction(code, '__shareWasiAddonCrashFlag')}
3984
+ let __wasiAddonCrashFlag
3985
+ function onCreateWorker(name) {
3986
+ const worker = {
3987
+ postMessage(message) {
3988
+ posted.push([name, message])
3989
+ },
3990
+ }
3991
+ __wasiWorkers.add(worker)
3992
+ __shareWasiAddonCrashFlag(worker)
3993
+ return worker
3994
+ }
3995
+ function beforeInit({ instance }) {
3996
+ ${beforeInitBody}
3997
+ }
3998
+ onCreateWorker('pool-1')
3999
+ onCreateWorker('pool-2')
4000
+ beforeInit({ instance: { exports: { napi_wasm_thread_crash_flag_address: () => 512 } } })
4001
+ onCreateWorker('later')
4002
+ return { memory: __sharedMemory, view: __wasiAddonCrashFlag }
4003
+ `,
4004
+ )
4005
+ const { memory, view } = run(posted)
4006
+ t.true(view instanceof Int32Array)
4007
+ t.is(view.buffer, memory.buffer)
4008
+ t.is(view.byteOffset, 512)
4009
+ t.deepEqual(
4010
+ posted.map(([name]) => name),
4011
+ ['pool-1', 'pool-2', 'later'],
4012
+ )
4013
+ for (const [, message] of posted) {
4014
+ t.deepEqual(Object.keys(message as object), ['__napiRsAddonCrashFlag'])
4015
+ t.is(
4016
+ (message as { __napiRsAddonCrashFlag: unknown }).__napiRsAddonCrashFlag,
4017
+ view,
4018
+ )
4019
+ }
4020
+
4021
+ // The threadless browser loader has no workers.
4022
+ const threadless = createWasiBrowserBinding(
4023
+ 'test-wasi',
4024
+ 4000,
4025
+ 65536,
4026
+ false,
4027
+ false,
4028
+ false,
4029
+ false,
4030
+ false,
4031
+ )
4032
+ t.false(threadless.includes('__captureWasiAddonCrashFlag'))
4033
+ t.false(threadless.includes('__shareWasiAddonCrashFlag'))
4034
+ })
4035
+
4036
+ test('browser WASI worker raises the addon crash flag it was posted', (t) => {
4037
+ for (const [fs, errorEvent] of [
4038
+ [false, false],
4039
+ [true, true],
4040
+ ]) {
4041
+ const code = createWasiBrowserWorkerBinding(fs, errorEvent)
4042
+ const start = code.indexOf('let __addonCrashFlag\n')
4043
+ t.true(start > 0)
4044
+ const bridge = code.slice(start)
4045
+ const calls: string[] = []
4046
+ const { memory, word } = addonCrashFlagView(256)
4047
+ const handler: {
4048
+ instance?: unknown
4049
+ beforeReportError: (...args: unknown[]) => unknown
4050
+ handle: (event: { data: unknown }) => void
4051
+ } = {
4052
+ instance: undefined,
4053
+ beforeReportError(this: unknown, error: unknown) {
4054
+ calls.push(
4055
+ `emnapi ${(error as Error).message} word=${Atomics.load(word, 0)} ${this === handler}`,
4056
+ )
4057
+ },
4058
+ handle(event) {
4059
+ calls.push(`handle ${JSON.stringify(event.data)}`)
4060
+ },
4061
+ }
4062
+ const globals: { onmessage?: (event: { data: unknown }) => void } = {}
4063
+ new Function('handler', 'globalThis', bridge)(handler, globals)
4064
+
4065
+ // No view yet: emnapi's hook still runs, nothing is raised.
4066
+ handler.beforeReportError(new Error('early'), 'load')
4067
+ // The view is kept, not handed to emnapi; emnapi's messages still are.
4068
+ globals.onmessage!({ data: { __napiRsAddonCrashFlag: word } })
4069
+ globals.onmessage!({ data: { __emnapi__: { type: 'load' } } })
4070
+ // A wasm thread that died, with or without an instance.
4071
+ handler.beforeReportError(new Error('trap'), 'start')
4072
+ t.deepEqual(calls, [
4073
+ 'emnapi early word=0 true',
4074
+ 'handle {"__emnapi__":{"type":"load"}}',
4075
+ 'emnapi trap word=1 true',
4076
+ ])
4077
+ t.is(Atomics.load(new Int32Array(memory.buffer), 64), 1)
4078
+ }
4079
+ })
4080
+
4081
+ /**
4082
+ * Wires the worker's crash hook to the loader's crash error over one shared
4083
+ * report, the way `__createWasiWorker` passes it, and returns both ends.
4084
+ */
4085
+ function createWasiCrashReportPair() {
4086
+ const code = createWasiBinding('test', '@scope/test')
4087
+ const latchStart = code.indexOf('const __wasiThreadCrashFlag = ')
4088
+ const latchState = code.slice(
4089
+ latchStart,
4090
+ code.indexOf('let __wasiThreadCrashed = false\n', latchStart) +
4091
+ 'let __wasiThreadCrashed = false\n'.length,
4092
+ )
4093
+ const loader = new Function(`
4094
+ ${latchState}
4095
+ function __getWasiThreadManager() {
4096
+ return {}
4097
+ }
4098
+ ${generatedFunction(code, '__readWasiThreadCrashReport')}
4099
+ ${generatedFunction(code, '__recordWasiThreadCrashError')}
4100
+ ${generatedFunction(code, '__getWasiThreadCrashError')}
4101
+ ${generatedFunction(code, '__fillWasiThreadCrashError')}
4102
+ return {
4103
+ workerData: {
4104
+ crashFlag: __wasiThreadCrashFlag,
4105
+ crashReport: __wasiThreadCrashReport,
4106
+ },
4107
+ crashError: __getWasiThreadCrashError,
4108
+ workerError: __recordWasiThreadCrashError,
4109
+ }
4110
+ `)() as {
4111
+ workerData: { crashFlag: Int32Array; crashReport: SharedArrayBuffer }
4112
+ crashError: () => any
4113
+ workerError: (error: unknown, threadId: number) => void
4114
+ }
4115
+ const hook = wasiWorkerCrashHook()
4116
+ const reportStates: number[] = []
4117
+ const crashWorker = (error: unknown, threadId: number) => {
4118
+ const handler = {
4119
+ beforeReportError() {
4120
+ reportStates.push(
4121
+ Atomics.load(new Int32Array(loader.workerData.crashReport, 0, 1), 0),
4122
+ )
4123
+ },
4124
+ }
4125
+ new Function('workerData', 'handler', 'threadId', hook)(
4126
+ loader.workerData,
4127
+ handler,
4128
+ threadId,
4129
+ )
4130
+ ;(handler.beforeReportError as (...args: unknown[]) => void)(error, 'start')
4131
+ }
4132
+ return { ...loader, crashWorker, reportStates }
4133
+ }
4134
+
4135
+ // The 'error' event is dropped when the loader terminates the worker first, so
4136
+ // the crash error lost its cause in most crash runs. The worker now writes its
4137
+ // error into shared memory before it raises the flag, and the loader rebuilds
4138
+ // it from there without waiting for any event.
4139
+ test('WASI worker hands its error to the loader through the shared crash report', (t) => {
4140
+ const pair = createWasiCrashReportPair()
4141
+ const trap = new WebAssembly.RuntimeError('forced trap')
4142
+ pair.crashWorker(trap, 5)
4143
+ // Complete before emnapi's hook runs, and so before the flag is seen.
4144
+ t.deepEqual(pair.reportStates, [2])
4145
+ t.is(Atomics.load(pair.workerData.crashFlag, 0), 1)
4146
+ // A second crash does not overwrite the first report.
4147
+ pair.crashWorker(new Error('second worker'), 6)
4148
+ const crashError = pair.crashError()
4149
+ t.is(crashError.message, CRASH_DISPOSAL_MESSAGE)
4150
+ t.true(crashError.cause instanceof Error)
4151
+ t.is(crashError.cause.name, 'RuntimeError')
4152
+ t.is(crashError.cause.message, 'forced trap')
4153
+ t.is(crashError.cause.stack, trap.stack)
4154
+ t.is(crashError.workerThreadId, 5)
4155
+ // One shared error: a later event does not replace the cause.
4156
+ pair.workerError(new Error('late event'), 7)
4157
+ t.is(pair.crashError(), crashError)
4158
+ t.is(crashError.cause.message, 'forced trap')
4159
+
4160
+ // The listener's error, when it came first, is the original object.
4161
+ const withEvent = createWasiCrashReportPair()
4162
+ withEvent.crashWorker(trap, 5)
4163
+ const eventError = new Error('from the error event')
4164
+ withEvent.workerError(eventError, 5)
4165
+ t.is(withEvent.crashError().cause, eventError)
4166
+
4167
+ // A value that is not an error, and a message too large for the report.
4168
+ const thrownString = createWasiCrashReportPair()
4169
+ thrownString.crashWorker('boom', 3)
4170
+ t.is(thrownString.crashError().cause.message, 'boom')
4171
+ const huge = createWasiCrashReportPair()
4172
+ huge.crashWorker(new Error('x'.repeat(10_000)), 4)
4173
+ const hugeCause = huge.crashError().cause
4174
+ t.true(hugeCause.message.length > 0)
4175
+ t.true(hugeCause.message.length < 10_000)
4176
+ t.is(huge.crashError().workerThreadId, 4)
4177
+ })
4178
+
4179
+ // Thread pool preload (docs/wasi.md, "Thread pool preload"): the threaded Node
4180
+ // loader keeps emnapi's idle reuse pool at the addon's configured MultiThread
4181
+ // worker count, read from its `napi_wasm_runtime_pool_workers` export.
4182
+
4183
+ interface PoolWorkerStub {
4184
+ id: number
4185
+ onmessage?: unknown
4186
+ terminate: () => unknown
4187
+ // Resolves every pending \`terminate()\`, as Node does at the Worker's exit.
4188
+ exit: () => void
4189
+ terminateCalls: number
4190
+ }
4191
+
4192
+ /**
4193
+ * emnapi's ThreadManager, reduced to what the reconcile touches.
4194
+ * `terminateRemoves` models an emnapi whose `terminateWorker` takes the Worker
4195
+ * out of the pool itself; 2.1.0 leaves it there. \`asyncExit\` models Node's
4196
+ * \`worker.terminate()\`, whose promise settles only at the exit; without it
4197
+ * \`terminate()\` returns nothing, as a browser Worker's does.
4198
+ */
4199
+ function createPoolManagerStub(
4200
+ options: {
4201
+ load?: (worker: PoolWorkerStub) => Promise<unknown>
4202
+ allocateThrowsAt?: number
4203
+ terminateRemoves?: boolean
4204
+ asyncExit?: boolean
4205
+ } = {},
4206
+ ) {
4207
+ const load = options.load ?? ((worker) => Promise.resolve(worker))
4208
+ const manager = {
4209
+ unusedWorkers: [] as PoolWorkerStub[],
4210
+ allocated: [] as PoolWorkerStub[],
4211
+ loaded: [] as PoolWorkerStub[],
4212
+ terminated: [] as PoolWorkerStub[],
4213
+ // What `onCreateWorker` tracks in the loader's `__wasiWorkers`.
4214
+ onCreate: (_worker: PoolWorkerStub) => {},
4215
+ allocateUnusedWorker() {
4216
+ if (manager.allocated.length === options.allocateThrowsAt) {
4217
+ throw new Error('allocate threw')
4218
+ }
4219
+ let exit = () => {}
4220
+ const exited = new Promise<void>((resolve) => {
4221
+ exit = resolve
4222
+ })
4223
+ const worker: PoolWorkerStub = {
4224
+ id: manager.allocated.length,
4225
+ onmessage: () => {},
4226
+ terminate() {
4227
+ worker.terminateCalls++
4228
+ return options.asyncExit ? exited : undefined
4229
+ },
4230
+ exit,
4231
+ terminateCalls: 0,
4232
+ }
4233
+ manager.allocated.push(worker)
4234
+ manager.onCreate(worker)
4235
+ manager.unusedWorkers.push(worker)
4236
+ return worker
4237
+ },
4238
+ loadWasmModuleToWorker(worker: PoolWorkerStub) {
4239
+ manager.loaded.push(worker)
4240
+ return load(worker)
4241
+ },
4242
+ terminateWorker(worker: PoolWorkerStub) {
4243
+ manager.terminated.push(worker)
4244
+ if (options.terminateRemoves) {
4245
+ const index = manager.unusedWorkers.indexOf(worker)
4246
+ if (index !== -1) manager.unusedWorkers.splice(index, 1)
4247
+ }
4248
+ // emnapi starts the termination and drops its promise.
4249
+ worker.terminate()
4250
+ // emnapi replaces the handler with a reporter that logs every message.
4251
+ worker.onmessage = () => {}
4252
+ },
4253
+ }
4254
+ return manager
4255
+ }
4256
+
4257
+ interface PoolReconcileState {
4258
+ manager?: ReturnType<typeof createPoolManagerStub> | Record<string, unknown>
4259
+ // What the addon's export returns; `undefined` = no such export.
4260
+ poolWorkers?: number | (() => number)
4261
+ noInstance?: boolean
4262
+ disposed?: boolean
4263
+ disposing?: boolean
4264
+ crashed?: boolean
4265
+ }
4266
+
4267
+ /**
4268
+ * Runs the threaded Node loader's pool reconcile, sliced out of the generated
4269
+ * code, against a stubbed instance and thread manager.
4270
+ */
4271
+ function createPoolReconcile(code: string, state: PoolReconcileState) {
4272
+ const reads = { count: 0 }
4273
+ const wasiWorkers = new Set<PoolWorkerStub>()
4274
+ if (state.manager && 'onCreate' in state.manager) {
4275
+ state.manager.onCreate = (worker) => wasiWorkers.add(worker)
4276
+ }
4277
+ const exports: Record<string, unknown> = {}
4278
+ if (state.poolWorkers !== undefined) {
4279
+ exports.napi_wasm_runtime_pool_workers = () => {
4280
+ reads.count++
4281
+ return typeof state.poolWorkers === 'function'
4282
+ ? state.poolWorkers()
4283
+ : state.poolWorkers
4284
+ }
4285
+ }
4286
+ const scope = new Function(
4287
+ 'state',
4288
+ 'instance',
4289
+ 'wasiWorkers',
4290
+ `
4291
+ const __wasiThreadPoolReconcileSymbol = Symbol.for('napi.rs.wasi.reconcileThreadPool')
4292
+ const __wasiWorkers = wasiWorkers
4293
+ const __napiInstance = instance
4294
+ let __wasiDisposed = Boolean(state.disposed)
4295
+ let __wasiDisposePromise = state.disposing ? Promise.resolve() : undefined
4296
+ function __hasWasiThreadCrashed() {
4297
+ return Boolean(state.crashed)
4298
+ }
4299
+ function __getWasiThreadManager() {
4300
+ return state.manager
4301
+ }
4302
+ ${generatedFunction(code, '__isThenable')}
4303
+ ${generatedFunction(code, '__createCleanupError')}
4304
+ ${generatedFunction(code, '__keepEventLoopAliveUntil')}
4305
+ ${generatedFunction(code, '__terminateWasiWorkers')}
4306
+ ${generatedFunction(code, '__removeWasiPoolWorker')}
4307
+ ${generatedFunction(code, '__untrackWasiWorkerOnExit')}
4308
+ ${generatedFunction(code, '__reconcileWasiThreadPool')}
4309
+ ${generatedFunction(code, '__publishWasiThreadPoolReconcile')}
4310
+ ${generatedFunction(code, '__wrapWasiConfigureAsyncRuntime')}
4311
+ return {
4312
+ reconcile: __reconcileWasiThreadPool,
4313
+ terminateWorkers: __terminateWasiWorkers,
4314
+ publish: __publishWasiThreadPoolReconcile,
4315
+ wrap: typeof __wrapWasiConfigureAsyncRuntime === 'function'
4316
+ ? __wrapWasiConfigureAsyncRuntime
4317
+ : undefined,
4318
+ }
4319
+ `,
4320
+ )
4321
+ const fns = scope(
4322
+ state,
4323
+ state.noInstance ? undefined : { exports },
4324
+ wasiWorkers,
4325
+ ) as {
4326
+ reconcile: () => void
4327
+ terminateWorkers: () => unknown
4328
+ publish: (exports: object) => void
4329
+ wrap?: (binding: Record<string, unknown>) => void
4330
+ }
4331
+ return { ...fns, reads, wasiWorkers }
4332
+ }
4333
+
4334
+ const threadedNodeLoader = () =>
4335
+ createWasiBinding('test', '@scope/test', 4000, 65536, true, 'wasm32-wasi')
4336
+ const threadedAsyncRuntimeNodeLoader = () =>
4337
+ createWasiBinding(
4338
+ 'test',
4339
+ '@scope/test',
4340
+ 4000,
4341
+ 65536,
4342
+ true,
4343
+ 'wasm32-wasi',
4344
+ 'test.wasm32-wasi',
4345
+ true,
4346
+ )
4347
+
4348
+ const ids = (workers: PoolWorkerStub[] | Set<PoolWorkerStub>) =>
4349
+ [...workers].map((worker) => worker.id)
4350
+
4351
+ test('node WASI pool reconcile preloads one loading Worker per configured worker', (t) => {
4352
+ const manager = createPoolManagerStub()
4353
+ const pool = createPoolReconcile(threadedNodeLoader(), {
4354
+ manager,
4355
+ poolWorkers: 3,
4356
+ })
4357
+ pool.reconcile()
4358
+ t.deepEqual(ids(manager.allocated), [0, 1, 2])
4359
+ t.deepEqual(manager.loaded, manager.allocated)
4360
+ t.deepEqual(manager.unusedWorkers, manager.allocated)
4361
+ t.deepEqual(ids(pool.wasiWorkers), [0, 1, 2])
4362
+ t.is(manager.terminated.length, 0)
4363
+ // The pool already matches: nothing more.
4364
+ pool.reconcile()
4365
+ t.is(manager.allocated.length, 3)
4366
+ t.is(pool.reads.count, 2)
4367
+ })
4368
+
4369
+ test('node WASI pool reconcile does nothing without the export, the instance or the manager', (t) => {
4370
+ const code = threadedNodeLoader()
4371
+ // An addon built without napi-async-runtime, or with an older one.
4372
+ const noExport = createPoolManagerStub()
4373
+ t.notThrows(() =>
4374
+ createPoolReconcile(code, { manager: noExport }).reconcile(),
4375
+ )
4376
+ t.is(noExport.allocated.length, 0)
4377
+ const noInstance = createPoolManagerStub()
4378
+ t.notThrows(() =>
4379
+ createPoolReconcile(code, {
4380
+ manager: noInstance,
4381
+ poolWorkers: 3,
4382
+ noInstance: true,
4383
+ }).reconcile(),
4384
+ )
4385
+ t.is(noInstance.allocated.length, 0)
4386
+ // No manager, or one without the pool API.
4387
+ const noManager = createPoolReconcile(code, { poolWorkers: 3 })
4388
+ t.notThrows(() => noManager.reconcile())
4389
+ t.notThrows(() =>
4390
+ createPoolReconcile(code, {
4391
+ manager: { unusedWorkers: [], terminateWorker() {} },
4392
+ poolWorkers: 3,
4393
+ }).reconcile(),
4394
+ )
4395
+ // An export that throws.
4396
+ const throwing = createPoolManagerStub()
4397
+ t.notThrows(() =>
4398
+ createPoolReconcile(code, {
4399
+ manager: throwing,
4400
+ poolWorkers: () => {
4401
+ throw new Error('export threw')
4402
+ },
4403
+ }).reconcile(),
4404
+ )
4405
+ t.is(throwing.allocated.length, 0)
4406
+ })
4407
+
4408
+ test('node WASI pool reconcile does nothing once disposed, while disposing or after a crash', (t) => {
4409
+ const code = threadedNodeLoader()
4410
+ for (const flag of ['disposed', 'disposing', 'crashed'] as const) {
4411
+ const manager = createPoolManagerStub()
4412
+ const pool = createPoolReconcile(code, {
4413
+ manager,
4414
+ poolWorkers: 3,
4415
+ [flag]: true,
4416
+ })
4417
+ pool.reconcile()
4418
+ t.is(pool.reads.count, 0, flag)
4419
+ t.is(manager.allocated.length, 0, flag)
4420
+ }
4421
+ })
4422
+
4423
+ test('node WASI pool reconcile terminates the idle Workers above the count, newest first', (t) => {
4424
+ for (const terminateRemoves of [false, true]) {
4425
+ const label = `terminateRemoves=${terminateRemoves}`
4426
+ const manager = createPoolManagerStub({ terminateRemoves })
4427
+ const state: PoolReconcileState = { manager, poolWorkers: 4 }
4428
+ const pool = createPoolReconcile(threadedNodeLoader(), state)
4429
+ pool.reconcile()
4430
+ // A spawn took the newest one; it is not in the pool any more.
4431
+ manager.unusedWorkers.pop()
4432
+ state.poolWorkers = 1
4433
+ pool.reconcile()
4434
+ t.deepEqual(ids(manager.terminated), [2, 1], label)
4435
+ t.deepEqual(ids(manager.unusedWorkers), [0], label)
4436
+ t.deepEqual(ids(pool.wasiWorkers), [0, 3], label)
4437
+ // The reporter `terminateWorker` installed is gone again.
4438
+ for (const worker of manager.terminated) {
4439
+ t.is(worker.onmessage, undefined, label)
4440
+ }
4441
+ t.is(manager.allocated.length, 4, label)
4442
+
4443
+ state.poolWorkers = 0
4444
+ pool.reconcile()
4445
+ t.deepEqual(ids(manager.terminated), [2, 1, 0], label)
4446
+ t.deepEqual(manager.unusedWorkers, [], label)
4447
+ }
4448
+ })
4449
+
4450
+ test('node WASI pool reconcile keeps a terminated Worker tracked until it has exited', async (t) => {
4451
+ const manager = createPoolManagerStub({ asyncExit: true })
4452
+ const state: PoolReconcileState = { manager, poolWorkers: 2 }
4453
+ const pool = createPoolReconcile(threadedNodeLoader(), state)
4454
+ pool.reconcile()
4455
+ state.poolWorkers = 1
4456
+ pool.reconcile()
4457
+ const [shrunk] = manager.terminated
4458
+ t.is(shrunk.id, 1)
4459
+ t.deepEqual(ids(manager.unusedWorkers), [0])
4460
+ // Its termination is still under way: disposal has to find it.
4461
+ t.deepEqual(ids(pool.wasiWorkers), [0, 1])
4462
+
4463
+ // A disposal right after the shrink waits for that exit too.
4464
+ const order: string[] = []
4465
+ const terminated = Promise.resolve(pool.terminateWorkers()).then(() => {
4466
+ order.push('disposed')
4467
+ })
4468
+ manager.allocated[0].exit()
4469
+ await new Promise((resolve) => setTimeout(resolve, 0))
4470
+ t.deepEqual(order, [])
4471
+ order.push('exit')
4472
+ shrunk.exit()
4473
+ await terminated
4474
+ t.deepEqual(order, ['exit', 'disposed'])
4475
+ t.deepEqual(ids(pool.wasiWorkers), [])
4476
+
4477
+ // Without a disposal, the exit alone takes it out of the set.
4478
+ const alone = createPoolManagerStub({ asyncExit: true })
4479
+ const aloneState: PoolReconcileState = { manager: alone, poolWorkers: 1 }
4480
+ const alonePool = createPoolReconcile(threadedNodeLoader(), aloneState)
4481
+ alonePool.reconcile()
4482
+ aloneState.poolWorkers = 0
4483
+ alonePool.reconcile()
4484
+ t.deepEqual(ids(alonePool.wasiWorkers), [0])
4485
+ alone.allocated[0].exit()
4486
+ await new Promise((resolve) => setTimeout(resolve, 0))
4487
+ t.deepEqual(ids(alonePool.wasiWorkers), [])
4488
+ })
4489
+
4490
+ test('node WASI pool reconcile drops a Worker whose load rejects', async (t) => {
4491
+ const manager = createPoolManagerStub({
4492
+ load: (worker) =>
4493
+ worker.id === 1
4494
+ ? Promise.reject(new Error('load failed'))
4495
+ : Promise.resolve(worker),
4496
+ })
4497
+ createPoolReconcile(threadedNodeLoader(), {
4498
+ manager,
4499
+ poolWorkers: 3,
4500
+ }).reconcile()
4501
+ t.deepEqual(ids(manager.unusedWorkers), [0, 1, 2])
4502
+ await new Promise((resolve) => setTimeout(resolve, 0))
4503
+ t.deepEqual(ids(manager.unusedWorkers), [0, 2])
4504
+ })
4505
+
4506
+ test('node WASI pool reconcile stops at a synchronous failure', (t) => {
4507
+ // The load throws: that Worker is terminated and taken out of both sets.
4508
+ const loadThrows = createPoolManagerStub({
4509
+ load: (worker) => {
4510
+ if (worker.id === 1) throw new Error('load threw')
4511
+ return Promise.resolve(worker)
4512
+ },
4513
+ })
4514
+ const pool = createPoolReconcile(threadedNodeLoader(), {
4515
+ manager: loadThrows,
4516
+ poolWorkers: 3,
4517
+ })
4518
+ t.notThrows(() => pool.reconcile())
4519
+ t.deepEqual(ids(loadThrows.allocated), [0, 1])
4520
+ t.deepEqual(ids(loadThrows.unusedWorkers), [0])
4521
+ t.deepEqual(ids(loadThrows.terminated), [1])
4522
+ t.deepEqual(ids(pool.wasiWorkers), [0])
4523
+
4524
+ // The allocation throws: there is no Worker to clean up.
4525
+ const allocateThrows = createPoolManagerStub({ allocateThrowsAt: 1 })
4526
+ t.notThrows(() =>
4527
+ createPoolReconcile(threadedNodeLoader(), {
4528
+ manager: allocateThrows,
4529
+ poolWorkers: 3,
4530
+ }).reconcile(),
4531
+ )
4532
+ t.deepEqual(ids(allocateThrows.allocated), [0])
4533
+ t.deepEqual(ids(allocateThrows.unusedWorkers), [0])
4534
+ t.is(allocateThrows.terminated.length, 0)
4535
+ })
4536
+
4537
+ test('node WASI pool reconcile keeps a Worker whose load threw tracked until it has exited', async (t) => {
4538
+ const manager = createPoolManagerStub({
4539
+ asyncExit: true,
4540
+ load: (worker) => {
4541
+ if (worker.id === 1) throw new Error('load threw')
4542
+ return Promise.resolve(worker)
4543
+ },
4544
+ })
4545
+ const pool = createPoolReconcile(threadedNodeLoader(), {
4546
+ manager,
4547
+ poolWorkers: 3,
4548
+ })
4549
+ t.notThrows(() => pool.reconcile())
4550
+ const [threw] = manager.terminated
4551
+ t.is(threw.id, 1)
4552
+ t.deepEqual(ids(manager.unusedWorkers), [0])
4553
+ // Its termination is still under way: disposal has to find it.
4554
+ t.deepEqual(ids(pool.wasiWorkers), [0, 1])
4555
+
4556
+ const order: string[] = []
4557
+ const terminated = Promise.resolve(pool.terminateWorkers()).then(() => {
4558
+ order.push('disposed')
4559
+ })
4560
+ manager.allocated[0].exit()
4561
+ await new Promise((resolve) => setTimeout(resolve, 0))
4562
+ t.deepEqual(order, [])
4563
+ order.push('exit')
4564
+ threw.exit()
4565
+ await terminated
4566
+ t.deepEqual(order, ['exit', 'disposed'])
4567
+ t.deepEqual(ids(pool.wasiWorkers), [])
4568
+ })
4569
+
4570
+ test('node WASI pool reconcile is published as a non-enumerable, read-only symbol', (t) => {
4571
+ const pool = createPoolReconcile(threadedNodeLoader(), {})
4572
+ const exports = { other: 1 }
4573
+ pool.publish(exports)
4574
+ const descriptor = Object.getOwnPropertyDescriptor(
4575
+ exports,
4576
+ Symbol.for('napi.rs.wasi.reconcileThreadPool'),
4577
+ )
4578
+ t.is(descriptor?.value, pool.reconcile)
4579
+ t.false(descriptor?.enumerable)
4580
+ t.false(descriptor?.writable)
4581
+ t.false(descriptor?.configurable)
4582
+ t.deepEqual(Object.keys(exports), ['other'])
4583
+ })
4584
+
4585
+ test('node WASI configureAsyncRuntime wrapper reconciles only after a successful configure', (t) => {
4586
+ const manager = createPoolManagerStub()
4587
+ const state: PoolReconcileState = { manager, poolWorkers: 0 }
4588
+ const pool = createPoolReconcile(threadedAsyncRuntimeNodeLoader(), state)
4589
+ t.truthy(pool.wrap)
4590
+
4591
+ const result = { configured: true }
4592
+ const calls: Array<{ self: unknown; args: unknown[] }> = []
4593
+ const binding: Record<string, unknown> = {
4594
+ configureAsyncRuntime(this: unknown, ...args: unknown[]) {
4595
+ calls.push({ self: this, args })
4596
+ if ((args[0] as { fail?: boolean }).fail) {
4597
+ // The count it would read changed, but the configure failed.
4598
+ state.poolWorkers = 5
4599
+ throw new Error('the runtime already started')
4600
+ }
4601
+ state.poolWorkers = (args[0] as { workerThreads: number }).workerThreads
4602
+ return result
4603
+ },
4604
+ }
4605
+ pool.wrap!(binding)
4606
+ const configure = binding.configureAsyncRuntime as (
4607
+ ...args: unknown[]
4608
+ ) => unknown
4609
+ t.is(configure.name, 'configureAsyncRuntime')
4610
+
4611
+ t.is(configure.call(binding, { workerThreads: 2 }, 'extra'), result)
4612
+ t.deepEqual(calls, [{ self: binding, args: [{ workerThreads: 2 }, 'extra'] }])
4613
+ t.is(manager.allocated.length, 2)
4614
+
4615
+ t.throws(() => configure.call(binding, { fail: true }), {
4616
+ message: 'the runtime already started',
4617
+ })
4618
+ t.is(manager.allocated.length, 2, 'a failed configure skips the reconcile')
4619
+ t.is(pool.reads.count, 1)
4620
+
4621
+ // No export to wrap, or one that throws when read: nothing changes.
4622
+ const empty: Record<string, unknown> = {}
4623
+ t.notThrows(() => pool.wrap!(empty))
4624
+ t.deepEqual(empty, {})
4625
+ const guarded = Object.defineProperty({}, 'configureAsyncRuntime', {
4626
+ get() {
4627
+ throw new Error('getter threw')
4628
+ },
4629
+ })
4630
+ t.notThrows(() => pool.wrap!(guarded))
4631
+ })
4632
+
4633
+ test('node WASI loader wires the pool reconcile into the threaded load only', (t) => {
4634
+ for (const asyncRuntime of [false, true]) {
4635
+ const label = `asyncRuntime=${asyncRuntime}`
4636
+ const code = asyncRuntime
4637
+ ? threadedAsyncRuntimeNodeLoader()
4638
+ : threadedNodeLoader()
4639
+ assertValidJS(t, code, `threaded node ${label}`)
4640
+ t.true(code.includes('napi_wasm_runtime_pool_workers'), label)
4641
+ const publishDispose = code.indexOf(
4642
+ ' __publishWasiDispose(__napiModule.exports)\n',
4643
+ )
4644
+ const publish = code.indexOf(
4645
+ ' __publishWasiThreadPoolReconcile(__napiModule.exports)\n',
4646
+ )
4647
+ t.true(publish > publishDispose, label)
4648
+ const initializationCatch = code.indexOf('\n} catch (error) {')
4649
+ t.true(publish < initializationCatch, label)
4650
+ // The load-time reconcile is the last thing the template emits: after the
4651
+ // load succeeded, before the export tail build.ts appends.
4652
+ t.true(
4653
+ code.endsWith(
4654
+ ` throw rollback.error
4655
+ }
4656
+ // Preload the pool for the count the addon configured during registration.
4657
+ // See \`__reconcileWasiThreadPool\`.
4658
+ try {
4659
+ __reconcileWasiThreadPool()
4660
+ } catch {}
4661
+ `,
4662
+ ),
4663
+ label,
4664
+ )
4665
+ const wrapCall = code.indexOf(
4666
+ ' __wrapWasiConfigureAsyncRuntime(__napiModule.exports)\n',
4667
+ )
4668
+ if (asyncRuntime) {
4669
+ // After the host install, inside the initialization try.
4670
+ t.true(wrapCall > code.indexOf('__installCurrentThreadHosts('), label)
4671
+ t.true(wrapCall < code.indexOf(WASI_CJS_STAMP_LINE), label)
4672
+ } else {
4673
+ t.is(wrapCall, -1, label)
4674
+ t.false(code.includes('function __wrapWasiConfigureAsyncRuntime('))
4675
+ }
4676
+ }
4677
+
4678
+ const threadless = [false, true].map((asyncRuntime) =>
4679
+ createWasiBinding(
4680
+ 'test',
4681
+ '@scope/test',
4682
+ 4000,
4683
+ 65536,
4684
+ false,
4685
+ 'wasm32-wasip1',
4686
+ 'test.wasm32-wasip1',
4687
+ asyncRuntime,
4688
+ ),
4689
+ )
4690
+ for (const code of threadless) {
4691
+ assertValidJS(t, code, 'threadless node')
4692
+ }
4693
+ const others = [
4694
+ ...threadless,
4695
+ ...[false, true].flatMap((threads) =>
4696
+ [false, true].map((asyncRuntime) =>
4697
+ createWasiBrowserBinding(
4698
+ 'test',
4699
+ 4000,
4700
+ 65536,
4701
+ false,
4702
+ false,
4703
+ false,
4704
+ false,
4705
+ threads,
4706
+ undefined,
4707
+ asyncRuntime,
4708
+ ),
4709
+ ),
4710
+ ),
4711
+ createWasiDeferredBrowserBinding('test'),
4712
+ ]
4713
+ for (const code of others) {
4714
+ t.false(code.includes('napi_wasm_runtime_pool_workers'))
4715
+ t.false(code.includes('reconcileThreadPool'))
4716
+ t.false(code.includes('__wrapWasiConfigureAsyncRuntime'))
4717
+ }
4718
+ })