@napi-rs/cli 3.9.1 → 3.10.1
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/README.md +17 -4
- package/dist/cli.js +1594 -180
- package/dist/index.cjs +1594 -180
- package/dist/index.d.cts +69 -2
- package/dist/index.d.ts +69 -2
- package/dist/index.js +1594 -180
- package/docs/wasi.md +335 -4
- package/package.json +3 -3
- package/src/api/__tests__/__snapshots__/templates.spec.ts.md +4361 -15
- package/src/api/__tests__/__snapshots__/templates.spec.ts.snap +0 -0
- package/src/api/__tests__/build-regressions.spec.ts +210 -3
- package/src/api/__tests__/build.spec.ts +2139 -3
- package/src/api/__tests__/create-npm-dirs.spec.ts +101 -0
- package/src/api/__tests__/pre-publish.spec.ts +153 -1
- package/src/api/__tests__/templates.spec.ts +1352 -0
- package/src/api/build.ts +637 -31
- package/src/api/create-npm-dirs.ts +21 -9
- package/src/api/pre-publish.ts +29 -1
- package/src/api/templates/binding-target.ts +176 -0
- package/src/api/templates/index.ts +1 -0
- package/src/api/templates/js-binding.ts +48 -3
- package/src/api/templates/load-wasi-template.ts +747 -45
- package/src/utils/__tests__/reconciliation.spec.ts +683 -0
- package/src/utils/config.ts +50 -0
- package/src/utils/misc.ts +347 -74
- package/src/utils/typegen.ts +367 -12
|
@@ -1,11 +1,16 @@
|
|
|
1
|
+
import { dirname } from 'node:path'
|
|
2
|
+
import { fileURLToPath } from 'node:url'
|
|
3
|
+
|
|
1
4
|
import ava, { type ExecutionContext } from 'ava'
|
|
2
5
|
import { parseSync } from 'oxc-parser'
|
|
6
|
+
import ts from 'typescript'
|
|
3
7
|
|
|
4
8
|
import { createCjsBinding, createEsmBinding } from '../templates/js-binding.js'
|
|
5
9
|
import {
|
|
6
10
|
createWasiBinding,
|
|
7
11
|
createWasiBrowserBinding,
|
|
8
12
|
createWasiDeferredBrowserBinding,
|
|
13
|
+
createWasiDeferredBrowserBindingTypeDef,
|
|
9
14
|
} from '../templates/load-wasi-template.js'
|
|
10
15
|
import {
|
|
11
16
|
createWasiBrowserWorkerBinding,
|
|
@@ -14,6 +19,8 @@ import {
|
|
|
14
19
|
|
|
15
20
|
const test = ava
|
|
16
21
|
|
|
22
|
+
const __dirname = dirname(fileURLToPath(import.meta.url))
|
|
23
|
+
|
|
17
24
|
// Snapshot tests for full template output
|
|
18
25
|
|
|
19
26
|
test('createWasiBrowserBinding default', (t) => {
|
|
@@ -68,6 +75,63 @@ test('createWasiBrowserBinding threadless keeps sync init and no pool', (t) => {
|
|
|
68
75
|
t.true(binding.includes('__emnapiInstantiateNapiModuleSync(__wasmFile'))
|
|
69
76
|
})
|
|
70
77
|
|
|
78
|
+
test('threadless loaders embed the initial memory they are given', (t) => {
|
|
79
|
+
const unshared = `new WebAssembly.Memory({
|
|
80
|
+
initial: 1027,
|
|
81
|
+
maximum: 65536,
|
|
82
|
+
})`
|
|
83
|
+
|
|
84
|
+
const nodeLoader = createWasiBinding(
|
|
85
|
+
'test',
|
|
86
|
+
'@scope/test',
|
|
87
|
+
1027,
|
|
88
|
+
65536,
|
|
89
|
+
false,
|
|
90
|
+
)
|
|
91
|
+
t.true(nodeLoader.includes(`const __wasmMemory = ${unshared}`))
|
|
92
|
+
t.false(nodeLoader.includes('shared: true'))
|
|
93
|
+
|
|
94
|
+
const browserLoader = createWasiBrowserBinding(
|
|
95
|
+
'test-wasi',
|
|
96
|
+
1027,
|
|
97
|
+
65536,
|
|
98
|
+
false,
|
|
99
|
+
false,
|
|
100
|
+
false,
|
|
101
|
+
false,
|
|
102
|
+
false,
|
|
103
|
+
)
|
|
104
|
+
t.true(browserLoader.includes(`const __wasmMemory = ${unshared}`))
|
|
105
|
+
t.false(browserLoader.includes('shared: true'))
|
|
106
|
+
|
|
107
|
+
// The deferred loader publishes its descriptor instead of inlining it, and
|
|
108
|
+
// allocates from it in function scope (workerd bans global scope allocation).
|
|
109
|
+
const deferred = createWasiDeferredBrowserBinding('test', 1027, 65536)
|
|
110
|
+
t.true(
|
|
111
|
+
deferred.includes(`export const WASM_MEMORY = Object.freeze({
|
|
112
|
+
initialPages: 1027,
|
|
113
|
+
maximumPages: 65536,`),
|
|
114
|
+
)
|
|
115
|
+
t.true(
|
|
116
|
+
deferred.includes(` const __allocated = new WebAssembly.Memory({
|
|
117
|
+
initial:
|
|
118
|
+
__options != null && __options.initialMemoryPages !== undefined
|
|
119
|
+
? __options.initialMemoryPages
|
|
120
|
+
: WASM_MEMORY.initialPages,`),
|
|
121
|
+
)
|
|
122
|
+
t.false(deferred.includes('shared: true'))
|
|
123
|
+
|
|
124
|
+
// the threaded loader still gets its own, shared descriptor
|
|
125
|
+
const threaded = createWasiBinding('test', '@scope/test', 16384, 65536, true)
|
|
126
|
+
t.true(
|
|
127
|
+
threaded.includes(`const __sharedMemory = new WebAssembly.Memory({
|
|
128
|
+
initial: 16384,
|
|
129
|
+
maximum: 65536,
|
|
130
|
+
shared: true,
|
|
131
|
+
})`),
|
|
132
|
+
)
|
|
133
|
+
})
|
|
134
|
+
|
|
71
135
|
test('createWasiBrowserBinding with errorEvent', (t) => {
|
|
72
136
|
t.snapshot(
|
|
73
137
|
createWasiBrowserBinding(
|
|
@@ -96,6 +160,47 @@ test('createWasiBrowserBinding with errorEvent and fs', (t) => {
|
|
|
96
160
|
)
|
|
97
161
|
})
|
|
98
162
|
|
|
163
|
+
test('createWasiBrowserBinding with asyncRuntime hosts', (t) => {
|
|
164
|
+
t.snapshot(
|
|
165
|
+
createWasiBrowserBinding(
|
|
166
|
+
'test-wasi',
|
|
167
|
+
4000,
|
|
168
|
+
65536,
|
|
169
|
+
false,
|
|
170
|
+
false,
|
|
171
|
+
false,
|
|
172
|
+
false,
|
|
173
|
+
false,
|
|
174
|
+
'wasm32-wasip1',
|
|
175
|
+
true,
|
|
176
|
+
),
|
|
177
|
+
)
|
|
178
|
+
})
|
|
179
|
+
|
|
180
|
+
// The deferred loader is the published `./workerd` entry and the only loader
|
|
181
|
+
// whose module namespace is part of the package's public API, so its bytes get
|
|
182
|
+
// the same snapshot treatment as the browser loaders'.
|
|
183
|
+
test('createWasiDeferredBrowserBinding default', (t) => {
|
|
184
|
+
t.snapshot(createWasiDeferredBrowserBinding('test-wasi'))
|
|
185
|
+
})
|
|
186
|
+
|
|
187
|
+
test('createWasiDeferredBrowserBinding with async-runtime hosts and buffer', (t) => {
|
|
188
|
+
t.snapshot(
|
|
189
|
+
createWasiDeferredBrowserBinding(
|
|
190
|
+
'test-wasi',
|
|
191
|
+
1027,
|
|
192
|
+
65536,
|
|
193
|
+
true,
|
|
194
|
+
'wasm32-wasip1',
|
|
195
|
+
true,
|
|
196
|
+
),
|
|
197
|
+
)
|
|
198
|
+
})
|
|
199
|
+
|
|
200
|
+
test('createWasiDeferredBrowserBindingTypeDef', (t) => {
|
|
201
|
+
t.snapshot(createWasiDeferredBrowserBindingTypeDef('./test-wasi.wasip1.cjs'))
|
|
202
|
+
})
|
|
203
|
+
|
|
99
204
|
test('createWasiBrowserWorkerBinding default', (t) => {
|
|
100
205
|
t.snapshot(createWasiBrowserWorkerBinding(false, false))
|
|
101
206
|
})
|
|
@@ -143,6 +248,36 @@ const browserBindingCases: Array<{
|
|
|
143
248
|
args: ['test', 4000, 65536, false, true, false, true],
|
|
144
249
|
},
|
|
145
250
|
{ name: 'all options', args: ['test', 4000, 65536, true, true, true, true] },
|
|
251
|
+
{
|
|
252
|
+
name: 'asyncRuntime',
|
|
253
|
+
args: [
|
|
254
|
+
'test',
|
|
255
|
+
4000,
|
|
256
|
+
65536,
|
|
257
|
+
false,
|
|
258
|
+
false,
|
|
259
|
+
false,
|
|
260
|
+
false,
|
|
261
|
+
true,
|
|
262
|
+
'wasm32-wasi',
|
|
263
|
+
true,
|
|
264
|
+
],
|
|
265
|
+
},
|
|
266
|
+
{
|
|
267
|
+
name: 'all options + asyncRuntime',
|
|
268
|
+
args: [
|
|
269
|
+
'test',
|
|
270
|
+
4000,
|
|
271
|
+
65536,
|
|
272
|
+
true,
|
|
273
|
+
true,
|
|
274
|
+
true,
|
|
275
|
+
true,
|
|
276
|
+
true,
|
|
277
|
+
'wasm32-wasi',
|
|
278
|
+
true,
|
|
279
|
+
],
|
|
280
|
+
},
|
|
146
281
|
]
|
|
147
282
|
|
|
148
283
|
for (const { name, args } of browserBindingCases) {
|
|
@@ -221,6 +356,69 @@ for (const { name, code } of cjsBindingCases) {
|
|
|
221
356
|
// emitted flavors it builds (node cjs threadless, deferred/workerd)
|
|
222
357
|
// behaviorally; the other flavors are never instantiated by any test, so assert
|
|
223
358
|
// the shape here as well.
|
|
359
|
+
// `napi.wasm.asyncRuntime` output. The seven-export / contract-v4 / liveness /
|
|
360
|
+
// rollback checks all live in `@napi-rs/async-runtime`; the loader only has to
|
|
361
|
+
// call it at the right moment and tear it down at the right moment.
|
|
362
|
+
const asyncRuntimeLoaderCases: Array<{
|
|
363
|
+
name: string
|
|
364
|
+
code: string
|
|
365
|
+
install: string
|
|
366
|
+
}> = [
|
|
367
|
+
{
|
|
368
|
+
name: 'node cjs',
|
|
369
|
+
code: createWasiBinding(
|
|
370
|
+
'test',
|
|
371
|
+
'@scope/test',
|
|
372
|
+
4000,
|
|
373
|
+
65536,
|
|
374
|
+
true,
|
|
375
|
+
'wasm32-wasi',
|
|
376
|
+
'test',
|
|
377
|
+
true,
|
|
378
|
+
),
|
|
379
|
+
install: "require('@napi-rs/async-runtime')",
|
|
380
|
+
},
|
|
381
|
+
{
|
|
382
|
+
name: 'node cjs threadless',
|
|
383
|
+
code: createWasiBinding(
|
|
384
|
+
'test',
|
|
385
|
+
'@scope/test',
|
|
386
|
+
4000,
|
|
387
|
+
65536,
|
|
388
|
+
false,
|
|
389
|
+
'wasm32-wasip1',
|
|
390
|
+
'test',
|
|
391
|
+
true,
|
|
392
|
+
),
|
|
393
|
+
install: "require('@napi-rs/async-runtime')",
|
|
394
|
+
},
|
|
395
|
+
{
|
|
396
|
+
name: 'browser esm',
|
|
397
|
+
code: createWasiBrowserBinding(
|
|
398
|
+
'test',
|
|
399
|
+
4000,
|
|
400
|
+
65536,
|
|
401
|
+
false,
|
|
402
|
+
false,
|
|
403
|
+
false,
|
|
404
|
+
false,
|
|
405
|
+
true,
|
|
406
|
+
'wasm32-wasi',
|
|
407
|
+
true,
|
|
408
|
+
),
|
|
409
|
+
install: "from '@napi-rs/async-runtime'",
|
|
410
|
+
},
|
|
411
|
+
]
|
|
412
|
+
|
|
413
|
+
const asyncRuntimeDeferredCode = createWasiDeferredBrowserBinding(
|
|
414
|
+
'test',
|
|
415
|
+
1024,
|
|
416
|
+
65536,
|
|
417
|
+
false,
|
|
418
|
+
'wasm32-wasip1',
|
|
419
|
+
true,
|
|
420
|
+
)
|
|
421
|
+
|
|
224
422
|
const wasiLoaderCases: Array<{ name: string; code: string }> = [
|
|
225
423
|
{ name: 'node cjs', code: createWasiBinding('test', '@scope/test') },
|
|
226
424
|
{
|
|
@@ -229,8 +427,498 @@ const wasiLoaderCases: Array<{ name: string; code: string }> = [
|
|
|
229
427
|
},
|
|
230
428
|
{ name: 'browser esm', code: createWasiBrowserBinding('test') },
|
|
231
429
|
{ name: 'deferred/workerd', code: createWasiDeferredBrowserBinding('test') },
|
|
430
|
+
...asyncRuntimeLoaderCases.map(({ name, code }) => ({
|
|
431
|
+
name: `${name} + asyncRuntime`,
|
|
432
|
+
code,
|
|
433
|
+
})),
|
|
434
|
+
{
|
|
435
|
+
name: 'deferred/workerd + asyncRuntime',
|
|
436
|
+
code: asyncRuntimeDeferredCode,
|
|
437
|
+
},
|
|
232
438
|
]
|
|
233
439
|
|
|
440
|
+
// The flag is off by default, so every existing generated loader keeps its
|
|
441
|
+
// current bytes when a project bumps the CLI. The six template snapshots are
|
|
442
|
+
// the byte-level proof; this is the cheap named regression net.
|
|
443
|
+
for (const { name, code } of [
|
|
444
|
+
{ name: 'node cjs', code: createWasiBinding('test', '@scope/test') },
|
|
445
|
+
{ name: 'browser esm', code: createWasiBrowserBinding('test') },
|
|
446
|
+
{ name: 'deferred/workerd', code: createWasiDeferredBrowserBinding('test') },
|
|
447
|
+
]) {
|
|
448
|
+
test(`asyncRuntime is off by default: ${name}`, (t) => {
|
|
449
|
+
t.false(code.includes('@napi-rs/async-runtime'))
|
|
450
|
+
t.false(code.includes('__disposeCurrentThreadHosts'))
|
|
451
|
+
t.false(code.includes('installCurrentThreadHosts'))
|
|
452
|
+
})
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
for (const { name, code, install } of asyncRuntimeLoaderCases) {
|
|
456
|
+
test(`asyncRuntime loader installs and tears down the hosts: ${name}`, (t) => {
|
|
457
|
+
assertValidJS(t, code, name)
|
|
458
|
+
t.true(code.includes(install))
|
|
459
|
+
// The package owns the seven-export / contract-v4 / liveness / rollback
|
|
460
|
+
// checks; the loader must not re-implement any of them.
|
|
461
|
+
t.true(code.includes('__installCurrentThreadHosts('))
|
|
462
|
+
t.false(code.includes('reserveCurrentThreadHostRegistration'))
|
|
463
|
+
t.false(code.includes('getCurrentThreadTaskHostContractVersion'))
|
|
464
|
+
|
|
465
|
+
// Install runs after the dispose symbol is published and INSIDE the try,
|
|
466
|
+
// so a mismatch throw reaches the existing rollback.
|
|
467
|
+
const installIndex = code.indexOf(
|
|
468
|
+
'__currentThreadHostsDisposer = __installCurrentThreadHosts(',
|
|
469
|
+
)
|
|
470
|
+
t.true(
|
|
471
|
+
installIndex > code.indexOf('__publishWasiDispose(__napiModule.exports)'),
|
|
472
|
+
)
|
|
473
|
+
t.true(
|
|
474
|
+
installIndex < code.indexOf('\n} catch (error) {\n', installIndex - 1),
|
|
475
|
+
)
|
|
476
|
+
|
|
477
|
+
// Teardown runs before the context is destroyed, on every path, because
|
|
478
|
+
// __destroyEmnapiContext is the single funnel dispose()/rollback/'exit'
|
|
479
|
+
// all reach.
|
|
480
|
+
const destroyBody = code.slice(
|
|
481
|
+
code.indexOf('function __destroyEmnapiContext() {'),
|
|
482
|
+
code.indexOf('function __terminateWasiWorkers() {'),
|
|
483
|
+
)
|
|
484
|
+
t.true(destroyBody.includes('__disposeCurrentThreadHosts()'))
|
|
485
|
+
t.true(
|
|
486
|
+
destroyBody.indexOf('__disposeCurrentThreadHosts()') <
|
|
487
|
+
destroyBody.indexOf('__emnapiContext.destroy()'),
|
|
488
|
+
)
|
|
489
|
+
// …and after the drain: __startWasiDisposal prepares + drains before it
|
|
490
|
+
// ever calls __continueWasiDisposal -> __destroyEmnapiContext.
|
|
491
|
+
t.false(destroyBody.includes('__drainWasmEnvCleanup'))
|
|
492
|
+
})
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
// `@emnapi/wasi-threads` records a worker exit as expected only when ITS thread
|
|
496
|
+
// manager performed the termination. A bare `worker.terminate()` reaches the
|
|
497
|
+
// manager's own 'exit' listener, which reports the exit as a worker failure and
|
|
498
|
+
// rethrows inside the emit — aborting the `once('exit')` that backs the
|
|
499
|
+
// terminate promise, so `dispose()` never settles.
|
|
500
|
+
for (const { name, code } of [
|
|
501
|
+
{ name: 'node cjs', code: createWasiBinding('test', '@scope/test') },
|
|
502
|
+
{ name: 'browser esm', code: createWasiBrowserBinding('test') },
|
|
503
|
+
]) {
|
|
504
|
+
test(`pool workers are terminated through the thread manager: ${name}`, (t) => {
|
|
505
|
+
const start = code.indexOf('function __terminateWasiWorkers() {')
|
|
506
|
+
t.true(start > 0)
|
|
507
|
+
const body = code.slice(
|
|
508
|
+
start,
|
|
509
|
+
code.indexOf('function __finishWasiDisposal() {'),
|
|
510
|
+
)
|
|
511
|
+
const mark = body.indexOf('threadManager.terminateWorker(worker)')
|
|
512
|
+
const terminate = body.indexOf('result = worker.terminate()')
|
|
513
|
+
t.true(terminate > 0)
|
|
514
|
+
t.true(mark > 0, 'the termination has to be marked on the thread manager')
|
|
515
|
+
t.true(mark < terminate, 'and marked before the worker is terminated')
|
|
516
|
+
// Not `terminateAllThreads()`: it recreates the pool it just shut down.
|
|
517
|
+
t.false(body.includes('terminateAllThreads'))
|
|
518
|
+
// `terminateWorker` leaves a reporter behind that logs every message still
|
|
519
|
+
// queued on the port, which Node flushes on exit.
|
|
520
|
+
t.true(body.includes('worker.onmessage = undefined'))
|
|
521
|
+
// The manager is resolved through the helper, not read off `__napiModule`:
|
|
522
|
+
// the rollback runs on the one path where that binding was never assigned.
|
|
523
|
+
t.true(body.includes('const threadManager = __getWasiThreadManager()'))
|
|
524
|
+
t.false(body.includes('__napiModule.PThread'))
|
|
525
|
+
})
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
// The pool workers are unreferenced on purpose, and emnapi unreferences them
|
|
529
|
+
// again when one reports `async-thread-ready`, so a pending termination has no
|
|
530
|
+
// handle of its own to hold the loop open with.
|
|
531
|
+
for (const { name, code } of [
|
|
532
|
+
{ name: 'node cjs', code: createWasiBinding('test', '@scope/test') },
|
|
533
|
+
{ name: 'browser esm', code: createWasiBrowserBinding('test') },
|
|
534
|
+
]) {
|
|
535
|
+
test(`a pending termination holds the event loop open: ${name}`, (t) => {
|
|
536
|
+
const body = code.slice(
|
|
537
|
+
code.indexOf('function __terminateWasiWorkers() {'),
|
|
538
|
+
code.indexOf('function __finishWasiDisposal() {'),
|
|
539
|
+
)
|
|
540
|
+
t.true(
|
|
541
|
+
body.includes(
|
|
542
|
+
'__keepEventLoopAliveUntil(Promise.all(pending)).then(finish)',
|
|
543
|
+
),
|
|
544
|
+
'the terminate promises have to be awaited under a keep-alive',
|
|
545
|
+
)
|
|
546
|
+
// …and the keep-alive is released the moment the work settles, so it can
|
|
547
|
+
// never outlive the disposal that asked for it.
|
|
548
|
+
const keepAlive = code.slice(
|
|
549
|
+
code.indexOf('function __keepEventLoopAliveUntil(work) {'),
|
|
550
|
+
)
|
|
551
|
+
t.true(keepAlive.indexOf('clearTimer(timer)') > 0)
|
|
552
|
+
t.true(keepAlive.indexOf('release()') < keepAlive.indexOf('return value'))
|
|
553
|
+
// Nothing puts the stubbed `ref` functions back: doing so is what raced
|
|
554
|
+
// emnapi's own unreference.
|
|
555
|
+
t.false(code.includes('__wasiWorkerRefRestorers'))
|
|
556
|
+
t.false(code.includes('__restoreWasiWorkerRef'))
|
|
557
|
+
})
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
test('the node loader keeps its pool workers unreferenced for life', (t) => {
|
|
561
|
+
const code = createWasiBinding('test', '@scope/test')
|
|
562
|
+
t.true(code.includes('worker[kPublicPort].ref = () => {}'))
|
|
563
|
+
t.true(code.includes('worker[kHandle].ref = () => {}'))
|
|
564
|
+
t.true(code.includes('worker.unref()'))
|
|
565
|
+
// An idle binding must not hold the process open, and disposal does not
|
|
566
|
+
// reverse that — `__keepEventLoopAliveUntil` covers the termination instead.
|
|
567
|
+
t.false(code.includes('.ref = publicPortRef'))
|
|
568
|
+
t.false(code.includes('.ref = handleRef'))
|
|
569
|
+
})
|
|
570
|
+
|
|
571
|
+
// `examples/custom-async-runtime` asserts a threadless loader never mentions
|
|
572
|
+
// `Worker`, so nothing in the *shared* prelude may name the class — comments
|
|
573
|
+
// included. That lane needs a wasm build to fail; this does not.
|
|
574
|
+
for (const { name, code } of [
|
|
575
|
+
{
|
|
576
|
+
name: 'node cjs threadless',
|
|
577
|
+
code: createWasiBinding('test', '@scope/test', 4000, 65536, false),
|
|
578
|
+
},
|
|
579
|
+
{
|
|
580
|
+
name: 'browser esm threadless',
|
|
581
|
+
code: createWasiBrowserBinding(
|
|
582
|
+
'test',
|
|
583
|
+
4000,
|
|
584
|
+
65536,
|
|
585
|
+
false,
|
|
586
|
+
false,
|
|
587
|
+
false,
|
|
588
|
+
false,
|
|
589
|
+
false,
|
|
590
|
+
),
|
|
591
|
+
},
|
|
592
|
+
{ name: 'deferred/workerd', code: createWasiDeferredBrowserBinding('test') },
|
|
593
|
+
]) {
|
|
594
|
+
test(`threadless loaders never name Worker: ${name}`, (t) => {
|
|
595
|
+
t.notRegex(code, /\bWorker\b/)
|
|
596
|
+
t.notRegex(code, /node:worker_threads/)
|
|
597
|
+
})
|
|
598
|
+
}
|
|
599
|
+
|
|
600
|
+
test('asyncRuntime deferred loader registers per instance', (t) => {
|
|
601
|
+
const code = asyncRuntimeDeferredCode
|
|
602
|
+
assertValidJS(t, code, 'deferred asyncRuntime')
|
|
603
|
+
// Per-instance helpers, NOT installCurrentThreadHosts: each instance owns
|
|
604
|
+
// its own env and needs an exact disposer, not a realm-global dedup.
|
|
605
|
+
t.true(code.includes('__registerWorkerdCurrentThreadTaskHost('))
|
|
606
|
+
t.true(code.includes('__registerWorkerdTimerHost('))
|
|
607
|
+
t.false(code.includes('installCurrentThreadHosts'))
|
|
608
|
+
// Task host first, timer host second; disposal is the reverse.
|
|
609
|
+
t.true(
|
|
610
|
+
code.indexOf('const __disposeTaskHost =') <
|
|
611
|
+
code.indexOf('const __disposeTimerHost ='),
|
|
612
|
+
)
|
|
613
|
+
const disposer = code.slice(code.indexOf('__disposeInstanceHosts = () => {'))
|
|
614
|
+
t.true(
|
|
615
|
+
disposer.indexOf('__disposeTimerHost()') <
|
|
616
|
+
disposer.indexOf('__disposeTaskHost()'),
|
|
617
|
+
)
|
|
618
|
+
// Hooked next to __prepareEnvCleanup, which every destroy path calls.
|
|
619
|
+
const managedDestroy = code.slice(
|
|
620
|
+
code.indexOf(' __prepareEnvCleanup?.()'),
|
|
621
|
+
)
|
|
622
|
+
t.true(
|
|
623
|
+
managedDestroy.indexOf('__disposeHosts?.()') <
|
|
624
|
+
managedDestroy.indexOf('__result = __emnapiContext.destroy()'),
|
|
625
|
+
)
|
|
626
|
+
})
|
|
627
|
+
|
|
628
|
+
// The deferred loader's module namespace is the published `./workerd` entry's
|
|
629
|
+
// public API. `WASM_MEMORY` and `getDeferredRuntimeStats` are new names in it,
|
|
630
|
+
// and `createInstance`'s option bag is the only way a host sizes an instance
|
|
631
|
+
// under a hard isolate cap.
|
|
632
|
+
test('deferred loader is syntactically valid in both host modes', (t) => {
|
|
633
|
+
assertValidJS(t, createWasiDeferredBrowserBinding('test'), 'deferred')
|
|
634
|
+
assertValidJS(
|
|
635
|
+
t,
|
|
636
|
+
createWasiDeferredBrowserBinding(
|
|
637
|
+
'test',
|
|
638
|
+
1027,
|
|
639
|
+
65536,
|
|
640
|
+
true,
|
|
641
|
+
'wasm32-wasip1',
|
|
642
|
+
true,
|
|
643
|
+
),
|
|
644
|
+
'deferred + hosts',
|
|
645
|
+
)
|
|
646
|
+
})
|
|
647
|
+
|
|
648
|
+
test('deferred loader publishes the memory floor it was configured with', (t) => {
|
|
649
|
+
const code = createWasiDeferredBrowserBinding('test', 1027, 40000)
|
|
650
|
+
t.true(code.includes('export const WASM_MEMORY = Object.freeze({'))
|
|
651
|
+
t.true(code.includes('initialPages: 1027'))
|
|
652
|
+
t.true(code.includes('maximumPages: 40000'))
|
|
653
|
+
t.true(code.includes('initialBytes: 1027 * 65536'))
|
|
654
|
+
t.true(code.includes('maximumBytes: 40000 * 65536'))
|
|
655
|
+
t.true(code.includes('export function getDeferredRuntimeStats()'))
|
|
656
|
+
// workerd bans allocation in global scope, so the single `new
|
|
657
|
+
// WebAssembly.Memory` stays inside the per-instance resolver.
|
|
658
|
+
t.is(code.split('new WebAssembly.Memory(').length - 1, 1)
|
|
659
|
+
t.true(
|
|
660
|
+
code.indexOf('function __resolveInstanceMemory(') <
|
|
661
|
+
code.indexOf('new WebAssembly.Memory('),
|
|
662
|
+
)
|
|
663
|
+
})
|
|
664
|
+
|
|
665
|
+
test('deferred loader claims caller memory exactly once and rejects shared memory', (t) => {
|
|
666
|
+
const code = createWasiDeferredBrowserBinding('test')
|
|
667
|
+
t.true(code.includes('const __claimedMemories = new WeakSet()'))
|
|
668
|
+
t.true(code.includes('__claimedMemories.add(__provided)'))
|
|
669
|
+
t.true(
|
|
670
|
+
code.includes('requires an unshared WebAssembly.Memory'),
|
|
671
|
+
'a SharedArrayBuffer-backed memory must be rejected, not silently accepted',
|
|
672
|
+
)
|
|
673
|
+
t.true(
|
|
674
|
+
code.includes(
|
|
675
|
+
'Pass either memory or initialMemoryPages/maximumMemoryPages, not both',
|
|
676
|
+
),
|
|
677
|
+
)
|
|
678
|
+
// The claim is taken before instantiation can fail, so a failed attempt
|
|
679
|
+
// cannot hand the same half-written bytes to a second instance.
|
|
680
|
+
const resolver = code.slice(
|
|
681
|
+
code.indexOf('function __resolveInstanceMemory('),
|
|
682
|
+
code.indexOf('\n}\n', code.indexOf('function __resolveInstanceMemory(')),
|
|
683
|
+
)
|
|
684
|
+
t.true(resolver.includes('__claimedMemories.add(__provided)'))
|
|
685
|
+
// The loader-allocated Memory is claimed too: the handle publishes it as
|
|
686
|
+
// `instance.memory`, and two live instances on one linear memory each
|
|
687
|
+
// reinitialize the state the other is running on.
|
|
688
|
+
t.true(resolver.includes('__claimedMemories.add(__allocated)'))
|
|
689
|
+
})
|
|
690
|
+
|
|
691
|
+
test('deferred loader rejects a cross-realm memory before claiming it', (t) => {
|
|
692
|
+
const code = createWasiDeferredBrowserBinding('test')
|
|
693
|
+
const resolver = code.slice(
|
|
694
|
+
code.indexOf('function __resolveInstanceMemory('),
|
|
695
|
+
code.indexOf('\n}\n', code.indexOf('function __resolveInstanceMemory(')),
|
|
696
|
+
)
|
|
697
|
+
// The intrinsic getters accept a genuine Memory from any realm, but
|
|
698
|
+
// `WASI.setMemory` and emnapi identify one with a realm-local `instanceof`.
|
|
699
|
+
t.true(resolver.includes('if (!(__provided instanceof WebAssembly.Memory))'))
|
|
700
|
+
t.true(
|
|
701
|
+
resolver.includes(
|
|
702
|
+
'memory must be a WebAssembly.Memory created in the same realm as this loader',
|
|
703
|
+
),
|
|
704
|
+
)
|
|
705
|
+
// Every rejection must precede the claim, or a corrected retry would be
|
|
706
|
+
// refused as a reuse of a Memory that never ran anything.
|
|
707
|
+
t.true(
|
|
708
|
+
resolver.indexOf('__provided instanceof WebAssembly.Memory') <
|
|
709
|
+
resolver.indexOf('__claimedMemories.add(__provided)'),
|
|
710
|
+
)
|
|
711
|
+
t.true(
|
|
712
|
+
resolver.indexOf(
|
|
713
|
+
'Pass either memory or initialMemoryPages/maximumMemoryPages, not both',
|
|
714
|
+
) < resolver.indexOf('__claimedMemories.add(__provided)'),
|
|
715
|
+
)
|
|
716
|
+
})
|
|
717
|
+
|
|
718
|
+
test('deferred instance handle reports its memory and retires exactly once', (t) => {
|
|
719
|
+
const code = createWasiDeferredBrowserBinding('test')
|
|
720
|
+
const handleStart = code.indexOf(
|
|
721
|
+
' return {\n exports: __napiModule.exports,',
|
|
722
|
+
)
|
|
723
|
+
t.true(
|
|
724
|
+
handleStart !== -1,
|
|
725
|
+
'the instance handle must be returned as a literal',
|
|
726
|
+
)
|
|
727
|
+
const handle = code.slice(
|
|
728
|
+
handleStart,
|
|
729
|
+
code.indexOf(' } catch (error) {', handleStart),
|
|
730
|
+
)
|
|
731
|
+
t.true(handle.includes('get memory() {'))
|
|
732
|
+
t.true(handle.includes('get memoryBytes() {'))
|
|
733
|
+
t.true(handle.includes('get disposed() {'))
|
|
734
|
+
// The handle delegates to the coalescing wrapper, so the retirement
|
|
735
|
+
// bookkeeping lives in the one disposal body that wrapper runs.
|
|
736
|
+
t.true(handle.includes('dispose: __disposeInstance,'))
|
|
737
|
+
const disposalStart = code.indexOf(
|
|
738
|
+
' const __runInstanceDisposal = async () => {',
|
|
739
|
+
)
|
|
740
|
+
t.true(disposalStart !== -1, 'the disposal body must be a named arrow')
|
|
741
|
+
const disposal = code.slice(
|
|
742
|
+
disposalStart,
|
|
743
|
+
code.indexOf('\n }\n', disposalStart),
|
|
744
|
+
)
|
|
745
|
+
// The counter moves only after the destroy resolves, so a dispose() that
|
|
746
|
+
// throws stays retryable without double-decrementing.
|
|
747
|
+
t.true(
|
|
748
|
+
disposal.indexOf('await (__beforeExitDestroy') <
|
|
749
|
+
disposal.indexOf('__liveInstances -= 1'),
|
|
750
|
+
)
|
|
751
|
+
t.true(disposal.includes('if (!__disposed) {'))
|
|
752
|
+
})
|
|
753
|
+
|
|
754
|
+
test('deferred loader keeps the singleton on the loader-owned memory', (t) => {
|
|
755
|
+
const code = createWasiDeferredBrowserBinding('test')
|
|
756
|
+
// `instantiate()` takes no option bag, so it must pass the options slot
|
|
757
|
+
// explicitly rather than shifting `__disposeDefaultInstance` into it.
|
|
758
|
+
t.true(
|
|
759
|
+
code.includes(`__createInstance(
|
|
760
|
+
__module,
|
|
761
|
+
undefined,
|
|
762
|
+
__disposeDefaultInstance,`),
|
|
763
|
+
)
|
|
764
|
+
t.true(code.includes('return __createInstance(__wasmInput, __options)'))
|
|
765
|
+
})
|
|
766
|
+
|
|
767
|
+
test('deferred loader pulls its hosts from the isolate-safe subpath', (t) => {
|
|
768
|
+
const code = createWasiDeferredBrowserBinding(
|
|
769
|
+
'test',
|
|
770
|
+
1024,
|
|
771
|
+
65536,
|
|
772
|
+
false,
|
|
773
|
+
'wasm32-wasip1',
|
|
774
|
+
true,
|
|
775
|
+
)
|
|
776
|
+
// The barrel (`index.cjs`) also requires `current-thread-hosts.cjs`, the
|
|
777
|
+
// Node-lane relay a worker bundle never runs and CJS cannot tree-shake.
|
|
778
|
+
t.true(code.includes("} from '@napi-rs/async-runtime/workerd'"))
|
|
779
|
+
t.false(code.includes("from '@napi-rs/async-runtime'\n"))
|
|
780
|
+
})
|
|
781
|
+
|
|
782
|
+
test('deferred loader type definition covers the new surface', (t) => {
|
|
783
|
+
const typeDef = createWasiDeferredBrowserBindingTypeDef('./test.wasip1.cjs')
|
|
784
|
+
// The two memory forms are separate interfaces, each declaring the other
|
|
785
|
+
// form's properties as `never`, so the combination `__resolveInstanceMemory`
|
|
786
|
+
// always throws on cannot be spelled by a typed caller.
|
|
787
|
+
t.true(typeDef.includes('export interface WasiCallerMemoryOptions {'))
|
|
788
|
+
t.true(typeDef.includes(' memory: WebAssembly.Memory'))
|
|
789
|
+
t.true(typeDef.includes(' initialMemoryPages?: never'))
|
|
790
|
+
t.true(typeDef.includes(' maximumMemoryPages?: never'))
|
|
791
|
+
t.true(typeDef.includes('export interface WasiAllocatedMemoryOptions {'))
|
|
792
|
+
t.true(typeDef.includes(' memory?: never'))
|
|
793
|
+
t.true(typeDef.includes(' initialMemoryPages?: number'))
|
|
794
|
+
t.true(typeDef.includes(' maximumMemoryPages?: number'))
|
|
795
|
+
t.true(
|
|
796
|
+
typeDef.includes(`export type WasiInstanceOptions =
|
|
797
|
+
| WasiCallerMemoryOptions
|
|
798
|
+
| WasiAllocatedMemoryOptions`),
|
|
799
|
+
)
|
|
800
|
+
t.false(typeDef.includes('export interface WasiInstanceOptions {'))
|
|
801
|
+
t.true(typeDef.includes('readonly memoryBytes: number'))
|
|
802
|
+
t.true(typeDef.includes('readonly disposed: boolean'))
|
|
803
|
+
t.true(typeDef.includes('export const WASM_MEMORY: Readonly<{'))
|
|
804
|
+
t.true(
|
|
805
|
+
typeDef.includes(
|
|
806
|
+
'export function getDeferredRuntimeStats(): Readonly<WasiRuntimeStats>',
|
|
807
|
+
),
|
|
808
|
+
)
|
|
809
|
+
t.true(
|
|
810
|
+
typeDef.includes(`export function createInstance(
|
|
811
|
+
wasmInput: WasiModuleInput,
|
|
812
|
+
options?: WasiInstanceOptions,
|
|
813
|
+
): Promise<WasiInstance>`),
|
|
814
|
+
)
|
|
815
|
+
// `createWasiDeferredBindingTypeDef` rewrites this exact string when the
|
|
816
|
+
// project builds without type definitions.
|
|
817
|
+
t.true(typeDef.includes("typeof import('./test.wasip1.cjs')"))
|
|
818
|
+
})
|
|
819
|
+
|
|
820
|
+
const DEFERRED_TYPE_CHECK_OPTIONS: ts.CompilerOptions = {
|
|
821
|
+
strict: true,
|
|
822
|
+
noEmit: true,
|
|
823
|
+
// The generated `.d.ts` is the file under test, so it must not be skipped.
|
|
824
|
+
// Only the default lib is.
|
|
825
|
+
skipLibCheck: false,
|
|
826
|
+
skipDefaultLibCheck: true,
|
|
827
|
+
target: ts.ScriptTarget.ESNext,
|
|
828
|
+
module: ts.ModuleKind.ESNext,
|
|
829
|
+
moduleResolution: ts.ModuleResolutionKind.Bundler,
|
|
830
|
+
}
|
|
831
|
+
|
|
832
|
+
/**
|
|
833
|
+
* A consumer module for the generated deferred `.d.ts`, wrapping the given
|
|
834
|
+
* `createInstance()` calls in the declarations they need.
|
|
835
|
+
*/
|
|
836
|
+
const deferredConsumerSource = (calls: string) =>
|
|
837
|
+
`import { createInstance } from './loader.js'
|
|
838
|
+
|
|
839
|
+
declare const wasmModule: WebAssembly.Module
|
|
840
|
+
declare const memory: WebAssembly.Memory
|
|
841
|
+
|
|
842
|
+
${calls}
|
|
843
|
+
`
|
|
844
|
+
|
|
845
|
+
/**
|
|
846
|
+
* TypeScript's verdict on such a consumer, as a list of diagnostic codes. The
|
|
847
|
+
* default lib still comes off disk through the real host; the generated
|
|
848
|
+
* typedef, the binding it imports, and the consumer are served from memory, so
|
|
849
|
+
* the check needs no temporary directory. Mirrors the semantic-check host in
|
|
850
|
+
* `build.spec.ts`.
|
|
851
|
+
*/
|
|
852
|
+
const deferredConsumerDiagnostics = (calls: string) => {
|
|
853
|
+
const root = `${__dirname.replaceAll('\\', '/')}/__deferred_typecheck__`
|
|
854
|
+
const files = new Map([
|
|
855
|
+
[
|
|
856
|
+
`${root}/loader.d.ts`,
|
|
857
|
+
createWasiDeferredBrowserBindingTypeDef('./test.wasip1.cjs'),
|
|
858
|
+
],
|
|
859
|
+
[`${root}/test.wasip1.d.cts`, 'export declare const binding: number\n'],
|
|
860
|
+
[`${root}/consumer.ts`, deferredConsumerSource(calls)],
|
|
861
|
+
])
|
|
862
|
+
const host = ts.createCompilerHost(DEFERRED_TYPE_CHECK_OPTIONS, true)
|
|
863
|
+
const readRealSourceFile = host.getSourceFile.bind(host)
|
|
864
|
+
const realFileExists = host.fileExists.bind(host)
|
|
865
|
+
const realReadFile = host.readFile.bind(host)
|
|
866
|
+
const realDirectoryExists = host.directoryExists?.bind(host)
|
|
867
|
+
host.getSourceFile = (fileName, languageVersion, ...rest) => {
|
|
868
|
+
const virtual = files.get(fileName)
|
|
869
|
+
return virtual === undefined
|
|
870
|
+
? readRealSourceFile(fileName, languageVersion, ...rest)
|
|
871
|
+
: ts.createSourceFile(fileName, virtual, languageVersion, true)
|
|
872
|
+
}
|
|
873
|
+
host.fileExists = (fileName) =>
|
|
874
|
+
files.has(fileName) || realFileExists(fileName)
|
|
875
|
+
host.readFile = (fileName) => files.get(fileName) ?? realReadFile(fileName)
|
|
876
|
+
if (realDirectoryExists) {
|
|
877
|
+
host.directoryExists = (directoryName) =>
|
|
878
|
+
directoryName === root || realDirectoryExists(directoryName)
|
|
879
|
+
}
|
|
880
|
+
const program = ts.createProgram({
|
|
881
|
+
rootNames: [`${root}/consumer.ts`],
|
|
882
|
+
options: DEFERRED_TYPE_CHECK_OPTIONS,
|
|
883
|
+
host,
|
|
884
|
+
})
|
|
885
|
+
return ts.getPreEmitDiagnostics(program).map((d) => `TS${d.code}`)
|
|
886
|
+
}
|
|
887
|
+
|
|
888
|
+
test('deferred createInstance() options type-check per memory form', (t) => {
|
|
889
|
+
// Every form the loader accepts at runtime. `{}` and an omitted argument
|
|
890
|
+
// must stay legal: the allocated form is all-optional.
|
|
891
|
+
t.deepEqual(
|
|
892
|
+
deferredConsumerDiagnostics(`void createInstance(wasmModule)
|
|
893
|
+
void createInstance(wasmModule, undefined)
|
|
894
|
+
void createInstance(wasmModule, {})
|
|
895
|
+
void createInstance(wasmModule, { memory })
|
|
896
|
+
void createInstance(wasmModule, { initialMemoryPages: 1 })
|
|
897
|
+
void createInstance(wasmModule, { initialMemoryPages: 1, maximumMemoryPages: 2 })`),
|
|
898
|
+
[],
|
|
899
|
+
)
|
|
900
|
+
})
|
|
901
|
+
|
|
902
|
+
test('deferred createInstance() rejects memory beside a page count', (t) => {
|
|
903
|
+
// `__resolveInstanceMemory` throws a TypeError on either mix, so neither
|
|
904
|
+
// may type-check. `memory` selects the caller form, whose page properties
|
|
905
|
+
// are `?: never` — i.e. `undefined` — so the checker rejects the offending
|
|
906
|
+
// property in place rather than the whole call: TS2322, "Type 'number' is not
|
|
907
|
+
// assignable to type 'undefined'".
|
|
908
|
+
t.deepEqual(
|
|
909
|
+
deferredConsumerDiagnostics(
|
|
910
|
+
'void createInstance(wasmModule, { memory, initialMemoryPages: 1024 })',
|
|
911
|
+
),
|
|
912
|
+
['TS2322'],
|
|
913
|
+
)
|
|
914
|
+
t.deepEqual(
|
|
915
|
+
deferredConsumerDiagnostics(
|
|
916
|
+
'void createInstance(wasmModule, { memory, maximumMemoryPages: 2048 })',
|
|
917
|
+
),
|
|
918
|
+
['TS2322'],
|
|
919
|
+
)
|
|
920
|
+
})
|
|
921
|
+
|
|
234
922
|
test('Node WASI loader uses an accessible host root on Android', (t) => {
|
|
235
923
|
const code = createWasiBinding('test', '@scope/test')
|
|
236
924
|
assertValidJS(t, code, 'Node WASI loader')
|
|
@@ -317,6 +1005,264 @@ function initializationRollbackBody(code: string): string {
|
|
|
317
1005
|
return code.slice(deferredStart, code.indexOf('throw error', deferredStart))
|
|
318
1006
|
}
|
|
319
1007
|
|
|
1008
|
+
// The loaders order their own teardown barrier-then-destroy, but the emnapi
|
|
1009
|
+
// context is a live object: an embedder or test harness holding it, or emnapi's
|
|
1010
|
+
// own `beforeExit` auto-destroy on a host where `suppressDestroy()` is absent,
|
|
1011
|
+
// can call `Context.destroy()` directly. `destroy()` disables JavaScript calls
|
|
1012
|
+
// before it runs cleanup hooks, so a raw call discards the very settlements the
|
|
1013
|
+
// barrier exists to cancel and deliver. Own the ordering on the object: every
|
|
1014
|
+
// flavor shadows `destroy` once, at creation, before anything can reach it.
|
|
1015
|
+
const CONTEXT_DESTROY_WRAP_SIGNATURE =
|
|
1016
|
+
'function __wrapEmnapiContextDestroyForSettlement('
|
|
1017
|
+
|
|
1018
|
+
// The shared barrier's own in-flight flag, and the probe the wrapper reads it
|
|
1019
|
+
// through. It cannot live in the wrapper: `dispose()` runs the barrier itself
|
|
1020
|
+
// and only then calls `destroy()`, so a wrapper-local flag would still be clear
|
|
1021
|
+
// while the barrier is running and would let a reentrant destroy through.
|
|
1022
|
+
const preparingBarrierGuards = {
|
|
1023
|
+
shared: {
|
|
1024
|
+
probe: '__isPreparingWasmEnvCleanup',
|
|
1025
|
+
snippets: [
|
|
1026
|
+
'let __emnapiWasmEnvCleanupPreparing = false',
|
|
1027
|
+
`function __isPreparingWasmEnvCleanup() {
|
|
1028
|
+
return __emnapiWasmEnvCleanupPreparing
|
|
1029
|
+
}`,
|
|
1030
|
+
` if (__emnapiWasmEnvCleanupPrepared || __emnapiWasmEnvCleanupPreparing) {
|
|
1031
|
+
return
|
|
1032
|
+
}`,
|
|
1033
|
+
` __emnapiWasmEnvCleanupPreparing = true
|
|
1034
|
+
try {
|
|
1035
|
+
prepare()
|
|
1036
|
+
} finally {
|
|
1037
|
+
__emnapiWasmEnvCleanupPreparing = false
|
|
1038
|
+
}`,
|
|
1039
|
+
],
|
|
1040
|
+
},
|
|
1041
|
+
deferred: {
|
|
1042
|
+
probe: '__isPreparingEnvCleanup',
|
|
1043
|
+
snippets: [
|
|
1044
|
+
'let __wasmEnvCleanupPreparing = false',
|
|
1045
|
+
'const __isPreparingEnvCleanup = () => __wasmEnvCleanupPreparing',
|
|
1046
|
+
` if (__wasmEnvCleanupPrepared || __wasmEnvCleanupPreparing) {
|
|
1047
|
+
return
|
|
1048
|
+
}`,
|
|
1049
|
+
` __wasmEnvCleanupPreparing = true
|
|
1050
|
+
try {
|
|
1051
|
+
__prepareWasmEnvCleanup()
|
|
1052
|
+
} finally {
|
|
1053
|
+
__wasmEnvCleanupPreparing = false
|
|
1054
|
+
}`,
|
|
1055
|
+
],
|
|
1056
|
+
},
|
|
1057
|
+
} as const
|
|
1058
|
+
|
|
1059
|
+
// A nested `destroy()` must answer `undefined` without touching the real one:
|
|
1060
|
+
// no fallthrough, no deferral. `Context.destroy()` is typed `void`, so nothing
|
|
1061
|
+
// observable is lost, and the frame that started the barrier destroys the
|
|
1062
|
+
// moment it returns.
|
|
1063
|
+
const NESTED_DESTROY_NO_OP = ` if (isPreparingEnvCleanup?.()) {
|
|
1064
|
+
return
|
|
1065
|
+
}
|
|
1066
|
+
prepareEnvCleanup?.()`
|
|
1067
|
+
|
|
1068
|
+
const wrappedContextCreationCases: Array<{
|
|
1069
|
+
name: string
|
|
1070
|
+
code: string
|
|
1071
|
+
prepare: string
|
|
1072
|
+
guard: (typeof preparingBarrierGuards)[keyof typeof preparingBarrierGuards]
|
|
1073
|
+
}> = [
|
|
1074
|
+
{
|
|
1075
|
+
name: 'node cjs',
|
|
1076
|
+
code: createWasiBinding('test', '@scope/test'),
|
|
1077
|
+
prepare: '__prepareWasmEnvCleanup',
|
|
1078
|
+
guard: preparingBarrierGuards.shared,
|
|
1079
|
+
},
|
|
1080
|
+
{
|
|
1081
|
+
name: 'node cjs threadless',
|
|
1082
|
+
code: createWasiBinding('test', '@scope/test', 4000, 65536, false),
|
|
1083
|
+
prepare: '__prepareWasmEnvCleanup',
|
|
1084
|
+
guard: preparingBarrierGuards.shared,
|
|
1085
|
+
},
|
|
1086
|
+
{
|
|
1087
|
+
name: 'browser esm',
|
|
1088
|
+
code: createWasiBrowserBinding('test'),
|
|
1089
|
+
prepare: '__prepareWasmEnvCleanup',
|
|
1090
|
+
guard: preparingBarrierGuards.shared,
|
|
1091
|
+
},
|
|
1092
|
+
{
|
|
1093
|
+
name: 'deferred/workerd',
|
|
1094
|
+
code: createWasiDeferredBrowserBinding('test'),
|
|
1095
|
+
prepare: '__prepareEnvCleanup',
|
|
1096
|
+
guard: preparingBarrierGuards.deferred,
|
|
1097
|
+
},
|
|
1098
|
+
]
|
|
1099
|
+
|
|
1100
|
+
for (const { name, code, prepare, guard } of wrappedContextCreationCases) {
|
|
1101
|
+
test(`WASI loader runs the barrier on a raw context.destroy(): ${name}`, (t) => {
|
|
1102
|
+
assertValidJS(t, code, name)
|
|
1103
|
+
t.is(
|
|
1104
|
+
code.split(CONTEXT_DESTROY_WRAP_SIGNATURE).length - 1,
|
|
1105
|
+
1,
|
|
1106
|
+
'loader must define the destroy wrapper exactly once',
|
|
1107
|
+
)
|
|
1108
|
+
// No unwrapped context may escape: there is exactly one createContext call
|
|
1109
|
+
// and it is the wrapper's argument.
|
|
1110
|
+
t.is(
|
|
1111
|
+
code.split('__emnapiCreateContext({ autoDestroy: false })').length - 1,
|
|
1112
|
+
1,
|
|
1113
|
+
'loader must create exactly one emnapi context',
|
|
1114
|
+
)
|
|
1115
|
+
t.true(
|
|
1116
|
+
code
|
|
1117
|
+
.replace(/\s+/g, ' ')
|
|
1118
|
+
.includes(
|
|
1119
|
+
'__emnapiContext = __wrapEmnapiContextDestroyForSettlement( ' +
|
|
1120
|
+
`__emnapiCreateContext({ autoDestroy: false }), ${prepare}, ${guard.probe}, )`,
|
|
1121
|
+
),
|
|
1122
|
+
'the createContext result must be wrapped before anything can reach it',
|
|
1123
|
+
)
|
|
1124
|
+
// Ordering is the whole point: barrier first, real destroy second.
|
|
1125
|
+
const wrapperStart = code.indexOf(CONTEXT_DESTROY_WRAP_SIGNATURE)
|
|
1126
|
+
const wrapper = code.slice(
|
|
1127
|
+
wrapperStart,
|
|
1128
|
+
code.indexOf('\n}\n', wrapperStart),
|
|
1129
|
+
)
|
|
1130
|
+
t.true(
|
|
1131
|
+
wrapper.indexOf('prepareEnvCleanup?.()') <
|
|
1132
|
+
wrapper.indexOf('Reflect.apply(destroy, this, arguments)'),
|
|
1133
|
+
'the barrier must run before the real destroy, while the env can still call into JavaScript',
|
|
1134
|
+
)
|
|
1135
|
+
})
|
|
1136
|
+
|
|
1137
|
+
// The barrier settles the promises it cancels *synchronously*, under a
|
|
1138
|
+
// lifecycle mutex the addon cannot acquire twice. A `promiseHooks.onSettled`
|
|
1139
|
+
// handler — or the `async_hooks` hook `AsyncLocalStorage` installs — that
|
|
1140
|
+
// calls `destroy()` therefore re-enters the wrapper from inside the barrier,
|
|
1141
|
+
// and a second trip into the export aborts the wasm instance outright. The
|
|
1142
|
+
// flag lives in the barrier, not in the wrapper, so the same guard covers the
|
|
1143
|
+
// `dispose()` path, where the barrier runs before `destroy()` is ever called.
|
|
1144
|
+
test(`WASI loader makes a destroy reentered from the barrier a no-op: ${name}`, (t) => {
|
|
1145
|
+
for (const snippet of guard.snippets) {
|
|
1146
|
+
t.is(
|
|
1147
|
+
code.split(snippet).length - 1,
|
|
1148
|
+
1,
|
|
1149
|
+
`barrier must carry its in-flight guard exactly once: ${snippet}`,
|
|
1150
|
+
)
|
|
1151
|
+
}
|
|
1152
|
+
t.is(
|
|
1153
|
+
code.split(NESTED_DESTROY_NO_OP).length - 1,
|
|
1154
|
+
1,
|
|
1155
|
+
'a destroy reentered while the barrier is in flight must return without running the barrier or the real destroy',
|
|
1156
|
+
)
|
|
1157
|
+
const wrapperStart = code.indexOf(CONTEXT_DESTROY_WRAP_SIGNATURE)
|
|
1158
|
+
const wrapper = code.slice(
|
|
1159
|
+
wrapperStart,
|
|
1160
|
+
code.indexOf('\n}\n', wrapperStart),
|
|
1161
|
+
)
|
|
1162
|
+
t.true(
|
|
1163
|
+
wrapper.indexOf('isPreparingEnvCleanup?.()') <
|
|
1164
|
+
wrapper.indexOf('Reflect.apply(destroy, this, arguments)'),
|
|
1165
|
+
'the reentry check must come before the real destroy',
|
|
1166
|
+
)
|
|
1167
|
+
})
|
|
1168
|
+
}
|
|
1169
|
+
|
|
1170
|
+
// The nested-destroy no-op above is only safe because the frame that started
|
|
1171
|
+
// the barrier destroys as soon as the barrier returns. The deferred loader's
|
|
1172
|
+
// instance `dispose()` is the one frame that does not: it runs the barrier and
|
|
1173
|
+
// then yields for the settlement drain. So a promise hook firing inside that
|
|
1174
|
+
// barrier can call the same instance's `dispose()` again, reach the context
|
|
1175
|
+
// destroyer while the outer frame is still parked in its drain, and have the
|
|
1176
|
+
// wrapper's no-op recorded as a completed destroy — after which the outer frame
|
|
1177
|
+
// skips the real one and the context is retained with its cleanup hooks unrun.
|
|
1178
|
+
// dispose() has to coalesce, the way the eager loaders' `__disposeWasiBinding`
|
|
1179
|
+
// does, and the memo has to be in place *before* the barrier runs.
|
|
1180
|
+
test('deferred WASI loader coalesces a reentrant instance dispose()', (t) => {
|
|
1181
|
+
const code = createWasiDeferredBrowserBinding('test')
|
|
1182
|
+
assertValidJS(t, code, 'deferred/workerd')
|
|
1183
|
+
t.is(
|
|
1184
|
+
code.split('let __instanceDisposePromise').length - 1,
|
|
1185
|
+
1,
|
|
1186
|
+
'the instance must carry exactly one disposal memo',
|
|
1187
|
+
)
|
|
1188
|
+
const disposeStart = code.indexOf('const __disposeInstance = () => {')
|
|
1189
|
+
t.true(disposeStart > 0, 'dispose() must go through a coalescing wrapper')
|
|
1190
|
+
const disposeWrapper = code.slice(
|
|
1191
|
+
disposeStart,
|
|
1192
|
+
code.indexOf('\n }\n', disposeStart),
|
|
1193
|
+
)
|
|
1194
|
+
t.true(
|
|
1195
|
+
disposeWrapper.includes(` if (__instanceDisposePromise) {
|
|
1196
|
+
return __instanceDisposePromise
|
|
1197
|
+
}`),
|
|
1198
|
+
'a reentrant dispose() must join the disposal already running',
|
|
1199
|
+
)
|
|
1200
|
+
// The memo has to be published before the disposal body — and therefore
|
|
1201
|
+
// before the barrier — runs, or a hook that fires inside the barrier still
|
|
1202
|
+
// finds it unset and starts a second frame.
|
|
1203
|
+
t.true(
|
|
1204
|
+
disposeWrapper.indexOf('__instanceDisposePromise = __disposePromise') <
|
|
1205
|
+
disposeWrapper.indexOf('__runInstanceDisposal()'),
|
|
1206
|
+
'the memo must be published before the disposal body runs',
|
|
1207
|
+
)
|
|
1208
|
+
// Still retryable: the drain can reject on its own (a host `setImmediate`
|
|
1209
|
+
// that throws), and dispose() has to be callable again after that.
|
|
1210
|
+
t.true(
|
|
1211
|
+
disposeWrapper.includes(` __instanceDisposePromise = undefined
|
|
1212
|
+
__rejectDispose(__error)`),
|
|
1213
|
+
'a failed disposal must clear the memo so dispose() stays retryable',
|
|
1214
|
+
)
|
|
1215
|
+
// No second entry point into the disposal body: the instance exposes the
|
|
1216
|
+
// coalescing wrapper itself, not an inline method that re-runs it.
|
|
1217
|
+
t.true(
|
|
1218
|
+
code.includes(' dispose: __disposeInstance,'),
|
|
1219
|
+
'the instance must expose the coalescing wrapper as its dispose()',
|
|
1220
|
+
)
|
|
1221
|
+
t.false(
|
|
1222
|
+
code.includes(' async dispose() {'),
|
|
1223
|
+
'the instance must not carry a second inline dispose() method',
|
|
1224
|
+
)
|
|
1225
|
+
t.is(
|
|
1226
|
+
code.split('__prepareForDisposal()').length - 1,
|
|
1227
|
+
2,
|
|
1228
|
+
'only the disposal body and the initialization rollback may prepare for disposal',
|
|
1229
|
+
)
|
|
1230
|
+
})
|
|
1231
|
+
|
|
1232
|
+
// Belt and braces for any caller that still reaches the managed destroyer from
|
|
1233
|
+
// inside the barrier: a `Context.destroy()` the wrapper skipped must never be
|
|
1234
|
+
// recorded as a completed destroy, or every later destroy — the outer disposal
|
|
1235
|
+
// frame's and managed beforeExit cleanup's alike — is skipped with it.
|
|
1236
|
+
test('deferred WASI loader never records a skipped destroy as completed', (t) => {
|
|
1237
|
+
const code = createWasiDeferredBrowserBinding('test')
|
|
1238
|
+
const destroyStart = code.indexOf('const __destroy = (')
|
|
1239
|
+
t.true(destroyStart > 0, 'deferred loader must define a managed destroyer')
|
|
1240
|
+
const destroyer = code.slice(
|
|
1241
|
+
destroyStart,
|
|
1242
|
+
code.indexOf('\n const __destroyForModuleLifecycle', destroyStart),
|
|
1243
|
+
)
|
|
1244
|
+
t.true(
|
|
1245
|
+
destroyer.includes(` if (__isPreparingEnvCleanup?.()) {`),
|
|
1246
|
+
'the managed destroyer must detect that the barrier is in flight',
|
|
1247
|
+
)
|
|
1248
|
+
const guardIndex = destroyer.indexOf('if (__isPreparingEnvCleanup?.()) {')
|
|
1249
|
+
const destroyIndex = destroyer.indexOf('__result = __emnapiContext.destroy()')
|
|
1250
|
+
t.true(
|
|
1251
|
+
guardIndex < destroyIndex,
|
|
1252
|
+
'the in-flight check must come before the destroy it would skip',
|
|
1253
|
+
)
|
|
1254
|
+
t.true(
|
|
1255
|
+
destroyer
|
|
1256
|
+
.slice(guardIndex, destroyIndex)
|
|
1257
|
+
.includes(`throw __createLifecycleReentryError('dispose')`),
|
|
1258
|
+
'a destroy the wrapper would skip must fail loudly instead of flagging the context disposed',
|
|
1259
|
+
)
|
|
1260
|
+
t.true(
|
|
1261
|
+
destroyIndex < destroyer.indexOf('__disposed = true'),
|
|
1262
|
+
'nothing may be flagged disposed before the real destroy is attempted',
|
|
1263
|
+
)
|
|
1264
|
+
})
|
|
1265
|
+
|
|
320
1266
|
test('createCjsBinding uses one statement dialect', (t) => {
|
|
321
1267
|
const code = createCjsBinding('test', '@scope/test', ['sum'], '1.0.0', [
|
|
322
1268
|
'wasm32-wasi',
|
|
@@ -338,6 +1284,412 @@ test('createCjsBinding uses one statement dialect', (t) => {
|
|
|
338
1284
|
)
|
|
339
1285
|
})
|
|
340
1286
|
|
|
1287
|
+
test('the root CommonJS loader stamps before it aliases the addon', (t) => {
|
|
1288
|
+
// The guard is safe against an addon accessor — `hasOwnProperty` and a read —
|
|
1289
|
+
// but an assignment is not: a `#[napi(module_exports)]` hook can expose a
|
|
1290
|
+
// getter reporting the value about to be stamped and a setter that throws.
|
|
1291
|
+
// So the lexer-visible assignment has to land on the loader's own
|
|
1292
|
+
// `module.exports`, while it is still the original object.
|
|
1293
|
+
const cjs = createCjsBinding('test', '@scope/test', ['sum'], '1.0.0')
|
|
1294
|
+
assertValidJS(t, cjs, 'root cjs stamp before alias')
|
|
1295
|
+
const stamp = cjs.indexOf(ROOT_CJS_STAMP_CALL)
|
|
1296
|
+
t.true(stamp > -1, 'the root loader must stamp through the guard')
|
|
1297
|
+
t.true(
|
|
1298
|
+
stamp < cjs.indexOf('\nmodule.exports = nativeBinding'),
|
|
1299
|
+
'the stamp must precede the alias, or it assigns onto the addon',
|
|
1300
|
+
)
|
|
1301
|
+
// the guard still stamps the object the loader hands out
|
|
1302
|
+
t.false(cjs.includes('__napiStampBindingTarget(module.exports,'))
|
|
1303
|
+
})
|
|
1304
|
+
|
|
1305
|
+
test('native loaders export the artifact that actually loaded', (t) => {
|
|
1306
|
+
const flavors = ['wasm32-wasi', 'wasm32-wasip1']
|
|
1307
|
+
const cjs = createCjsBinding('test', '@scope/test', ['sum'], '1.0.0', flavors)
|
|
1308
|
+
assertValidJS(t, cjs, 'cjs binding target')
|
|
1309
|
+
t.true(cjs.includes("let __napiLoadedBindingTarget = 'native'"))
|
|
1310
|
+
t.true(cjs.includes(ROOT_CJS_STAMP_CALL))
|
|
1311
|
+
// one assignment per candidate: 2 flavors x (local loader + flavor package)
|
|
1312
|
+
for (const flavor of flavors) {
|
|
1313
|
+
t.is(
|
|
1314
|
+
cjs.split(`__napiLoadedBindingTarget = '${flavor}'`).length - 1,
|
|
1315
|
+
2,
|
|
1316
|
+
`${flavor} must be recorded on both its local and package candidates`,
|
|
1317
|
+
)
|
|
1318
|
+
}
|
|
1319
|
+
// the target is never read back off the WASI module
|
|
1320
|
+
t.false(cjs.includes('wasiBinding.__napiBindingTarget'))
|
|
1321
|
+
|
|
1322
|
+
const esm = createEsmBinding('test', '@scope/test', ['sum'], '1.0.0', flavors)
|
|
1323
|
+
assertValidJS(t, esm, 'esm binding target')
|
|
1324
|
+
t.true(
|
|
1325
|
+
esm.includes(
|
|
1326
|
+
'export const __napiBindingTarget = __napiLoadedBindingTarget',
|
|
1327
|
+
),
|
|
1328
|
+
)
|
|
1329
|
+
// zero-ident packages take the `export default` branch and must keep it
|
|
1330
|
+
const esmNoIdents = createEsmBinding(
|
|
1331
|
+
'test',
|
|
1332
|
+
'@scope/test',
|
|
1333
|
+
[],
|
|
1334
|
+
'1.0.0',
|
|
1335
|
+
flavors,
|
|
1336
|
+
)
|
|
1337
|
+
assertValidJS(t, esmNoIdents, 'esm binding target without idents')
|
|
1338
|
+
t.true(
|
|
1339
|
+
esmNoIdents.includes(
|
|
1340
|
+
'export const __napiBindingTarget = __napiLoadedBindingTarget',
|
|
1341
|
+
),
|
|
1342
|
+
)
|
|
1343
|
+
})
|
|
1344
|
+
|
|
1345
|
+
test('a napi export may not shadow __napiBindingTarget', (t) => {
|
|
1346
|
+
t.throws(
|
|
1347
|
+
() => createEsmBinding('test', '@scope/test', ['__napiBindingTarget']),
|
|
1348
|
+
{
|
|
1349
|
+
message: /reserved by the generated binding loader/,
|
|
1350
|
+
},
|
|
1351
|
+
)
|
|
1352
|
+
t.throws(
|
|
1353
|
+
() => createCjsBinding('test', '@scope/test', ['__napiBindingTarget']),
|
|
1354
|
+
{
|
|
1355
|
+
message: /reserved by the generated binding loader/,
|
|
1356
|
+
},
|
|
1357
|
+
)
|
|
1358
|
+
})
|
|
1359
|
+
|
|
1360
|
+
test('WASI loaders self-identify their flavor', (t) => {
|
|
1361
|
+
t.true(
|
|
1362
|
+
createWasiBinding('test', '@scope/test').includes(
|
|
1363
|
+
"const __napiBindingTarget = 'wasm32-wasi'",
|
|
1364
|
+
),
|
|
1365
|
+
)
|
|
1366
|
+
t.true(
|
|
1367
|
+
createWasiBinding(
|
|
1368
|
+
'test',
|
|
1369
|
+
'@scope/test',
|
|
1370
|
+
4000,
|
|
1371
|
+
65536,
|
|
1372
|
+
false,
|
|
1373
|
+
'wasm32-wasip1',
|
|
1374
|
+
).includes("const __napiBindingTarget = 'wasm32-wasip1'"),
|
|
1375
|
+
)
|
|
1376
|
+
t.true(
|
|
1377
|
+
createWasiBrowserBinding('test').includes(
|
|
1378
|
+
"export const __napiBindingTarget = 'wasm32-wasi'",
|
|
1379
|
+
),
|
|
1380
|
+
)
|
|
1381
|
+
t.true(
|
|
1382
|
+
createWasiBrowserBinding(
|
|
1383
|
+
'test',
|
|
1384
|
+
4000,
|
|
1385
|
+
65536,
|
|
1386
|
+
false,
|
|
1387
|
+
false,
|
|
1388
|
+
false,
|
|
1389
|
+
false,
|
|
1390
|
+
false,
|
|
1391
|
+
).includes("export const __napiBindingTarget = 'wasm32-wasip1'"),
|
|
1392
|
+
)
|
|
1393
|
+
t.true(
|
|
1394
|
+
createWasiDeferredBrowserBinding('test').includes(
|
|
1395
|
+
"export const __napiBindingTarget = 'wasm32-wasip1'",
|
|
1396
|
+
),
|
|
1397
|
+
)
|
|
1398
|
+
})
|
|
1399
|
+
|
|
1400
|
+
const STAMP_HELPER_DECL =
|
|
1401
|
+
'function __napiStampBindingTarget(exportsObject, target) {'
|
|
1402
|
+
const WASI_STAMP_CALL =
|
|
1403
|
+
'__napiStampBindingTarget(__napiModule.exports, __napiBindingTarget)'
|
|
1404
|
+
// The CJS loaders assign the guard's return value instead of calling it as a
|
|
1405
|
+
// statement: `cjs-module-lexer` only reports `__napiBindingTarget` as a named
|
|
1406
|
+
// export when it can see `module.exports.<name> =`, and Node's CJS->ESM named
|
|
1407
|
+
// export detection is that lexer.
|
|
1408
|
+
const ROOT_CJS_STAMP_CALL =
|
|
1409
|
+
'module.exports.__napiBindingTarget = __napiStampBindingTarget(nativeBinding, __napiLoadedBindingTarget)'
|
|
1410
|
+
// The node WASI loader stamps the emnapi exports object — the one the CommonJS
|
|
1411
|
+
// tail then aliases — while assigning through `module.exports` for the lexer.
|
|
1412
|
+
const WASI_CJS_STAMP_LINE = `module.exports.__napiBindingTarget = ${WASI_STAMP_CALL}`
|
|
1413
|
+
// the call inside the initialization try, not the function declaration
|
|
1414
|
+
const WASI_EXIT_LISTENER_CALL = '\n __registerWasiExitListener()'
|
|
1415
|
+
// nothing may write the marker onto a user-controlled exports object without
|
|
1416
|
+
// going through the guard, so an assignment is only legal when the guard call
|
|
1417
|
+
// is its right-hand side
|
|
1418
|
+
const UNGUARDED_MODULE_EXPORTS_STAMP =
|
|
1419
|
+
/module\.exports\.__napiBindingTarget = (?!__napiStampBindingTarget\()/
|
|
1420
|
+
|
|
1421
|
+
test('browser and deferred loaders carry the flavor on the binding they hand out', (t) => {
|
|
1422
|
+
// `export default __napiModule.exports` and `instantiate()` hand out the raw
|
|
1423
|
+
// emnapi exports object, which a named module export does not travel with.
|
|
1424
|
+
const browser = createWasiBrowserBinding('test')
|
|
1425
|
+
assertValidJS(t, browser, 'browser binding target on exports')
|
|
1426
|
+
t.true(browser.includes(WASI_STAMP_CALL))
|
|
1427
|
+
const deferred = createWasiDeferredBrowserBinding('test')
|
|
1428
|
+
assertValidJS(t, deferred, 'deferred binding target on exports')
|
|
1429
|
+
t.is(
|
|
1430
|
+
deferred.split(WASI_STAMP_CALL).length - 1,
|
|
1431
|
+
1,
|
|
1432
|
+
'every instance created by __createInstance must be marked exactly once',
|
|
1433
|
+
)
|
|
1434
|
+
// the marker is assigned before the instance escapes to the caller
|
|
1435
|
+
t.true(
|
|
1436
|
+
deferred.indexOf(WASI_STAMP_CALL) <
|
|
1437
|
+
deferred.indexOf('exports: __napiModule.exports'),
|
|
1438
|
+
)
|
|
1439
|
+
})
|
|
1440
|
+
|
|
1441
|
+
test('every mutating loader stamps the binding target through the guard', (t) => {
|
|
1442
|
+
const cases: Array<{ name: string; code: string; call?: string }> = [
|
|
1443
|
+
{
|
|
1444
|
+
name: 'root cjs',
|
|
1445
|
+
code: createCjsBinding('test', '@scope/test', ['sum'], '1.0.0'),
|
|
1446
|
+
call: ROOT_CJS_STAMP_CALL,
|
|
1447
|
+
},
|
|
1448
|
+
{
|
|
1449
|
+
name: 'wasi node cjs',
|
|
1450
|
+
code: createWasiBinding('test', '@scope/test'),
|
|
1451
|
+
call: WASI_CJS_STAMP_LINE,
|
|
1452
|
+
},
|
|
1453
|
+
{
|
|
1454
|
+
name: 'wasi browser esm',
|
|
1455
|
+
code: createWasiBrowserBinding('test'),
|
|
1456
|
+
call: WASI_STAMP_CALL,
|
|
1457
|
+
},
|
|
1458
|
+
{
|
|
1459
|
+
name: 'wasi deferred esm',
|
|
1460
|
+
code: createWasiDeferredBrowserBinding('test'),
|
|
1461
|
+
call: WASI_STAMP_CALL,
|
|
1462
|
+
},
|
|
1463
|
+
]
|
|
1464
|
+
for (const { name, code, call } of cases) {
|
|
1465
|
+
assertValidJS(t, code, `${name} stamp guard`)
|
|
1466
|
+
t.is(
|
|
1467
|
+
code.split(STAMP_HELPER_DECL).length - 1,
|
|
1468
|
+
1,
|
|
1469
|
+
`${name} must emit the guard exactly once`,
|
|
1470
|
+
)
|
|
1471
|
+
if (call) {
|
|
1472
|
+
t.is(
|
|
1473
|
+
code.split(call).length - 1,
|
|
1474
|
+
1,
|
|
1475
|
+
`${name} must stamp exactly once, through the guard`,
|
|
1476
|
+
)
|
|
1477
|
+
}
|
|
1478
|
+
// [[Define]], not [[Set]]: an ordinary assignment walks the prototype
|
|
1479
|
+
// chain, so an inherited accessor on a user-controlled exports object could
|
|
1480
|
+
// swallow the marker or throw and fail an otherwise successful load
|
|
1481
|
+
t.true(
|
|
1482
|
+
code.includes(
|
|
1483
|
+
"Object.defineProperty(exportsObject, '__napiBindingTarget'",
|
|
1484
|
+
),
|
|
1485
|
+
`${name} must define the marker as an own data property`,
|
|
1486
|
+
)
|
|
1487
|
+
t.false(
|
|
1488
|
+
code.includes('exportsObject.__napiBindingTarget = target'),
|
|
1489
|
+
`${name} must not stamp the marker through an ordinary assignment`,
|
|
1490
|
+
)
|
|
1491
|
+
// an addon's exports object is user-controlled: nothing may write the
|
|
1492
|
+
// marker onto it without going through the guard
|
|
1493
|
+
t.false(
|
|
1494
|
+
UNGUARDED_MODULE_EXPORTS_STAMP.test(code),
|
|
1495
|
+
`${name} must not assign the marker onto module.exports directly`,
|
|
1496
|
+
)
|
|
1497
|
+
t.false(
|
|
1498
|
+
code.includes('__napiModule.exports.__napiBindingTarget ='),
|
|
1499
|
+
`${name} must not assign the marker onto the emnapi exports directly`,
|
|
1500
|
+
)
|
|
1501
|
+
}
|
|
1502
|
+
})
|
|
1503
|
+
|
|
1504
|
+
test('the node WASI loader stamps inside the rollback boundary', (t) => {
|
|
1505
|
+
// Anything the guard throws — a conflicting `#[napi(module_exports)]` export,
|
|
1506
|
+
// or an addon accessor whose setter refuses the write — has to land in the
|
|
1507
|
+
// initialization `try`. From outside it the throw escapes with the emnapi
|
|
1508
|
+
// context built and the process 'exit' listener installed, so a failed
|
|
1509
|
+
// `require()` leaks an initialized WASI environment nothing can reach.
|
|
1510
|
+
const code = createWasiBinding('test', '@scope/test')
|
|
1511
|
+
assertValidJS(t, code, 'wasi node cjs rollback boundary')
|
|
1512
|
+
const initializationCatch = '\n} catch (error) {'
|
|
1513
|
+
t.is(
|
|
1514
|
+
code.split(initializationCatch).length - 1,
|
|
1515
|
+
1,
|
|
1516
|
+
'the top-level initialization catch must be unambiguous',
|
|
1517
|
+
)
|
|
1518
|
+
// exactly one, so nothing can stamp a second time outside the boundary
|
|
1519
|
+
t.is(code.split('module.exports.__napiBindingTarget =').length - 1, 1)
|
|
1520
|
+
const stamp = code.indexOf(WASI_CJS_STAMP_LINE)
|
|
1521
|
+
t.true(stamp > code.indexOf('__publishWasiDispose(__napiModule.exports)'))
|
|
1522
|
+
t.true(stamp < code.indexOf(WASI_EXIT_LISTENER_CALL))
|
|
1523
|
+
t.true(stamp < code.indexOf(initializationCatch))
|
|
1524
|
+
// and the rollback the catch runs is the one that tears the environment down
|
|
1525
|
+
t.true(code.includes('__runWasiInitializationRollback(rollback)'))
|
|
1526
|
+
})
|
|
1527
|
+
|
|
1528
|
+
/**
|
|
1529
|
+
* Every loader that stamps an addon-owned exports object, with the marker that
|
|
1530
|
+
* ends its initialization guard and the host installation that must precede the
|
|
1531
|
+
* stamp. A host install hands that same object to addon-provided registration
|
|
1532
|
+
* functions, which can put anything on it — including this marker — so a stamp
|
|
1533
|
+
* placed before them reads a state that is not final.
|
|
1534
|
+
*/
|
|
1535
|
+
const HOST_INSTALL_ORDER_CASES = [
|
|
1536
|
+
{
|
|
1537
|
+
name: 'wasi node cjs',
|
|
1538
|
+
build: (asyncRuntime: boolean) =>
|
|
1539
|
+
createWasiBinding(
|
|
1540
|
+
'test',
|
|
1541
|
+
'@scope/test',
|
|
1542
|
+
undefined,
|
|
1543
|
+
undefined,
|
|
1544
|
+
undefined,
|
|
1545
|
+
undefined,
|
|
1546
|
+
undefined,
|
|
1547
|
+
asyncRuntime,
|
|
1548
|
+
),
|
|
1549
|
+
stamp: WASI_CJS_STAMP_LINE,
|
|
1550
|
+
hostInstall: '__installCurrentThreadHosts(',
|
|
1551
|
+
endOfGuard: WASI_EXIT_LISTENER_CALL,
|
|
1552
|
+
},
|
|
1553
|
+
{
|
|
1554
|
+
name: 'wasi browser esm',
|
|
1555
|
+
build: (asyncRuntime: boolean) =>
|
|
1556
|
+
createWasiBrowserBinding(
|
|
1557
|
+
'test',
|
|
1558
|
+
undefined,
|
|
1559
|
+
undefined,
|
|
1560
|
+
undefined,
|
|
1561
|
+
undefined,
|
|
1562
|
+
undefined,
|
|
1563
|
+
undefined,
|
|
1564
|
+
undefined,
|
|
1565
|
+
undefined,
|
|
1566
|
+
asyncRuntime,
|
|
1567
|
+
),
|
|
1568
|
+
stamp: WASI_STAMP_CALL,
|
|
1569
|
+
hostInstall: '__installCurrentThreadHosts(',
|
|
1570
|
+
endOfGuard: '\n} catch (error) {',
|
|
1571
|
+
},
|
|
1572
|
+
{
|
|
1573
|
+
name: 'wasi deferred esm',
|
|
1574
|
+
build: (asyncRuntime: boolean) =>
|
|
1575
|
+
createWasiDeferredBrowserBinding(
|
|
1576
|
+
'test',
|
|
1577
|
+
undefined,
|
|
1578
|
+
undefined,
|
|
1579
|
+
undefined,
|
|
1580
|
+
undefined,
|
|
1581
|
+
asyncRuntime,
|
|
1582
|
+
),
|
|
1583
|
+
stamp: WASI_STAMP_CALL,
|
|
1584
|
+
hostInstall: '__registerWorkerdCurrentThreadTaskHost(',
|
|
1585
|
+
endOfGuard: "__lifecycleState === 'pending'",
|
|
1586
|
+
},
|
|
1587
|
+
] as const
|
|
1588
|
+
|
|
1589
|
+
for (const {
|
|
1590
|
+
name,
|
|
1591
|
+
build,
|
|
1592
|
+
stamp,
|
|
1593
|
+
hostInstall,
|
|
1594
|
+
endOfGuard,
|
|
1595
|
+
} of HOST_INSTALL_ORDER_CASES) {
|
|
1596
|
+
test(`${name} stamps the binding object after its host installation`, (t) => {
|
|
1597
|
+
const withHosts = build(true)
|
|
1598
|
+
assertValidJS(t, withHosts, `${name} asyncRuntime stamp order`)
|
|
1599
|
+
const hostInstallAt = withHosts.indexOf(hostInstall)
|
|
1600
|
+
t.true(hostInstallAt > -1, 'the asyncRuntime host install must be emitted')
|
|
1601
|
+
const stampAt = withHosts.indexOf(stamp)
|
|
1602
|
+
t.true(stampAt > hostInstallAt, 'the stamp must follow the host install')
|
|
1603
|
+
t.true(
|
|
1604
|
+
stampAt < withHosts.indexOf(endOfGuard),
|
|
1605
|
+
'the stamp must stay inside the initialization guard',
|
|
1606
|
+
)
|
|
1607
|
+
|
|
1608
|
+
// and without an async runtime the stamp keeps that same place: last thing
|
|
1609
|
+
// before the guard closes, so nothing can reshape the object behind it
|
|
1610
|
+
const withoutHosts = build(false)
|
|
1611
|
+
assertValidJS(t, withoutHosts, `${name} stamp order`)
|
|
1612
|
+
t.is(withoutHosts.indexOf(hostInstall), -1)
|
|
1613
|
+
t.true(withoutHosts.indexOf(stamp) < withoutHosts.indexOf(endOfGuard))
|
|
1614
|
+
})
|
|
1615
|
+
}
|
|
1616
|
+
|
|
1617
|
+
test('the deferred loader marks the binding without requiring an extensible exports object', (t) => {
|
|
1618
|
+
const deferred = createWasiDeferredBrowserBinding('test')
|
|
1619
|
+
assertValidJS(t, deferred, 'deferred guarded stamp')
|
|
1620
|
+
// a `#[napi(module_exports)]` hook may have sealed or frozen this object
|
|
1621
|
+
t.true(deferred.includes('if (!Object.isExtensible(exportsObject)) {'))
|
|
1622
|
+
// and it may have claimed the name, which is a hard error, not a silent
|
|
1623
|
+
// overwrite — with a stable `code` to branch on
|
|
1624
|
+
t.true(
|
|
1625
|
+
deferred.includes(
|
|
1626
|
+
"Object.prototype.hasOwnProperty.call(exportsObject, '__napiBindingTarget')",
|
|
1627
|
+
),
|
|
1628
|
+
)
|
|
1629
|
+
t.true(deferred.includes("error.code = 'ERR_NAPI_BINDING_TARGET_CONFLICT'"))
|
|
1630
|
+
})
|
|
1631
|
+
|
|
1632
|
+
test('every emitted loader says what its own entry reports after a skipped stamp', (t) => {
|
|
1633
|
+
// The runtime is pinned by build.spec.ts (`a frozen addon keeps
|
|
1634
|
+
// __napiBindingTarget importable, just undefined`): both CommonJS entries
|
|
1635
|
+
// replace `module.exports` with the object the stamp was skipped on, so the
|
|
1636
|
+
// value is absent there, while the ESM loaders keep a module-level export.
|
|
1637
|
+
// The comment shipped inside every loader has to describe that split rather
|
|
1638
|
+
// than promise the module export survives everywhere.
|
|
1639
|
+
for (const [name, code] of [
|
|
1640
|
+
['root cjs', createCjsBinding('test', '@scope/test', ['sum'], '1.0.0')],
|
|
1641
|
+
['wasi node cjs', createWasiBinding('test', '@scope/test')],
|
|
1642
|
+
['wasi browser esm', createWasiBrowserBinding('test')],
|
|
1643
|
+
['wasi deferred esm', createWasiDeferredBrowserBinding('test')],
|
|
1644
|
+
] as const) {
|
|
1645
|
+
assertValidJS(t, code, `${name} skip contract`)
|
|
1646
|
+
t.false(
|
|
1647
|
+
code.includes('module export still reports it'),
|
|
1648
|
+
`${name} must not claim every entry still reports the target`,
|
|
1649
|
+
)
|
|
1650
|
+
t.true(
|
|
1651
|
+
code.includes('while the CommonJS entries hand back'),
|
|
1652
|
+
`${name} must say the CommonJS entries lose the value`,
|
|
1653
|
+
)
|
|
1654
|
+
}
|
|
1655
|
+
|
|
1656
|
+
// and the root CommonJS loader's own note about its lexer-visible assignment:
|
|
1657
|
+
// that assignment lands on the loader's own `module.exports`, so it always
|
|
1658
|
+
// succeeds and the alias on the next line is what discards it
|
|
1659
|
+
const cjs = createCjsBinding('test', '@scope/test', ['sum'], '1.0.0')
|
|
1660
|
+
t.false(
|
|
1661
|
+
cjs.includes('is a silent no-op'),
|
|
1662
|
+
'the assignment is not a no-op; its target is extensible',
|
|
1663
|
+
)
|
|
1664
|
+
t.true(
|
|
1665
|
+
cjs.includes('The assignment itself always succeeds'),
|
|
1666
|
+
'the root CommonJS loader must not call its own assignment a no-op',
|
|
1667
|
+
)
|
|
1668
|
+
t.true(
|
|
1669
|
+
cjs.includes('own, still extensible `module.exports`'),
|
|
1670
|
+
'the root CommonJS loader must name its real assignment target',
|
|
1671
|
+
)
|
|
1672
|
+
})
|
|
1673
|
+
|
|
1674
|
+
test('NAPI_RS_NATIVE_LIBRARY_PATH keeps the flavor its override reports', (t) => {
|
|
1675
|
+
const adoption = `__napiLoadedBindingTarget =
|
|
1676
|
+
overrideBinding && typeof overrideBinding.__napiBindingTarget === 'string'
|
|
1677
|
+
? overrideBinding.__napiBindingTarget
|
|
1678
|
+
: 'native'`
|
|
1679
|
+
for (const [name, code] of [
|
|
1680
|
+
['cjs', createCjsBinding('test', '@scope/test', ['sum'], '1.0.0')],
|
|
1681
|
+
['esm', createEsmBinding('test', '@scope/test', ['sum'], '1.0.0')],
|
|
1682
|
+
] as const) {
|
|
1683
|
+
assertValidJS(t, code, `${name} override binding target`)
|
|
1684
|
+
t.true(code.includes(adoption), `${name} must adopt the override's target`)
|
|
1685
|
+
// the override result must not be returned before it is inspected
|
|
1686
|
+
t.false(
|
|
1687
|
+
code.includes('return require(process.env.NAPI_RS_NATIVE_LIBRARY_PATH)'),
|
|
1688
|
+
`${name} must bind the override before returning it`,
|
|
1689
|
+
)
|
|
1690
|
+
}
|
|
1691
|
+
})
|
|
1692
|
+
|
|
341
1693
|
test('WASI worker template matches the CJS/ESM quote and semicolon dialect', (t) => {
|
|
342
1694
|
t.true(WASI_WORKER_TEMPLATE.includes("import fs from 'node:fs'"))
|
|
343
1695
|
t.false(WASI_WORKER_TEMPLATE.includes('from "node:fs"'))
|