@napi-rs/cli 3.10.6 → 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.
@@ -1450,17 +1450,25 @@ const NESTED_DESTROY_NO_OP = ` if (isPreparingEnvCleanup?.()) {
1450
1450
  }
1451
1451
  prepareEnvCleanup?.()`
1452
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
+
1453
1459
  const wrappedContextCreationCases: Array<{
1454
1460
  name: string
1455
1461
  code: string
1456
1462
  prepare: string
1457
1463
  guard: (typeof preparingBarrierGuards)[keyof typeof preparingBarrierGuards]
1464
+ contextOptions?: string
1458
1465
  }> = [
1459
1466
  {
1460
1467
  name: 'node cjs',
1461
1468
  code: createWasiBinding('test', '@scope/test'),
1462
1469
  prepare: '__prepareWasmEnvCleanup',
1463
1470
  guard: preparingBarrierGuards.shared,
1471
+ contextOptions: THREADED_NODE_CONTEXT_OPTIONS,
1464
1472
  },
1465
1473
  {
1466
1474
  name: 'node cjs threadless',
@@ -1482,7 +1490,13 @@ const wrappedContextCreationCases: Array<{
1482
1490
  },
1483
1491
  ]
1484
1492
 
1485
- 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) {
1486
1500
  test(`WASI loader runs the barrier on a raw context.destroy(): ${name}`, (t) => {
1487
1501
  assertValidJS(t, code, name)
1488
1502
  t.is(
@@ -1493,16 +1507,17 @@ for (const { name, code, prepare, guard } of wrappedContextCreationCases) {
1493
1507
  // No unwrapped context may escape: there is exactly one createContext call
1494
1508
  // and it is the wrapper's argument.
1495
1509
  t.is(
1496
- code.split('__emnapiCreateContext({ autoDestroy: false })').length - 1,
1510
+ code.split('__emnapiCreateContext(').length - 1,
1497
1511
  1,
1498
1512
  'loader must create exactly one emnapi context',
1499
1513
  )
1514
+ t.true(code.includes(`__emnapiCreateContext(${contextOptions})`))
1500
1515
  t.true(
1501
1516
  code
1502
1517
  .replace(/\s+/g, ' ')
1503
1518
  .includes(
1504
1519
  '__emnapiContext = __wrapEmnapiContextDestroyForSettlement( ' +
1505
- `__emnapiCreateContext({ autoDestroy: false }), ${prepare}, ${guard.probe}, )`,
1520
+ `__emnapiCreateContext(${contextOptions}), ${prepare}, ${guard.probe}, )`,
1506
1521
  ),
1507
1522
  'the createContext result must be wrapped before anything can reach it',
1508
1523
  )
@@ -2305,6 +2320,7 @@ let __wasiExitListenerRegistered = true
2305
2320
  let __wasiDisposed = false
2306
2321
  let __wasiDisposePromise
2307
2322
  let __wasiThreadCrashDisposePromise
2323
+ let __wasiReentryClosed = false
2308
2324
  // The loader sets this to __removeWasiExitListener.
2309
2325
  let __completeWasiDisposal = function () {
2310
2326
  steps.push('complete')
@@ -2469,7 +2485,7 @@ test('node WASI crash disposal releases the waiting-request port', async (t) =>
2469
2485
  t.true(release.length > 0)
2470
2486
  t.true(
2471
2487
  generatedFunction(code, '__disposeWasiBindingAfterThreadCrash').includes(
2472
- ' __releaseEmnapiWaitingRequestHandle()\n let workerResult\n',
2488
+ ' __releaseEmnapiWaitingRequestHandle()\n // No call into wasm after this: see `__wasiSetImmediate`.\n __wasiReentryClosed = true\n let workerResult\n',
2473
2489
  ),
2474
2490
  'released before the workers are terminated',
2475
2491
  )
@@ -2531,7 +2547,7 @@ test('node WASI crash paths release the CurrentThread host timers', async (t) =>
2531
2547
  )
2532
2548
  t.true(
2533
2549
  generatedFunction(code, '__disposeWasiBindingAfterThreadCrash').includes(
2534
- ' __releaseEmnapiWaitingRequestHandle()\n __releaseCurrentThreadHostTimers()\n let workerResult\n',
2550
+ ' __releaseEmnapiWaitingRequestHandle()\n __releaseCurrentThreadHostTimers()\n // No call into wasm after this: see `__wasiSetImmediate`.\n __wasiReentryClosed = true\n let workerResult\n',
2535
2551
  ),
2536
2552
  'released before the workers are terminated',
2537
2553
  )
@@ -2581,6 +2597,135 @@ test('node WASI crash paths release the CurrentThread host timers', async (t) =>
2581
2597
  }
2582
2598
  })
2583
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
+
2584
2729
  test('node WASI crash release clears the host timeouts without settling them', async (t) => {
2585
2730
  const code = threadedAsyncRuntimeCode
2586
2731
  const stateStart = code.indexOf('const __currentThreadHostTimerCancels = ')
@@ -2788,6 +2933,7 @@ function createInflightWasiDisposal(
2788
2933
  let __wasiDisposed = false
2789
2934
  let __wasiDisposePromise
2790
2935
  let __wasiThreadCrashDisposePromise
2936
+ let __wasiReentryClosed = false
2791
2937
  let __wasiAsyncWorkDrainPromise
2792
2938
  let __emnapiWasmEnvCleanupPrepared = false
2793
2939
  let __emnapiWasmEnvCleanupPreparing = false
@@ -4029,3 +4175,544 @@ test('WASI worker hands its error to the loader through the shared crash report'
4029
4175
  t.true(hugeCause.message.length < 10_000)
4030
4176
  t.is(huge.crashError().workerThreadId, 4)
4031
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
+ })