@napi-rs/cli 3.10.0 → 3.10.2
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 +445 -26
- package/dist/index.cjs +445 -26
- package/dist/index.d.cts +1 -1
- package/dist/index.js +445 -26
- package/docs/wasi.md +17 -0
- package/package.json +1 -1
- package/src/api/__tests__/__snapshots__/templates.spec.ts.md +1492 -86
- package/src/api/__tests__/__snapshots__/templates.spec.ts.snap +0 -0
- package/src/api/__tests__/templates.spec.ts +223 -0
- package/src/api/templates/load-wasi-template.ts +473 -36
- package/src/utils/__tests__/reconciliation.spec.ts +9 -2
|
@@ -129,6 +129,35 @@ function __disposeCurrentThreadHosts() {
|
|
|
129
129
|
return `
|
|
130
130
|
const __wasiDisposeSymbol = Symbol.for('${WASI_DISPOSE_SYMBOL}')
|
|
131
131
|
const __wasiWorkers = new Set()
|
|
132
|
+
// The thread manager has to be reachable *before* anything that can throw
|
|
133
|
+
// during load or registration. Initialization can fail after the pool has
|
|
134
|
+
// already spawned workers, and the rollback still has to mark their
|
|
135
|
+
// terminations as expected — but \`__napiModule\` is assigned only when
|
|
136
|
+
// instantiation RETURNS, so on exactly that path it is still undefined. A
|
|
137
|
+
// plugin factory runs while the emnapi module is being created, before the
|
|
138
|
+
// wasm is loaded and before any registration function runs, and its context
|
|
139
|
+
// carries the very same manager instance.
|
|
140
|
+
let __wasiThreadManager
|
|
141
|
+
|
|
142
|
+
function __captureWasiThreadManager(context) {
|
|
143
|
+
if (context && context.PThread) {
|
|
144
|
+
__wasiThreadManager = context.PThread
|
|
145
|
+
}
|
|
146
|
+
return {}
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
function __getWasiThreadManager() {
|
|
150
|
+
const manager =
|
|
151
|
+
__wasiThreadManager !== undefined
|
|
152
|
+
? __wasiThreadManager
|
|
153
|
+
: __napiModule
|
|
154
|
+
? __napiModule.PThread
|
|
155
|
+
: undefined
|
|
156
|
+
if (manager && typeof manager.terminateWorker === 'function') {
|
|
157
|
+
return manager
|
|
158
|
+
}
|
|
159
|
+
return undefined
|
|
160
|
+
}
|
|
132
161
|
let __napiInstance
|
|
133
162
|
let __emnapiContextDestroyed = false
|
|
134
163
|
let __emnapiContextDestroyPromise
|
|
@@ -138,6 +167,7 @@ let __emnapiWasmEnvCleanupRan = false
|
|
|
138
167
|
let __emnapiWasmEnvCleanupDrained = false
|
|
139
168
|
let __emnapiWasmEnvCleanupDrainPromise
|
|
140
169
|
let __wasiDisposed = false
|
|
170
|
+
let __wasiAsyncWorkDrainPromise
|
|
141
171
|
let __wasiDisposePromise
|
|
142
172
|
let __completeWasiDisposal = function () {}
|
|
143
173
|
// Overridden by loader flavors that have a last-resort reclaim for a rollback
|
|
@@ -262,6 +292,24 @@ const __scheduleMacrotask = (function () {
|
|
|
262
292
|
}
|
|
263
293
|
})()
|
|
264
294
|
|
|
295
|
+
// A real, *referenced* timer, for waits that must let the whole host make
|
|
296
|
+
// progress between looks — the async-work drain polls the addon rather than
|
|
297
|
+
// interleaving with the @emnapi/core dispatch, so a zero-delay macrotask there
|
|
298
|
+
// would spin the loop instead of yielding it. Falls back to the macrotask
|
|
299
|
+
// scheduler on a host without timers.
|
|
300
|
+
function __scheduleTimer(callback, delay) {
|
|
301
|
+
const setTimer = globalThis.setTimeout
|
|
302
|
+
if (typeof setTimer !== 'function') {
|
|
303
|
+
__scheduleMacrotask(callback)
|
|
304
|
+
return
|
|
305
|
+
}
|
|
306
|
+
try {
|
|
307
|
+
setTimer(callback, delay)
|
|
308
|
+
} catch {
|
|
309
|
+
__scheduleMacrotask(callback)
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
|
|
265
313
|
// Turns to wait for while the addon still reports queued settlements. Reaching
|
|
266
314
|
// zero is the only success. A counter still nonzero at this bound rejects the
|
|
267
315
|
// disposal as retryable (\`ERR_NAPI_WASI_CLEANUP_PENDING\`) rather than
|
|
@@ -419,13 +467,209 @@ ${disposeCurrentThreadHosts}\
|
|
|
419
467
|
return destroyPromise
|
|
420
468
|
}
|
|
421
469
|
|
|
470
|
+
/**
|
|
471
|
+
* Holds the event loop open until \`work\` settles.
|
|
472
|
+
*
|
|
473
|
+
* Nothing else can: the pool workers are deliberately unreferenced so an idle
|
|
474
|
+
* binding cannot keep a process alive, and referencing them again for the
|
|
475
|
+
* termination does not hold either — emnapi unreferences a worker the moment it
|
|
476
|
+
* reports \`async-thread-ready\`, which for a worker that was still starting
|
|
477
|
+
* lands *after* the termination began. Without a handle of its own, an
|
|
478
|
+
* \`await dispose()\` with nothing else pending exits the process with its
|
|
479
|
+
* promise unsettled, and everything after the \`await\` is skipped.
|
|
480
|
+
*
|
|
481
|
+
* The timer is cleared as soon as the work settles, so this never outlives the
|
|
482
|
+
* disposal that asked for it.
|
|
483
|
+
*/
|
|
484
|
+
function __keepEventLoopAliveUntil(work) {
|
|
485
|
+
const setTimer = globalThis.setInterval
|
|
486
|
+
const clearTimer = globalThis.clearInterval
|
|
487
|
+
if (typeof setTimer !== 'function' || typeof clearTimer !== 'function') {
|
|
488
|
+
return work
|
|
489
|
+
}
|
|
490
|
+
let timer
|
|
491
|
+
try {
|
|
492
|
+
timer = setTimer(function () {}, 50)
|
|
493
|
+
} catch {
|
|
494
|
+
return work
|
|
495
|
+
}
|
|
496
|
+
const release = function () {
|
|
497
|
+
try {
|
|
498
|
+
clearTimer(timer)
|
|
499
|
+
} catch {}
|
|
500
|
+
}
|
|
501
|
+
return work.then(
|
|
502
|
+
(value) => {
|
|
503
|
+
release()
|
|
504
|
+
return value
|
|
505
|
+
},
|
|
506
|
+
(error) => {
|
|
507
|
+
release()
|
|
508
|
+
throw error
|
|
509
|
+
},
|
|
510
|
+
)
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
// How often to re-read \`napi_wasm_async_work_pending\` while waiting. The wait
|
|
514
|
+
// ends when the addon reports zero, so this only decides how promptly disposal
|
|
515
|
+
// notices — not how long it waits.
|
|
516
|
+
const __WASI_ASYNC_WORK_POLL_INTERVAL_MS = 1
|
|
517
|
+
|
|
518
|
+
/**
|
|
519
|
+
* Settles this addon's outstanding \`napi_async_work\` before the teardown that
|
|
520
|
+
* would strand it.
|
|
521
|
+
*
|
|
522
|
+
* \`napi_prepare_wasm_env_cleanup\` does not cover async work, and nothing about
|
|
523
|
+
* it is observable from JavaScript: the threadless archive resolves
|
|
524
|
+
* \`napi_*_async_work\` through the \`@emnapi/core\` plugins, but the threaded one
|
|
525
|
+
* links the C \`async_work.c\` on the uv threadpool, so there the wasm neither
|
|
526
|
+
* imports nor exports those symbols and the only brackets a loader could watch
|
|
527
|
+
* (\`_emnapi_ctx_*_waiting_request_counter\`) are shared with threadsafe
|
|
528
|
+
* functions. The addon is the one place both flavors go through, so it answers
|
|
529
|
+
* for both, through the same kind of handshake the settlement drain uses:
|
|
530
|
+
*
|
|
531
|
+
* - \`napi_wasm_cancel_pending_async_work()\` cancels what no thread has
|
|
532
|
+
* started. Those completion callbacks run with \`napi_cancelled\`, which
|
|
533
|
+
* napi-rs turns into a promise rejected with an \`AbortError\`.
|
|
534
|
+
* - \`napi_wasm_async_work_pending()\` counts what is still owed a completion
|
|
535
|
+
* callback. Work already executing refuses cancellation and stays counted
|
|
536
|
+
* until it finishes normally — which it can, because this runs before the
|
|
537
|
+
* barrier, before \`Context.destroy()\` and before anything is terminated.
|
|
538
|
+
*
|
|
539
|
+
* Both exports are optional: an addon built against a napi crate that predates
|
|
540
|
+
* them drains nothing and keeps the previous behavior, exactly as the
|
|
541
|
+
* \`napi_wasm_env_cleanup_pending\` handshake degrades.
|
|
542
|
+
*
|
|
543
|
+
* Returns nothing when there is nothing outstanding, which keeps disposal
|
|
544
|
+
* synchronous in the common case. The promise it returns otherwise never
|
|
545
|
+
* rejects.
|
|
546
|
+
*
|
|
547
|
+
* The wait has no deadline, and that is the point: giving up would destroy the
|
|
548
|
+
* environment with a completion callback still owed, which is the stranding
|
|
549
|
+
* this exists to prevent. A task whose \`execute\` never returns already keeps an
|
|
550
|
+
* *undisposed* process alive in exactly the same way, so disposal inherits that
|
|
551
|
+
* rather than inventing a bound it cannot honor.
|
|
552
|
+
*
|
|
553
|
+
* Safe to call from inside a completion callback, which is reachable: settling
|
|
554
|
+
* a task runs addon code that can re-enter JavaScript — a setter on the value
|
|
555
|
+
* being handed back, a threadsafe-function callback — and that JavaScript can
|
|
556
|
+
* call \`dispose()\`. Two things make it terminate rather than wait on itself:
|
|
557
|
+
*
|
|
558
|
+
* - The addon keeps a work registered until its completion callback
|
|
559
|
+
* *finishes*, so the count read here is at least one and this takes the
|
|
560
|
+
* polling path instead of declaring the environment drained and tearing it
|
|
561
|
+
* down from inside the frame that is still settling a promise.
|
|
562
|
+
* - The poll is a timer, so it cannot run until the callback has returned to
|
|
563
|
+
* the host — by which time that work has left the registry. The count the
|
|
564
|
+
* next poll reads is the one taken after the callback finished.
|
|
565
|
+
*
|
|
566
|
+
* \`__disposeWasiBinding\` hands every caller the same in-flight promise, so the
|
|
567
|
+
* nested call joins this disposal rather than starting a second one.
|
|
568
|
+
*/
|
|
569
|
+
function __drainWasiAsyncWork() {
|
|
570
|
+
if (__wasiAsyncWorkDrainPromise !== undefined) {
|
|
571
|
+
return __wasiAsyncWorkDrainPromise
|
|
572
|
+
}
|
|
573
|
+
const exports = __napiInstance?.exports
|
|
574
|
+
const pending = exports?.napi_wasm_async_work_pending
|
|
575
|
+
const cancelPending = exports?.napi_wasm_cancel_pending_async_work
|
|
576
|
+
if (typeof pending !== 'function' || typeof cancelPending !== 'function') {
|
|
577
|
+
return
|
|
578
|
+
}
|
|
579
|
+
|
|
580
|
+
const readPending = () => {
|
|
581
|
+
try {
|
|
582
|
+
return pending()
|
|
583
|
+
} catch (error) {
|
|
584
|
+
// A trap is the only way this call fails: it reads a counter and cannot
|
|
585
|
+
// allocate or call back into JavaScript. A trapped instance can no longer
|
|
586
|
+
// run anything, so its outstanding work is unreachable by definition —
|
|
587
|
+
// there is nothing left to wait for, and refusing to dispose would only
|
|
588
|
+
// keep a dead instance and its stuck counter alive. Best-effort here is
|
|
589
|
+
// the honest answer, and it is what disposal did before this drain
|
|
590
|
+
// existed.
|
|
591
|
+
//
|
|
592
|
+
// Only a trap. Anything else means the export is not what this loader
|
|
593
|
+
// thinks it is, which is a defect worth surfacing rather than disposing
|
|
594
|
+
// over.
|
|
595
|
+
if (error instanceof globalThis.WebAssembly.RuntimeError) {
|
|
596
|
+
return 0
|
|
597
|
+
}
|
|
598
|
+
throw error
|
|
599
|
+
}
|
|
600
|
+
}
|
|
601
|
+
|
|
602
|
+
if (!readPending()) {
|
|
603
|
+
return
|
|
604
|
+
}
|
|
605
|
+
try {
|
|
606
|
+
cancelPending()
|
|
607
|
+
} catch {
|
|
608
|
+
// Cancellation is an optimization: it bounds the wait by the work already
|
|
609
|
+
// executing. Failing it only means waiting for the whole queue instead.
|
|
610
|
+
}
|
|
611
|
+
if (!readPending()) {
|
|
612
|
+
return
|
|
613
|
+
}
|
|
614
|
+
|
|
615
|
+
const drainPromise = __keepEventLoopAliveUntil(
|
|
616
|
+
(async () => {
|
|
617
|
+
while (readPending()) {
|
|
618
|
+
await new Promise((resolve) => {
|
|
619
|
+
__scheduleTimer(resolve, __WASI_ASYNC_WORK_POLL_INTERVAL_MS)
|
|
620
|
+
})
|
|
621
|
+
}
|
|
622
|
+
})(),
|
|
623
|
+
).then(
|
|
624
|
+
() => {
|
|
625
|
+
__wasiAsyncWorkDrainPromise = undefined
|
|
626
|
+
},
|
|
627
|
+
(error) => {
|
|
628
|
+
// A wait that could not run is not a wait that finished. The only way
|
|
629
|
+
// here is a host whose timers and macrotask primitives all refuse, and
|
|
630
|
+
// the work is still outstanding — reporting success would destroy the
|
|
631
|
+
// environment over it, which is the stranding this exists to prevent.
|
|
632
|
+
// Reject instead: disposal stays retryable, and the context is not
|
|
633
|
+
// destroyed. Clearing the memo first is what makes the retry re-run this.
|
|
634
|
+
__wasiAsyncWorkDrainPromise = undefined
|
|
635
|
+
throw error
|
|
636
|
+
},
|
|
637
|
+
)
|
|
638
|
+
__wasiAsyncWorkDrainPromise = drainPromise
|
|
639
|
+
return drainPromise
|
|
640
|
+
}
|
|
641
|
+
|
|
642
|
+
/**
|
|
643
|
+
* \`@emnapi/wasi-threads\` counts a worker exit as expected only when its own
|
|
644
|
+
* thread manager performed the termination. A bare \`worker.terminate()\` reaches
|
|
645
|
+
* the manager's \`exit\` listener instead, which reports
|
|
646
|
+
* \`worker (tid = N) sent an error! ... stopped with exit code 1\` and rethrows
|
|
647
|
+
* inside the emit — aborting the \`once('exit')\` that backs the terminate
|
|
648
|
+
* promise, so disposal never settles and the process dies with an uncaught
|
|
649
|
+
* exception. Mark the termination through the manager first.
|
|
650
|
+
*
|
|
651
|
+
* The manager comes from \`__getWasiThreadManager\`, not from \`__napiModule\`:
|
|
652
|
+
* the initialization rollback runs on the one path where instantiation never
|
|
653
|
+
* returned, so \`__napiModule\` is still undefined there while the workers it
|
|
654
|
+
* spawned are already registered and loaded.
|
|
655
|
+
*
|
|
656
|
+
* Not \`terminateAllThreads()\`: that one recreates the pool it just shut down.
|
|
657
|
+
*/
|
|
422
658
|
function __terminateWasiWorkers() {
|
|
423
659
|
const cleanupErrors = []
|
|
424
660
|
const pending = []
|
|
661
|
+
const threadManager = __getWasiThreadManager()
|
|
425
662
|
|
|
426
663
|
for (const worker of __wasiWorkers) {
|
|
427
664
|
let result
|
|
428
665
|
try {
|
|
666
|
+
if (threadManager) {
|
|
667
|
+
threadManager.terminateWorker(worker)
|
|
668
|
+
// \`terminateWorker\` leaves behind a reporter that logs every message
|
|
669
|
+
// still queued on the port, which Node flushes on exit. Nothing is
|
|
670
|
+
// listening for those any more.
|
|
671
|
+
worker.onmessage = undefined
|
|
672
|
+
}
|
|
429
673
|
result = worker.terminate()
|
|
430
674
|
} catch (error) {
|
|
431
675
|
cleanupErrors.push(error)
|
|
@@ -455,7 +699,9 @@ function __terminateWasiWorkers() {
|
|
|
455
699
|
)
|
|
456
700
|
}
|
|
457
701
|
}
|
|
458
|
-
return pending.length > 0
|
|
702
|
+
return pending.length > 0
|
|
703
|
+
? __keepEventLoopAliveUntil(Promise.all(pending)).then(finish)
|
|
704
|
+
: finish()
|
|
459
705
|
}
|
|
460
706
|
|
|
461
707
|
function __finishWasiDisposal() {
|
|
@@ -474,7 +720,7 @@ function __continueWasiDisposal() {
|
|
|
474
720
|
return __finishWasiDisposal()
|
|
475
721
|
}
|
|
476
722
|
|
|
477
|
-
function
|
|
723
|
+
function __cleanUpWasmEnvForWasiDisposal() {
|
|
478
724
|
// Run the pre-teardown barrier, then let the settlements it queued actually
|
|
479
725
|
// reach JavaScript, and only then destroy the environment. Doing these two
|
|
480
726
|
// back to back is what strands them.
|
|
@@ -486,6 +732,21 @@ function __startWasiDisposal() {
|
|
|
486
732
|
return __continueWasiDisposal()
|
|
487
733
|
}
|
|
488
734
|
|
|
735
|
+
function __startWasiDisposal() {
|
|
736
|
+
// Outstanding \`napi_async_work\` goes first, while the environment is still
|
|
737
|
+
// completely live: the completion callbacks run addon code, and everything
|
|
738
|
+
// after this point takes that away from them — the barrier shuts the async
|
|
739
|
+
// runtime down, \`Context.destroy()\` stops JavaScript calls, and terminating
|
|
740
|
+
// the pool threads removes what would have reported the work finished.
|
|
741
|
+
const asyncWorkResult = __drainWasiAsyncWork()
|
|
742
|
+
if (__isThenable(asyncWorkResult)) {
|
|
743
|
+
return Promise.resolve(asyncWorkResult).then(
|
|
744
|
+
__cleanUpWasmEnvForWasiDisposal,
|
|
745
|
+
)
|
|
746
|
+
}
|
|
747
|
+
return __cleanUpWasmEnvForWasiDisposal()
|
|
748
|
+
}
|
|
749
|
+
|
|
489
750
|
/**
|
|
490
751
|
* Disposes this generated WASI binding.
|
|
491
752
|
*
|
|
@@ -627,29 +888,60 @@ function __retainFailedWasiRollback(cleanupErrors) {
|
|
|
627
888
|
* bug with no upper bound, while the retained bookkeeping is bounded by the page.
|
|
628
889
|
*/
|
|
629
890
|
function __rollbackWasiInitialization() {
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
891
|
+
// The environment teardown this rollback performs, kept nested so it cannot
|
|
892
|
+
// be reached without the async-work drain below running first.
|
|
893
|
+
function __rollbackWasmEnvForWasiInitialization() {
|
|
894
|
+
const cleanupErrors = []
|
|
895
|
+
let drainResult
|
|
896
|
+
let settlementsUnreached = false
|
|
897
|
+
try {
|
|
898
|
+
__prepareWasmEnvCleanup()
|
|
899
|
+
drainResult = __drainWasmEnvCleanup()
|
|
900
|
+
} catch (cleanupError) {
|
|
901
|
+
cleanupErrors.push(cleanupError)
|
|
902
|
+
settlementsUnreached = true
|
|
903
|
+
}
|
|
904
|
+
if (__isThenable(drainResult)) {
|
|
905
|
+
return Promise.resolve(drainResult).then(
|
|
906
|
+
() => __destroyContextForWasiRollback(cleanupErrors),
|
|
907
|
+
(cleanupError) => {
|
|
908
|
+
cleanupErrors.push(cleanupError)
|
|
909
|
+
return __retainFailedWasiRollback(cleanupErrors)
|
|
910
|
+
},
|
|
911
|
+
)
|
|
912
|
+
}
|
|
913
|
+
if (settlementsUnreached) {
|
|
914
|
+
return __retainFailedWasiRollback(cleanupErrors)
|
|
915
|
+
}
|
|
916
|
+
return __destroyContextForWasiRollback(cleanupErrors)
|
|
917
|
+
}
|
|
918
|
+
|
|
919
|
+
// Same reason as \`__startWasiDisposal\`: a module-init hook can start async
|
|
920
|
+
// work before the load goes on to fail, and this rollback tears down exactly
|
|
921
|
+
// what those completions need. Settle them while everything is still live,
|
|
922
|
+
// before the barrier and the teardown above take that away.
|
|
923
|
+
//
|
|
924
|
+
// A drain that could not finish leaves async work possibly outstanding, and
|
|
925
|
+
// destroying the context over it would strand exactly what this rollback is
|
|
926
|
+
// there to settle. Stop short and retain instead — the same trade
|
|
927
|
+
// \`__rollbackWasmEnvForWasiInitialization\` makes for the settlement drain, so
|
|
928
|
+
// the context stays reclaimable by a retry or by this flavor's own
|
|
929
|
+
// last-resort teardown.
|
|
930
|
+
const __retainAfterAsyncWorkDrainFailure = (cleanupError) =>
|
|
931
|
+
__retainFailedWasiRollback([cleanupError])
|
|
932
|
+
let asyncWorkResult
|
|
633
933
|
try {
|
|
634
|
-
|
|
635
|
-
drainResult = __drainWasmEnvCleanup()
|
|
934
|
+
asyncWorkResult = __drainWasiAsyncWork()
|
|
636
935
|
} catch (cleanupError) {
|
|
637
|
-
|
|
638
|
-
settlementsUnreached = true
|
|
936
|
+
return __retainAfterAsyncWorkDrainFailure(cleanupError)
|
|
639
937
|
}
|
|
640
|
-
if (__isThenable(
|
|
641
|
-
return Promise.resolve(
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
cleanupErrors.push(cleanupError)
|
|
645
|
-
return __retainFailedWasiRollback(cleanupErrors)
|
|
646
|
-
},
|
|
938
|
+
if (__isThenable(asyncWorkResult)) {
|
|
939
|
+
return Promise.resolve(asyncWorkResult).then(
|
|
940
|
+
__rollbackWasmEnvForWasiInitialization,
|
|
941
|
+
__retainAfterAsyncWorkDrainFailure,
|
|
647
942
|
)
|
|
648
943
|
}
|
|
649
|
-
|
|
650
|
-
return __retainFailedWasiRollback(cleanupErrors)
|
|
651
|
-
}
|
|
652
|
-
return __destroyContextForWasiRollback(cleanupErrors)
|
|
944
|
+
return __rollbackWasmEnvForWasiInitialization()
|
|
653
945
|
}
|
|
654
946
|
`
|
|
655
947
|
}
|
|
@@ -777,14 +1069,27 @@ const __workerPoolSize = Math.max(
|
|
|
777
1069
|
const memoryName = threads ? '__sharedMemory' : '__wasmMemory'
|
|
778
1070
|
const asyncWorkPoolOption = ` asyncWorkPoolSize: ${threads ? '__asyncWorkPoolSize' : 0},
|
|
779
1071
|
`
|
|
780
|
-
//
|
|
781
|
-
// threadsafe
|
|
782
|
-
//
|
|
783
|
-
//
|
|
784
|
-
//
|
|
785
|
-
//
|
|
1072
|
+
// Which archive a build links decides who implements async work and
|
|
1073
|
+
// threadsafe functions — see `emnapi_link_library` in
|
|
1074
|
+
// `crates/build/src/wasi.rs`. Without threads it is `emnapi-basic-napi-rs`,
|
|
1075
|
+
// the "basic" model: both stay wasm imports, and the `@emnapi/core`
|
|
1076
|
+
// JavaScript plugins below are what resolves them; without the plugins
|
|
1077
|
+
// instantiation fails with a LinkError naming the missing import. With
|
|
1078
|
+
// threads it is `emnapi-napi-rs-mt`, the full composition, whose C
|
|
1079
|
+
// `async_work.c` / `threadsafe_function.c` run on the uv threadpool inside
|
|
1080
|
+
// the wasm — it imports none of those symbols, so the plugins are inert
|
|
1081
|
+
// there. They are passed in both modes because only the archive knows which
|
|
1082
|
+
// applies, and a plugin nothing imports costs nothing.
|
|
1083
|
+
//
|
|
1084
|
+
// This is why the async-work drain on the disposal path cannot live here:
|
|
1085
|
+
// with threads there is no JavaScript seam at all, so `__drainWasiAsyncWork`
|
|
1086
|
+
// asks the addon instead.
|
|
786
1087
|
const emnapiPluginImport = ` emnapiAsyncWorkPlugin as __emnapiAsyncWorkPlugin,\n emnapiTSFNPlugin as __emnapiTSFNPlugin,\n`
|
|
787
|
-
const emnapiPluginOption = ` plugins: [
|
|
1088
|
+
const emnapiPluginOption = ` plugins: [
|
|
1089
|
+
__captureWasiThreadManager,
|
|
1090
|
+
__emnapiAsyncWorkPlugin,
|
|
1091
|
+
__emnapiTSFNPlugin,
|
|
1092
|
+
],\n`
|
|
788
1093
|
const workerOption = threads
|
|
789
1094
|
? ` onCreateWorker() {
|
|
790
1095
|
const worker = new Worker(new URL('./wasi-worker-browser.mjs', import.meta.url), {
|
|
@@ -1394,6 +1699,101 @@ function __drainWasmEnvCleanup(__instance) {
|
|
|
1394
1699
|
})()
|
|
1395
1700
|
}
|
|
1396
1701
|
|
|
1702
|
+
// A real, *referenced* timer for the async-work wait below, which polls the
|
|
1703
|
+
// addon rather than interleaving with the @emnapi/core dispatch: a zero-delay
|
|
1704
|
+
// macrotask there would spin the loop instead of yielding it. Falls back to the
|
|
1705
|
+
// macrotask scheduler on a host without timers.
|
|
1706
|
+
function __scheduleTimer(__callback, __delay) {
|
|
1707
|
+
const __setTimer = globalThis.setTimeout
|
|
1708
|
+
if (typeof __setTimer !== 'function') {
|
|
1709
|
+
__scheduleMacrotask(__callback)
|
|
1710
|
+
return
|
|
1711
|
+
}
|
|
1712
|
+
try {
|
|
1713
|
+
__setTimer(__callback, __delay)
|
|
1714
|
+
} catch {
|
|
1715
|
+
__scheduleMacrotask(__callback)
|
|
1716
|
+
}
|
|
1717
|
+
}
|
|
1718
|
+
|
|
1719
|
+
// How often to re-read \`napi_wasm_async_work_pending\` while waiting. The wait
|
|
1720
|
+
// ends when the addon reports zero, so this only decides how promptly disposal
|
|
1721
|
+
// notices — not how long it waits.
|
|
1722
|
+
const __WASI_ASYNC_WORK_POLL_INTERVAL_MS = 1
|
|
1723
|
+
|
|
1724
|
+
/**
|
|
1725
|
+
* Settles this instance's outstanding \`napi_async_work\` before the teardown
|
|
1726
|
+
* that would strand it.
|
|
1727
|
+
*
|
|
1728
|
+
* The same hole the eager loaders had, and the same fix: the environment
|
|
1729
|
+
* cleanup barrier covers promise settlements queued on the threadsafe-function
|
|
1730
|
+
* queue and says nothing about \`napi_async_work\`, so destroying the context
|
|
1731
|
+
* with a work still outstanding leaves its completion callback with nowhere to
|
|
1732
|
+
* run and its promise unsettled forever.
|
|
1733
|
+
*
|
|
1734
|
+
* This flavor is threadless, so \`compute\` runs on the JavaScript thread inside
|
|
1735
|
+
* the macrotask that dequeued it: while this is running, any outstanding work
|
|
1736
|
+
* is queued rather than executing, and \`napi_wasm_cancel_pending_async_work\`
|
|
1737
|
+
* can take all of it. The poll is still what decides when the drain is done —
|
|
1738
|
+
* cancellation delivers those completions from a later macrotask, not
|
|
1739
|
+
* synchronously.
|
|
1740
|
+
*
|
|
1741
|
+
* Per instance, from that instance's own exports: two instances of this loader
|
|
1742
|
+
* have separate registries and must not wait on each other.
|
|
1743
|
+
*
|
|
1744
|
+
* Both exports are optional, so an addon built against a napi crate that
|
|
1745
|
+
* predates them keeps the previous behavior.
|
|
1746
|
+
*
|
|
1747
|
+
* Returns nothing when there is nothing outstanding, which keeps disposal
|
|
1748
|
+
* synchronous in the common case. No deadline: giving up would destroy the
|
|
1749
|
+
* environment with a completion callback still owed.
|
|
1750
|
+
*/
|
|
1751
|
+
function __drainInstanceAsyncWork(__instance) {
|
|
1752
|
+
const __exports = __instance?.exports
|
|
1753
|
+
const __pending = __exports?.napi_wasm_async_work_pending
|
|
1754
|
+
const __cancelPending = __exports?.napi_wasm_cancel_pending_async_work
|
|
1755
|
+
if (typeof __pending !== 'function' || typeof __cancelPending !== 'function') {
|
|
1756
|
+
return
|
|
1757
|
+
}
|
|
1758
|
+
|
|
1759
|
+
const __readPending = () => {
|
|
1760
|
+
try {
|
|
1761
|
+
return __pending()
|
|
1762
|
+
} catch (__error) {
|
|
1763
|
+
// A trap is the only way this call fails: it reads a counter and cannot
|
|
1764
|
+
// allocate or call back into JavaScript. A trapped instance can no longer
|
|
1765
|
+
// run anything, so its outstanding work is unreachable by definition and
|
|
1766
|
+
// best-effort is the honest answer. Anything else means the export is not
|
|
1767
|
+
// what this loader thinks it is — a defect worth surfacing.
|
|
1768
|
+
if (__error instanceof globalThis.WebAssembly.RuntimeError) {
|
|
1769
|
+
return 0
|
|
1770
|
+
}
|
|
1771
|
+
throw __error
|
|
1772
|
+
}
|
|
1773
|
+
}
|
|
1774
|
+
|
|
1775
|
+
if (!__readPending()) {
|
|
1776
|
+
return
|
|
1777
|
+
}
|
|
1778
|
+
try {
|
|
1779
|
+
__cancelPending()
|
|
1780
|
+
} catch {
|
|
1781
|
+
// Cancellation only bounds the wait. Failing it means waiting for the queue
|
|
1782
|
+
// to run instead, which the poll below already does.
|
|
1783
|
+
}
|
|
1784
|
+
if (!__readPending()) {
|
|
1785
|
+
return
|
|
1786
|
+
}
|
|
1787
|
+
|
|
1788
|
+
return (async () => {
|
|
1789
|
+
while (__readPending()) {
|
|
1790
|
+
await new Promise((__resolve) => {
|
|
1791
|
+
__scheduleTimer(__resolve, __WASI_ASYNC_WORK_POLL_INTERVAL_MS)
|
|
1792
|
+
})
|
|
1793
|
+
}
|
|
1794
|
+
})()
|
|
1795
|
+
}
|
|
1796
|
+
|
|
1397
1797
|
function __createLifecycleReentryError(__operation) {
|
|
1398
1798
|
const __error = new Error(
|
|
1399
1799
|
__operation +
|
|
@@ -1890,7 +2290,15 @@ ${instanceHostState}\
|
|
|
1890
2290
|
if (__lifecycleState !== 'failed') {
|
|
1891
2291
|
__lifecycleState = 'disposal'
|
|
1892
2292
|
}
|
|
1893
|
-
//
|
|
2293
|
+
// Outstanding async work first, while the environment is still completely
|
|
2294
|
+
// live: its completion callbacks run addon code, and the barrier and
|
|
2295
|
+
// \`Context.destroy()\` below each take that away. Undefined unless work is
|
|
2296
|
+
// outstanding.
|
|
2297
|
+
const __asyncWorkDrained = __drainInstanceAsyncWork(__napiInstance)
|
|
2298
|
+
if (__asyncWorkDrained) {
|
|
2299
|
+
await __asyncWorkDrained
|
|
2300
|
+
}
|
|
2301
|
+
// Then settle what the barrier cancelled, before the environment stops
|
|
1894
2302
|
// accepting JavaScript calls. Undefined unless something is queued, so
|
|
1895
2303
|
// an idle disposal is not delayed by a single turn.
|
|
1896
2304
|
const __drained = __prepareForDisposal()
|
|
@@ -2047,6 +2455,14 @@ ${installInstanceHosts}\
|
|
|
2047
2455
|
// so a failure before beforeInit costs no extra turn.
|
|
2048
2456
|
let __settlementsUnreached = false
|
|
2049
2457
|
try {
|
|
2458
|
+
// Registration can start async work too, and this path destroys the same
|
|
2459
|
+
// environment its completions need. A drain that cannot finish leaves the
|
|
2460
|
+
// work outstanding, so it counts as settlements unreached and stops the
|
|
2461
|
+
// rollback short of destroying, exactly like a failed barrier drain.
|
|
2462
|
+
const __asyncWorkDrained = __drainInstanceAsyncWork(__napiInstance)
|
|
2463
|
+
if (__asyncWorkDrained) {
|
|
2464
|
+
await __asyncWorkDrained
|
|
2465
|
+
}
|
|
2050
2466
|
const __drained = __prepareForDisposal()
|
|
2051
2467
|
if (__drained) {
|
|
2052
2468
|
await __drained
|
|
@@ -2530,17 +2946,34 @@ function __createWasiWorker(filename) {
|
|
|
2530
2946
|
}
|
|
2531
2947
|
})(),
|
|
2532
2948
|
reuseWorker: true,
|
|
2533
|
-
plugins: [
|
|
2949
|
+
plugins: [
|
|
2950
|
+
__captureWasiThreadManager,
|
|
2951
|
+
__emnapiAsyncWorkPlugin,
|
|
2952
|
+
__emnapiTSFNPlugin,
|
|
2953
|
+
],
|
|
2534
2954
|
`
|
|
2535
2955
|
: ` asyncWorkPoolSize: 0,
|
|
2536
|
-
plugins: [
|
|
2956
|
+
plugins: [
|
|
2957
|
+
__captureWasiThreadManager,
|
|
2958
|
+
__emnapiAsyncWorkPlugin,
|
|
2959
|
+
__emnapiTSFNPlugin,
|
|
2960
|
+
],
|
|
2537
2961
|
`
|
|
2538
|
-
//
|
|
2539
|
-
// threadsafe
|
|
2540
|
-
//
|
|
2541
|
-
//
|
|
2542
|
-
//
|
|
2543
|
-
//
|
|
2962
|
+
// Which archive a build links decides who implements async work and
|
|
2963
|
+
// threadsafe functions — see `emnapi_link_library` in
|
|
2964
|
+
// `crates/build/src/wasi.rs`. Without threads it is `emnapi-basic-napi-rs`,
|
|
2965
|
+
// the "basic" model: both stay wasm imports, and the `@emnapi/core`
|
|
2966
|
+
// JavaScript plugins below are what resolves them; without the plugins
|
|
2967
|
+
// instantiation fails with a LinkError naming the missing import. With
|
|
2968
|
+
// threads it is `emnapi-napi-rs-mt`, the full composition, whose C
|
|
2969
|
+
// `async_work.c` / `threadsafe_function.c` run on the uv threadpool inside
|
|
2970
|
+
// the wasm — it imports none of those symbols, so the plugins are inert
|
|
2971
|
+
// there. They are passed in both modes because only the archive knows which
|
|
2972
|
+
// applies, and a plugin nothing imports costs nothing.
|
|
2973
|
+
//
|
|
2974
|
+
// This is why the async-work drain on the disposal path cannot live here:
|
|
2975
|
+
// with threads there is no JavaScript seam at all, so `__drainWasiAsyncWork`
|
|
2976
|
+
// asks the addon instead.
|
|
2544
2977
|
const emnapiPluginRequire = ` emnapiAsyncWorkPlugin: __emnapiAsyncWorkPlugin,\n emnapiTSFNPlugin: __emnapiTSFNPlugin,\n`
|
|
2545
2978
|
const workerOption = threads
|
|
2546
2979
|
? ` onCreateWorker() {
|
|
@@ -2571,6 +3004,10 @@ function __createWasiWorker(filename) {
|
|
|
2571
3004
|
}
|
|
2572
3005
|
|
|
2573
3006
|
worker.unref()
|
|
3007
|
+
// These stubs stay in place for the worker's whole life, disposal
|
|
3008
|
+
// included: \`__keepEventLoopAliveUntil\` is what holds the process open
|
|
3009
|
+
// while a termination is pending, precisely because a worker's own
|
|
3010
|
+
// references cannot be relied on for it.
|
|
2574
3011
|
}
|
|
2575
3012
|
return worker
|
|
2576
3013
|
},
|
|
@@ -117,7 +117,11 @@ test('resolveProcessExecutionIdentityForLocking re-probes until the identity is
|
|
|
117
117
|
reads += 1
|
|
118
118
|
return reads < 3 ? incompleteIdentity : completeIdentity
|
|
119
119
|
},
|
|
120
|
-
|
|
120
|
+
// A sub-second budget flakes on loaded CI runners: two 20ms retry delays
|
|
121
|
+
// plus event-loop lag can exceed it before the third read (seen on
|
|
122
|
+
// windows-msvc with ~300ms timer starvation). The nominal runtime stays
|
|
123
|
+
// ~40ms; the budget only bounds how long failures are ridden out.
|
|
124
|
+
{ expiresAt: performance.now() + 2_000, timeout: 2_000 },
|
|
121
125
|
)
|
|
122
126
|
|
|
123
127
|
t.true(resolution.complete)
|
|
@@ -132,7 +136,10 @@ test('resolveProcessExecutionIdentityForLocking resolves incomplete past the wai
|
|
|
132
136
|
reads += 1
|
|
133
137
|
return incompleteIdentity
|
|
134
138
|
},
|
|
135
|
-
|
|
139
|
+
// Reads past the first happen on 20ms retry delays; the budget must
|
|
140
|
+
// tolerate event-loop lag on loaded CI runners so at least one re-probe
|
|
141
|
+
// (reads > 1 below) is guaranteed.
|
|
142
|
+
{ expiresAt: performance.now() + 1_000, timeout: 1_000 },
|
|
136
143
|
)
|
|
137
144
|
|
|
138
145
|
t.false(resolution.complete)
|