@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.
- package/dist/cli.js +199 -4
- package/dist/index.cjs +199 -4
- package/dist/index.d.cts +1 -1
- package/dist/index.js +199 -4
- package/docs/wasi.md +68 -0
- package/package.json +1 -1
- package/src/api/__tests__/templates.spec.ts +692 -5
- package/src/api/templates/load-wasi-template.ts +213 -1
|
@@ -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 {
|
|
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(
|
|
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({
|
|
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
|
+
})
|