@napi-rs/cli 3.10.4 → 3.10.6
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 +1943 -153
- package/dist/index.cjs +1942 -152
- package/dist/index.d.cts +19 -7
- package/dist/index.d.ts +19 -7
- package/dist/index.js +1943 -153
- package/docs/build.md +1 -0
- package/docs/wasi.md +351 -8
- package/package.json +2 -2
- package/src/api/__tests__/__snapshots__/templates.spec.ts.md +2629 -86
- package/src/api/__tests__/__snapshots__/templates.spec.ts.snap +0 -0
- package/src/api/__tests__/templates.spec.ts +2192 -10
- package/src/api/build.ts +38 -1
- package/src/api/templates/load-wasi-template.ts +1412 -30
- package/src/api/templates/wasi-worker-template.ts +191 -63
- package/src/commands/__tests__/build.spec.ts +6 -0
- package/src/def/build.ts +13 -0
- package/src/utils/__tests__/ohos-selfsign.spec.ts +285 -0
- package/src/utils/__tests__/reconciliation.spec.ts +19 -0
- package/src/utils/config.ts +10 -6
- package/src/utils/index.ts +1 -0
- package/src/utils/misc.ts +5 -4
- package/src/utils/ohos-selfsign.ts +646 -0
|
@@ -1,5 +1,8 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { mkdtemp, rm, writeFile } from 'node:fs/promises'
|
|
2
|
+
import { tmpdir } from 'node:os'
|
|
3
|
+
import { dirname, join } from 'node:path'
|
|
2
4
|
import { fileURLToPath } from 'node:url'
|
|
5
|
+
import { Worker } from 'node:worker_threads'
|
|
3
6
|
|
|
4
7
|
import ava, { type ExecutionContext } from 'ava'
|
|
5
8
|
import { parseSync } from 'oxc-parser'
|
|
@@ -927,7 +930,9 @@ test('Node WASI loader uses an accessible host root on Android', (t) => {
|
|
|
927
930
|
t.true(code.includes('[__rootDir]: __hostRoot'))
|
|
928
931
|
t.true(code.includes('[__hostRoot]: __hostRoot'))
|
|
929
932
|
t.true(
|
|
930
|
-
code.includes(
|
|
933
|
+
code.includes(
|
|
934
|
+
'workerData: {\n hostRoot: __hostRoot,\n rootDir: __rootDir,\n',
|
|
935
|
+
),
|
|
931
936
|
)
|
|
932
937
|
t.false(code.includes('[__rootDir]: __rootDir'))
|
|
933
938
|
})
|
|
@@ -984,6 +989,10 @@ for (const { name, code } of wasiLoaderCases) {
|
|
|
984
989
|
}
|
|
985
990
|
|
|
986
991
|
const EAGER_ROLLBACK_SIGNATURE = 'function __rollbackWasiInitialization() {'
|
|
992
|
+
// The threaded node loader keeps the rollback's body under this name and makes
|
|
993
|
+
// `__rollbackWasiInitialization` the wrapper that stops it after a crash.
|
|
994
|
+
const LATCHED_ROLLBACK_SIGNATURE =
|
|
995
|
+
'function __runWasiInitializationRollbackSteps() {'
|
|
987
996
|
const DEFERRED_ROLLBACK_SIGNATURE = "__lifecycleState = 'failed'"
|
|
988
997
|
const DRAIN_CALL_BY_ROLLBACK_FLAVOR = {
|
|
989
998
|
eager: '__drainWasmEnvCleanup',
|
|
@@ -992,12 +1001,14 @@ const DRAIN_CALL_BY_ROLLBACK_FLAVOR = {
|
|
|
992
1001
|
|
|
993
1002
|
/**
|
|
994
1003
|
* The body of a loader's initialization-failure path: `__rollbackWasiInitialization`
|
|
995
|
-
* for the eager loaders, `__createInstance`'s catch for the deferred one.
|
|
1004
|
+
* (its steps, in the threaded node loader) for the eager loaders, `__createInstance`'s catch for the deferred one.
|
|
996
1005
|
* Sliced rather than searched whole-file, so a drain that only runs on the
|
|
997
1006
|
* ordinary disposal path cannot satisfy the assertion.
|
|
998
1007
|
*/
|
|
999
1008
|
function initializationRollbackBody(code: string): string {
|
|
1000
|
-
const
|
|
1009
|
+
const latchedStart = code.indexOf(LATCHED_ROLLBACK_SIGNATURE)
|
|
1010
|
+
const eagerStart =
|
|
1011
|
+
latchedStart !== -1 ? latchedStart : code.indexOf(EAGER_ROLLBACK_SIGNATURE)
|
|
1001
1012
|
if (eagerStart !== -1) {
|
|
1002
1013
|
return code.slice(eagerStart, code.indexOf('\n}', eagerStart))
|
|
1003
1014
|
}
|
|
@@ -1123,6 +1134,251 @@ for (const { name, code } of eagerWasiLoaderCases) {
|
|
|
1123
1134
|
})
|
|
1124
1135
|
}
|
|
1125
1136
|
|
|
1137
|
+
// `napi_prepare_wasm_env_cleanup` waits: it returns only once the addon's async
|
|
1138
|
+
// runtime has quiesced, and on a threaded artifact the work it waits for can be
|
|
1139
|
+
// waiting for a JavaScript turn from the very thread the export runs on — which
|
|
1140
|
+
// never comes, because that thread is inside the export. The addon exposes the
|
|
1141
|
+
// same teardown as `…_begin` / `napi_wasm_runtime_work_pending` / `…_finish` so
|
|
1142
|
+
// a loader can put real turns in the middle. Every shape that can yield has to
|
|
1143
|
+
// use it, and every shape has to keep working against an addon that has no such
|
|
1144
|
+
// exports.
|
|
1145
|
+
const TWO_PHASE_BARRIER_BY_FLAVOR = {
|
|
1146
|
+
eager: {
|
|
1147
|
+
detect:
|
|
1148
|
+
/if \(typeof begin !== 'function' \|\|\s*typeof finish !== 'function'\) \{/,
|
|
1149
|
+
poll: '}, __WASM_RUNTIME_WORK_POLL_INTERVAL_MS)',
|
|
1150
|
+
finish: '.then(finishCleanup, finishCleanup)',
|
|
1151
|
+
fallback: '__prepareWasmEnvCleanup()',
|
|
1152
|
+
report: '__reportUnreachedWasmEnvSettlements()',
|
|
1153
|
+
// The closer a caller that cannot yield uses to end a parked handshake,
|
|
1154
|
+
// and the point at which the poll publishes it.
|
|
1155
|
+
parked: '__finishParkedWasmEnvCleanup',
|
|
1156
|
+
publishParked: '__finishParkedWasmEnvCleanup = finishCleanup',
|
|
1157
|
+
clearParked: '__finishParkedWasmEnvCleanup = undefined',
|
|
1158
|
+
},
|
|
1159
|
+
deferred: {
|
|
1160
|
+
detect:
|
|
1161
|
+
/if \(typeof __begin !== 'function' \|\|\s*typeof __finish !== 'function'\) \{/,
|
|
1162
|
+
poll: '}, __WASM_RUNTIME_WORK_POLL_INTERVAL_MS)',
|
|
1163
|
+
finish: `return __pollWasmRuntimeWork(__workPending).then(
|
|
1164
|
+
__finishEnvCleanup,
|
|
1165
|
+
__finishEnvCleanup,
|
|
1166
|
+
)`,
|
|
1167
|
+
fallback: '__prepareEnvCleanup()',
|
|
1168
|
+
report: '__reportUnreachedSettlements()',
|
|
1169
|
+
parked: '__finishParkedEnvCleanup',
|
|
1170
|
+
publishParked: '__finishParkedEnvCleanup = __finishEnvCleanup',
|
|
1171
|
+
clearParked: '__finishParkedEnvCleanup = undefined',
|
|
1172
|
+
},
|
|
1173
|
+
} as const
|
|
1174
|
+
|
|
1175
|
+
for (const { name, code } of wasiLoaderCases) {
|
|
1176
|
+
const barrier =
|
|
1177
|
+
TWO_PHASE_BARRIER_BY_FLAVOR[
|
|
1178
|
+
code.includes(EAGER_ROLLBACK_SIGNATURE) ? 'eager' : 'deferred'
|
|
1179
|
+
]
|
|
1180
|
+
test(`WASI loader polls the two-phase wasm env cleanup: ${name}`, (t) => {
|
|
1181
|
+
t.true(
|
|
1182
|
+
code.includes('napi_prepare_wasm_env_cleanup_begin'),
|
|
1183
|
+
'loader must start the teardown without joining',
|
|
1184
|
+
)
|
|
1185
|
+
t.true(
|
|
1186
|
+
code.includes('napi_wasm_runtime_work_pending'),
|
|
1187
|
+
'loader must poll the runtime between the two halves; without it there is nothing to wait on',
|
|
1188
|
+
)
|
|
1189
|
+
t.true(
|
|
1190
|
+
code.includes('napi_prepare_wasm_env_cleanup_finish'),
|
|
1191
|
+
'loader must still join: finish is the call that owes quiescence',
|
|
1192
|
+
)
|
|
1193
|
+
// Optional, exactly like every other export in this teardown: an addon
|
|
1194
|
+
// built against a napi crate that predates the split keeps the single
|
|
1195
|
+
// blocking call.
|
|
1196
|
+
t.regex(code, barrier.detect, 'the split must be feature-detected')
|
|
1197
|
+
t.true(
|
|
1198
|
+
code.includes(barrier.fallback),
|
|
1199
|
+
'a loader that finds no split must fall back to the single call',
|
|
1200
|
+
)
|
|
1201
|
+
// Unbounded, and deliberately so. The only way to end the poll early is to
|
|
1202
|
+
// call `…_finish`, which joins on the JavaScript thread — the very thread
|
|
1203
|
+
// the work it joins may be waiting for a turn from — so a budget would not
|
|
1204
|
+
// end that wait, it would only move it somewhere the thread can no longer
|
|
1205
|
+
// be reached. Same reason the async-work drain has no deadline. A host that
|
|
1206
|
+
// breaks the "no blocking closure may wait on a JavaScript turn" rule keeps
|
|
1207
|
+
// its disposal promise pending instead of wedging the thread.
|
|
1208
|
+
t.false(
|
|
1209
|
+
code.includes('__WASM_RUNTIME_DRAIN_TURNS'),
|
|
1210
|
+
'the poll must not carry a turn budget',
|
|
1211
|
+
)
|
|
1212
|
+
t.is(
|
|
1213
|
+
code.split('for (;;) {').length - 1,
|
|
1214
|
+
1,
|
|
1215
|
+
'the poll must be an unbounded loop',
|
|
1216
|
+
)
|
|
1217
|
+
t.true(
|
|
1218
|
+
code.includes(barrier.poll),
|
|
1219
|
+
'the poll must yield with a real referenced timer, not a macrotask spin',
|
|
1220
|
+
)
|
|
1221
|
+
// `__scheduleTimer` falls back to the macrotask scheduler when `setTimeout`
|
|
1222
|
+
// is missing or throws — but not when it is present, returns a handle and
|
|
1223
|
+
// never fires (fake timers; a host whose timers belong to an IO context
|
|
1224
|
+
// that is gone). With no budget left to bail the poll out, that host would
|
|
1225
|
+
// park it forever. Arm both until timers have actually arrived, then pace
|
|
1226
|
+
// on the timer alone rather than spinning the zero-delay queue.
|
|
1227
|
+
//
|
|
1228
|
+
// Whether they arrive is a property of the poll, never of the module: a
|
|
1229
|
+
// host can lose its timers between two disposals, and in the deferred
|
|
1230
|
+
// shape every instance shares this module — one healthy instance must not
|
|
1231
|
+
// disarm the fallback for the next one.
|
|
1232
|
+
t.false(
|
|
1233
|
+
code.includes('let __wasmRuntimePollTimerArrived'),
|
|
1234
|
+
'the pacing state must not outlive the poll that learned it',
|
|
1235
|
+
)
|
|
1236
|
+
t.is(
|
|
1237
|
+
code.split('function __createWasmRuntimePollPace()').length - 1,
|
|
1238
|
+
1,
|
|
1239
|
+
'one definition of the pacing state',
|
|
1240
|
+
)
|
|
1241
|
+
t.is(
|
|
1242
|
+
code.split('__createWasmRuntimePollPace()').length - 1,
|
|
1243
|
+
2,
|
|
1244
|
+
'and exactly one caller: the poll loop, which owns it for its own run',
|
|
1245
|
+
)
|
|
1246
|
+
const yieldStart = code.indexOf('function __yieldWasmRuntimePollTurn(')
|
|
1247
|
+
t.true(yieldStart > 0, 'the poll must yield through one shared turn helper')
|
|
1248
|
+
const yieldTurn = code.slice(yieldStart, code.indexOf('\n}\n', yieldStart))
|
|
1249
|
+
t.true(
|
|
1250
|
+
yieldTurn.includes('__scheduleTimer(') &&
|
|
1251
|
+
yieldTurn.includes('__scheduleMacrotask('),
|
|
1252
|
+
'an undecided turn must arm both primitives, so an inert setTimeout cannot park the poll',
|
|
1253
|
+
)
|
|
1254
|
+
t.true(
|
|
1255
|
+
yieldTurn.includes('pace.arrivals++'),
|
|
1256
|
+
'the timer callback must count that timers work on this host',
|
|
1257
|
+
)
|
|
1258
|
+
t.true(
|
|
1259
|
+
yieldTurn.includes(
|
|
1260
|
+
'pace.arrivals < __WASM_RUNTIME_WORK_POLL_TRUSTED_ARRIVALS',
|
|
1261
|
+
),
|
|
1262
|
+
'and one arrival must not be enough: it can be a timer armed before the host stopped running them',
|
|
1263
|
+
)
|
|
1264
|
+
// A poll that has settled onto the timer alone has nothing left to fall
|
|
1265
|
+
// back on if the timers stop mid-poll — the turn that armed the dead timer
|
|
1266
|
+
// is the turn that parks, and a parked poll schedules nothing that could
|
|
1267
|
+
// notice. Every turn keeps a longer timer outstanding for that.
|
|
1268
|
+
t.true(
|
|
1269
|
+
yieldTurn.includes('__armWasmRuntimePollStallBackup('),
|
|
1270
|
+
'every turn must keep a stall backup outstanding, armed while the timers still work',
|
|
1271
|
+
)
|
|
1272
|
+
const backupStart = code.indexOf(
|
|
1273
|
+
'function __armWasmRuntimePollStallBackup(',
|
|
1274
|
+
)
|
|
1275
|
+
t.true(backupStart > 0, 'the backup must be one shared helper')
|
|
1276
|
+
const stallBackup = code.slice(
|
|
1277
|
+
backupStart,
|
|
1278
|
+
code.indexOf('\n}\n', backupStart),
|
|
1279
|
+
)
|
|
1280
|
+
t.true(
|
|
1281
|
+
stallBackup.includes('pace.arrivals = 0'),
|
|
1282
|
+
'a turn the backup has to end proves the timers stopped: the poll goes back to arming both',
|
|
1283
|
+
)
|
|
1284
|
+
t.true(
|
|
1285
|
+
stallBackup.includes('pace.settleTurn'),
|
|
1286
|
+
'and the backup must end whichever turn is parked, not the one that armed it',
|
|
1287
|
+
)
|
|
1288
|
+
// By due time, never by how long the parked turn has been waiting. A host
|
|
1289
|
+
// runs its timers in due order, so a backup that runs while a turn due a
|
|
1290
|
+
// window earlier is still parked proves that turn's timer was dropped —
|
|
1291
|
+
// and a healthy turn, whose timer runs first and clears `settleTurn`, is
|
|
1292
|
+
// never touched. Measuring the wait instead has a phase hole: arms spaced
|
|
1293
|
+
// further apart than the window leave the turn that parks between them
|
|
1294
|
+
// with no backup young enough to rescue it.
|
|
1295
|
+
t.regex(
|
|
1296
|
+
stallBackup,
|
|
1297
|
+
/pace\.turnTimerDueAt > (?:__)?dueAt - __WASM_RUNTIME_WORK_POLL_STALL_MS/,
|
|
1298
|
+
'the backup must judge by due time, so there is no phase to fall through',
|
|
1299
|
+
)
|
|
1300
|
+
t.false(
|
|
1301
|
+
stallBackup.includes('Date.now() -'),
|
|
1302
|
+
'and it must not measure how long the parked turn has waited',
|
|
1303
|
+
)
|
|
1304
|
+
t.true(
|
|
1305
|
+
yieldTurn.includes('pace.settleTurn = undefined'),
|
|
1306
|
+
'a turn that ends must stop being the parked one, or a later backup reads a due time already answered',
|
|
1307
|
+
)
|
|
1308
|
+
t.regex(
|
|
1309
|
+
yieldTurn,
|
|
1310
|
+
/if \(__settled\??\) \{\s*return\s*\}|if \(settled\) \{\s*return\s*\}/,
|
|
1311
|
+
'whichever primitive loses the race must resolve nothing: one poll per turn',
|
|
1312
|
+
)
|
|
1313
|
+
// The threaded node loader routes the poll's end through a crash check
|
|
1314
|
+
// first: after a worker died, `…_finish` would join the dead thread's work.
|
|
1315
|
+
// See `node WASI disposal stops the barrier poll when a worker crashes
|
|
1316
|
+
// mid-flight`.
|
|
1317
|
+
const crashLatched = code.includes('finishCleanupUnlessCrashed')
|
|
1318
|
+
t.true(
|
|
1319
|
+
code.includes(
|
|
1320
|
+
crashLatched
|
|
1321
|
+
? '.then(finishCleanupUnlessCrashed, finishCleanupUnlessCrashed)'
|
|
1322
|
+
: barrier.finish,
|
|
1323
|
+
),
|
|
1324
|
+
'finish must run whether the poll ended, timed out or could not run at all',
|
|
1325
|
+
)
|
|
1326
|
+
// The one caller that cannot yield is the raw `Context.destroy()` the
|
|
1327
|
+
// wrapper intercepts, and the queue it leaves behind is discarded by the
|
|
1328
|
+
// destroy that follows. Say so, once, without throwing.
|
|
1329
|
+
t.true(
|
|
1330
|
+
code.includes(barrier.report),
|
|
1331
|
+
'the single-call barrier must report settlements nothing can reach any more',
|
|
1332
|
+
)
|
|
1333
|
+
// The window between the halves spans real event-loop turns, so a caller
|
|
1334
|
+
// that cannot yield — the CJS 'exit' teardown, the managed beforeExit one —
|
|
1335
|
+
// can land in the middle of one. It cannot wait for the poll; it has to
|
|
1336
|
+
// close the handshake itself, because `…_finish` is the call that joins and
|
|
1337
|
+
// lowers the barrier. Without this the context is destroyed with the
|
|
1338
|
+
// barrier still raised, the runtime never joined and the destroy recorded
|
|
1339
|
+
// as done.
|
|
1340
|
+
t.is(
|
|
1341
|
+
code.split(barrier.publishParked).length - 1,
|
|
1342
|
+
1,
|
|
1343
|
+
'the poll must publish a closer before it yields',
|
|
1344
|
+
)
|
|
1345
|
+
t.is(
|
|
1346
|
+
code.split(barrier.clearParked).length - 1,
|
|
1347
|
+
// Plus the crash abort, which retracts it instead of finishing.
|
|
1348
|
+
crashLatched ? 2 : 1,
|
|
1349
|
+
'finishing the handshake must retract that closer exactly once',
|
|
1350
|
+
)
|
|
1351
|
+
const parkedIndex = code.indexOf(`const ${barrier.parked}`)
|
|
1352
|
+
t.true(
|
|
1353
|
+
parkedIndex > 0 || code.includes(`let ${barrier.parked}`),
|
|
1354
|
+
'the closer must live outside the barrier, where a non-yielding caller can reach it',
|
|
1355
|
+
)
|
|
1356
|
+
// And the single call is where it is reached: every teardown path runs the
|
|
1357
|
+
// barrier through it before destroying, so closing a parked handshake there
|
|
1358
|
+
// covers all of them at once.
|
|
1359
|
+
const isEager = barrier.parked === '__finishParkedWasmEnvCleanup'
|
|
1360
|
+
const prepareStart = code.indexOf(
|
|
1361
|
+
isEager
|
|
1362
|
+
? `function ${barrier.fallback.replace('()', '')}() {`
|
|
1363
|
+
: `const ${barrier.fallback.replace('()', '')} = () => {`,
|
|
1364
|
+
)
|
|
1365
|
+
t.true(prepareStart > 0, 'the single-call barrier must be one function')
|
|
1366
|
+
const prepareBody = code.slice(
|
|
1367
|
+
prepareStart,
|
|
1368
|
+
code.indexOf(isEager ? '\n}\n' : '\n }\n', prepareStart),
|
|
1369
|
+
)
|
|
1370
|
+
const closerIndex = prepareBody.indexOf(barrier.parked)
|
|
1371
|
+
t.true(
|
|
1372
|
+
closerIndex > 0,
|
|
1373
|
+
'the single call must close a parked handshake instead of skipping the barrier',
|
|
1374
|
+
)
|
|
1375
|
+
t.true(
|
|
1376
|
+
closerIndex < prepareBody.indexOf('napi_prepare_wasm_env_cleanup'),
|
|
1377
|
+
'and it must close it before looking the single-call export up: a parked handshake is already begun',
|
|
1378
|
+
)
|
|
1379
|
+
})
|
|
1380
|
+
}
|
|
1381
|
+
|
|
1126
1382
|
// The loaders order their own teardown barrier-then-destroy, but the emnapi
|
|
1127
1383
|
// context is a live object: an embedder or test harness holding it, or emnapi's
|
|
1128
1384
|
// own `beforeExit` auto-destroy on a host where `suppressDestroy()` is absent,
|
|
@@ -1140,14 +1396,22 @@ const CONTEXT_DESTROY_WRAP_SIGNATURE =
|
|
|
1140
1396
|
const preparingBarrierGuards = {
|
|
1141
1397
|
shared: {
|
|
1142
1398
|
probe: '__isPreparingWasmEnvCleanup',
|
|
1399
|
+
// Both barrier entry points read it: the single call and the two-phase
|
|
1400
|
+
// form, which keeps it raised across the turns it yields. The single call
|
|
1401
|
+
// splits the check in two, because a handshake parked between the halves is
|
|
1402
|
+
// one it closes rather than refuses — see the two-phase test above — so the
|
|
1403
|
+
// shape below is the reentrancy half alone.
|
|
1404
|
+
entryGuard: ` if (__emnapiWasmEnvCleanupPreparing) {
|
|
1405
|
+
return
|
|
1406
|
+
}`,
|
|
1407
|
+
twoPhaseEntryGuard: ` if (__emnapiWasmEnvCleanupPrepared || __emnapiWasmEnvCleanupPreparing) {
|
|
1408
|
+
return
|
|
1409
|
+
}`,
|
|
1143
1410
|
snippets: [
|
|
1144
1411
|
'let __emnapiWasmEnvCleanupPreparing = false',
|
|
1145
1412
|
`function __isPreparingWasmEnvCleanup() {
|
|
1146
1413
|
return __emnapiWasmEnvCleanupPreparing
|
|
1147
1414
|
}`,
|
|
1148
|
-
` if (__emnapiWasmEnvCleanupPrepared || __emnapiWasmEnvCleanupPreparing) {
|
|
1149
|
-
return
|
|
1150
|
-
}`,
|
|
1151
1415
|
` __emnapiWasmEnvCleanupPreparing = true
|
|
1152
1416
|
try {
|
|
1153
1417
|
prepare()
|
|
@@ -1158,12 +1422,15 @@ const preparingBarrierGuards = {
|
|
|
1158
1422
|
},
|
|
1159
1423
|
deferred: {
|
|
1160
1424
|
probe: '__isPreparingEnvCleanup',
|
|
1425
|
+
entryGuard: ` if (__wasmEnvCleanupPreparing) {
|
|
1426
|
+
return
|
|
1427
|
+
}`,
|
|
1428
|
+
twoPhaseEntryGuard: ` if (__wasmEnvCleanupPrepared || __wasmEnvCleanupPreparing) {
|
|
1429
|
+
return
|
|
1430
|
+
}`,
|
|
1161
1431
|
snippets: [
|
|
1162
1432
|
'let __wasmEnvCleanupPreparing = false',
|
|
1163
1433
|
'const __isPreparingEnvCleanup = () => __wasmEnvCleanupPreparing',
|
|
1164
|
-
` if (__wasmEnvCleanupPrepared || __wasmEnvCleanupPreparing) {
|
|
1165
|
-
return
|
|
1166
|
-
}`,
|
|
1167
1434
|
` __wasmEnvCleanupPreparing = true
|
|
1168
1435
|
try {
|
|
1169
1436
|
__prepareWasmEnvCleanup()
|
|
@@ -1267,6 +1534,20 @@ for (const { name, code, prepare, guard } of wrappedContextCreationCases) {
|
|
|
1267
1534
|
`barrier must carry its in-flight guard exactly once: ${snippet}`,
|
|
1268
1535
|
)
|
|
1269
1536
|
}
|
|
1537
|
+
// Both barrier entry points — the single call and the two-phase form —
|
|
1538
|
+
// refuse to re-enter a barrier already in flight. The two-phase form still
|
|
1539
|
+
// tests the pair in one condition; the single call tests the reentrancy
|
|
1540
|
+
// half on its own, after it has dealt with a parked handshake.
|
|
1541
|
+
t.is(
|
|
1542
|
+
code.split(guard.entryGuard).length - 1,
|
|
1543
|
+
1,
|
|
1544
|
+
'the single call must refuse to re-enter a barrier already in flight',
|
|
1545
|
+
)
|
|
1546
|
+
t.is(
|
|
1547
|
+
code.split(guard.twoPhaseEntryGuard).length - 1,
|
|
1548
|
+
1,
|
|
1549
|
+
'the two-phase barrier must refuse to re-enter a barrier already in flight',
|
|
1550
|
+
)
|
|
1270
1551
|
t.is(
|
|
1271
1552
|
code.split(NESTED_DESTROY_NO_OP).length - 1,
|
|
1272
1553
|
1,
|
|
@@ -1847,3 +2128,1904 @@ test('createEsmBinding builds native addon loading on portable ESM primitives',
|
|
|
1847
2128
|
'ESM loader must not treat URL.pathname as a filesystem path',
|
|
1848
2129
|
)
|
|
1849
2130
|
})
|
|
2131
|
+
|
|
2132
|
+
/**
|
|
2133
|
+
* A top-level `function <name>(` of generated code, through its closing brace
|
|
2134
|
+
* at column zero.
|
|
2135
|
+
*/
|
|
2136
|
+
function generatedFunction(code: string, name: string): string {
|
|
2137
|
+
const start = code.indexOf(`function ${name}(`)
|
|
2138
|
+
if (start === -1) {
|
|
2139
|
+
return ''
|
|
2140
|
+
}
|
|
2141
|
+
return code.slice(start, code.indexOf('\n}\n', start) + 2)
|
|
2142
|
+
}
|
|
2143
|
+
|
|
2144
|
+
/**
|
|
2145
|
+
* Runs the node loader's 'exit' listener against stubbed teardown steps, with
|
|
2146
|
+
* the crash latch in the given state, and returns the steps it took.
|
|
2147
|
+
*/
|
|
2148
|
+
function runWasiExitListener(
|
|
2149
|
+
code: string,
|
|
2150
|
+
crash: { flag?: boolean; errorEvent?: boolean; fatalError?: boolean },
|
|
2151
|
+
): string[] {
|
|
2152
|
+
const latchStart = code.indexOf('const __wasiThreadCrashFlag = ')
|
|
2153
|
+
const latchState = code.slice(
|
|
2154
|
+
latchStart,
|
|
2155
|
+
code.indexOf('let __wasiThreadCrashed = false\n', latchStart) +
|
|
2156
|
+
'let __wasiThreadCrashed = false\n'.length,
|
|
2157
|
+
)
|
|
2158
|
+
const steps: string[] = []
|
|
2159
|
+
const run = new Function(
|
|
2160
|
+
'steps',
|
|
2161
|
+
'crash',
|
|
2162
|
+
`
|
|
2163
|
+
let __wasiExitListenerRegistered = true
|
|
2164
|
+
${latchState}
|
|
2165
|
+
function __getWasiThreadManager() {
|
|
2166
|
+
return {
|
|
2167
|
+
terminateWorker() {},
|
|
2168
|
+
_fatalError: crash.fatalError ? new Error('worker died') : undefined,
|
|
2169
|
+
}
|
|
2170
|
+
}
|
|
2171
|
+
function __isThenable(value) {
|
|
2172
|
+
return value !== null && typeof value === 'object' && typeof value.then === 'function'
|
|
2173
|
+
}
|
|
2174
|
+
function __destroyEmnapiContext() {
|
|
2175
|
+
steps.push('destroy')
|
|
2176
|
+
}
|
|
2177
|
+
function __terminateWasiWorkers() {
|
|
2178
|
+
steps.push('terminate')
|
|
2179
|
+
}
|
|
2180
|
+
// Only an asyncRuntime loader calls it.
|
|
2181
|
+
function __releaseCurrentThreadHostTimers() {
|
|
2182
|
+
steps.push('release host timers')
|
|
2183
|
+
}
|
|
2184
|
+
${generatedFunction(code, '__hasWasiThreadCrashed')}
|
|
2185
|
+
${generatedFunction(code, '__disposeWasiBindingAtExit')}
|
|
2186
|
+
if (crash.flag) Atomics.store(__wasiThreadCrashFlag, 0, 1)
|
|
2187
|
+
if (crash.errorEvent) __wasiThreadCrashed = true
|
|
2188
|
+
__disposeWasiBindingAtExit()
|
|
2189
|
+
if (__wasiExitListenerRegistered) steps.push('still registered')
|
|
2190
|
+
`,
|
|
2191
|
+
)
|
|
2192
|
+
run(steps, crash)
|
|
2193
|
+
return steps
|
|
2194
|
+
}
|
|
2195
|
+
|
|
2196
|
+
// A wasm thread that died can leave the shared state half-updated: a lock it
|
|
2197
|
+
// held stays held, and the async runtime's shutdown waits for its work to go
|
|
2198
|
+
// idle. The 'exit' teardown re-enters wasm on the JavaScript thread and blocks
|
|
2199
|
+
// there in a raw atomic wait that no signal can interrupt, so the process hangs
|
|
2200
|
+
// for good. After a crash the listener must only stop the workers.
|
|
2201
|
+
test('node WASI loader skips the wasm teardown at exit after a thread crash', (t) => {
|
|
2202
|
+
const code = createWasiBinding('test', '@scope/test')
|
|
2203
|
+
assertValidJS(t, code, 'node cjs')
|
|
2204
|
+
t.true(code.includes('function __hasWasiThreadCrashed() {'))
|
|
2205
|
+
|
|
2206
|
+
// No crash: the teardown is exactly what it was.
|
|
2207
|
+
t.deepEqual(runWasiExitListener(code, {}), ['destroy', 'terminate'])
|
|
2208
|
+
|
|
2209
|
+
// Each of the three signals is enough on its own: the flag the worker sets
|
|
2210
|
+
// before it reports, the worker's 'error' event, and emnapi's fatal error.
|
|
2211
|
+
for (const crash of [
|
|
2212
|
+
{ flag: true },
|
|
2213
|
+
{ errorEvent: true },
|
|
2214
|
+
{ fatalError: true },
|
|
2215
|
+
]) {
|
|
2216
|
+
t.deepEqual(
|
|
2217
|
+
runWasiExitListener(code, crash),
|
|
2218
|
+
['terminate'],
|
|
2219
|
+
JSON.stringify(crash),
|
|
2220
|
+
)
|
|
2221
|
+
}
|
|
2222
|
+
})
|
|
2223
|
+
|
|
2224
|
+
/**
|
|
2225
|
+
* Runs the node loader's public disposer, and then its 'exit' listener, against
|
|
2226
|
+
* stubbed wasm steps with the crash latch in the given state. Every stub
|
|
2227
|
+
* records its name, so the steps show exactly what re-entered wasm.
|
|
2228
|
+
*
|
|
2229
|
+
* `__emnapiContext` is a stub shaped like emnapi's Node `Context`: its
|
|
2230
|
+
* `refCounter.refHandle` records 'port ref' / 'port unref', and every other
|
|
2231
|
+
* member records its name, so a step that touched the context shows up too.
|
|
2232
|
+
* `context` picks a context without a counter, one whose counter getter
|
|
2233
|
+
* throws, or no context at all.
|
|
2234
|
+
*/
|
|
2235
|
+
async function runWasiPublicDisposer(
|
|
2236
|
+
code: string,
|
|
2237
|
+
crash: {
|
|
2238
|
+
flag?: boolean
|
|
2239
|
+
errorEvent?: boolean
|
|
2240
|
+
fatalError?: boolean
|
|
2241
|
+
terminateThrows?: boolean
|
|
2242
|
+
context?: 'no counter' | 'throwing counter' | 'none'
|
|
2243
|
+
},
|
|
2244
|
+
calls = 1,
|
|
2245
|
+
): Promise<{
|
|
2246
|
+
steps: string[]
|
|
2247
|
+
results: Array<{ value?: unknown; error?: any }>
|
|
2248
|
+
samePromise: boolean
|
|
2249
|
+
exitError?: unknown
|
|
2250
|
+
count?: number
|
|
2251
|
+
}> {
|
|
2252
|
+
const latchStart = code.indexOf('const __wasiThreadCrashFlag = ')
|
|
2253
|
+
const latchState = code.slice(
|
|
2254
|
+
latchStart,
|
|
2255
|
+
code.indexOf('let __wasiThreadCrashed = false\n', latchStart) +
|
|
2256
|
+
'let __wasiThreadCrashed = false\n'.length,
|
|
2257
|
+
)
|
|
2258
|
+
const steps: string[] = []
|
|
2259
|
+
const fatalError = new Error('worker died')
|
|
2260
|
+
// Two requests still in flight on the dead thread.
|
|
2261
|
+
const refCounter = {
|
|
2262
|
+
count: 2,
|
|
2263
|
+
refHandle: {
|
|
2264
|
+
ref() {
|
|
2265
|
+
steps.push('port ref')
|
|
2266
|
+
},
|
|
2267
|
+
unref() {
|
|
2268
|
+
steps.push('port unref')
|
|
2269
|
+
},
|
|
2270
|
+
},
|
|
2271
|
+
}
|
|
2272
|
+
let context: object | undefined
|
|
2273
|
+
if (crash.context === 'no counter') {
|
|
2274
|
+
context = {}
|
|
2275
|
+
} else if (crash.context === 'throwing counter') {
|
|
2276
|
+
context = {
|
|
2277
|
+
get refCounter() {
|
|
2278
|
+
throw new Error('private field')
|
|
2279
|
+
},
|
|
2280
|
+
}
|
|
2281
|
+
} else if (crash.context !== 'none') {
|
|
2282
|
+
context = { refCounter }
|
|
2283
|
+
}
|
|
2284
|
+
if (context) {
|
|
2285
|
+
// Anything but the counter port touching the context is a wasm re-entry
|
|
2286
|
+
// (destroy runs the cleanup hooks) or a JS state change this path must not
|
|
2287
|
+
// make; record it.
|
|
2288
|
+
context = new Proxy(context, {
|
|
2289
|
+
get(target, key, receiver) {
|
|
2290
|
+
if (key !== 'refCounter') {
|
|
2291
|
+
steps.push(`context.${String(key)}`)
|
|
2292
|
+
}
|
|
2293
|
+
return Reflect.get(target, key, receiver)
|
|
2294
|
+
},
|
|
2295
|
+
})
|
|
2296
|
+
}
|
|
2297
|
+
const run = new Function(
|
|
2298
|
+
'steps',
|
|
2299
|
+
'crash',
|
|
2300
|
+
'fatalError',
|
|
2301
|
+
'calls',
|
|
2302
|
+
'__emnapiContext',
|
|
2303
|
+
`
|
|
2304
|
+
let __wasiExitListenerRegistered = true
|
|
2305
|
+
let __wasiDisposed = false
|
|
2306
|
+
let __wasiDisposePromise
|
|
2307
|
+
let __wasiThreadCrashDisposePromise
|
|
2308
|
+
// The loader sets this to __removeWasiExitListener.
|
|
2309
|
+
let __completeWasiDisposal = function () {
|
|
2310
|
+
steps.push('complete')
|
|
2311
|
+
__wasiExitListenerRegistered = false
|
|
2312
|
+
}
|
|
2313
|
+
${latchState}
|
|
2314
|
+
function __getWasiThreadManager() {
|
|
2315
|
+
return {
|
|
2316
|
+
terminateWorker() {},
|
|
2317
|
+
_fatalError: crash.fatalError ? fatalError : undefined,
|
|
2318
|
+
}
|
|
2319
|
+
}
|
|
2320
|
+
function __isThenable(value) {
|
|
2321
|
+
return value !== null && typeof value === 'object' && typeof value.then === 'function'
|
|
2322
|
+
}
|
|
2323
|
+
function __drainWasiAsyncWork() {
|
|
2324
|
+
steps.push('async work')
|
|
2325
|
+
}
|
|
2326
|
+
function __prepareWasmEnvCleanupWithTurns() {
|
|
2327
|
+
steps.push('prepare')
|
|
2328
|
+
}
|
|
2329
|
+
function __drainWasmEnvCleanup() {
|
|
2330
|
+
steps.push('drain')
|
|
2331
|
+
}
|
|
2332
|
+
function __destroyEmnapiContext() {
|
|
2333
|
+
steps.push('destroy')
|
|
2334
|
+
}
|
|
2335
|
+
function __terminateWasiWorkers() {
|
|
2336
|
+
steps.push('terminate')
|
|
2337
|
+
if (crash.terminateThrows) throw new Error('terminate failed')
|
|
2338
|
+
return Promise.resolve()
|
|
2339
|
+
}
|
|
2340
|
+
// Only an asyncRuntime loader calls it.
|
|
2341
|
+
function __releaseCurrentThreadHostTimers() {
|
|
2342
|
+
steps.push('release host timers')
|
|
2343
|
+
}
|
|
2344
|
+
${generatedFunction(code, '__createCleanupError')}
|
|
2345
|
+
${generatedFunction(code, '__attachCleanupErrors')}
|
|
2346
|
+
${generatedFunction(code, '__hasWasiThreadCrashed')}
|
|
2347
|
+
${generatedFunction(code, '__readWasiThreadCrashReport')}
|
|
2348
|
+
${generatedFunction(code, '__recordWasiThreadCrashError')}
|
|
2349
|
+
${generatedFunction(code, '__getWasiThreadCrashError')}
|
|
2350
|
+
${generatedFunction(code, '__fillWasiThreadCrashError')}
|
|
2351
|
+
${generatedFunction(code, '__releaseEmnapiWaitingRequestHandle')}
|
|
2352
|
+
${generatedFunction(code, '__disposeWasiBindingAfterThreadCrash')}
|
|
2353
|
+
${generatedFunction(code, '__abortWasiDisposalIfThreadCrashed')}
|
|
2354
|
+
${generatedFunction(code, '__settleWasiDisposalAfterThreadCrash')}
|
|
2355
|
+
${generatedFunction(code, '__finishWasiDisposal')}
|
|
2356
|
+
${generatedFunction(code, '__continueWasiDisposal')}
|
|
2357
|
+
${generatedFunction(code, '__drainWasmEnvForWasiDisposal')}
|
|
2358
|
+
${generatedFunction(code, '__cleanUpWasmEnvForWasiDisposal')}
|
|
2359
|
+
${generatedFunction(code, '__startWasiDisposal')}
|
|
2360
|
+
${generatedFunction(code, '__disposeWasiBinding')}
|
|
2361
|
+
${generatedFunction(code, '__disposeWasiBindingAtExit')}
|
|
2362
|
+
if (crash.flag) Atomics.store(__wasiThreadCrashFlag, 0, 1)
|
|
2363
|
+
if (crash.errorEvent) __wasiThreadCrashed = true
|
|
2364
|
+
return (async () => {
|
|
2365
|
+
const promises = []
|
|
2366
|
+
for (let i = 0; i < calls; i += 1) {
|
|
2367
|
+
promises.push(__disposeWasiBinding())
|
|
2368
|
+
}
|
|
2369
|
+
const results = []
|
|
2370
|
+
for (const promise of promises) {
|
|
2371
|
+
try {
|
|
2372
|
+
results.push({ value: await promise })
|
|
2373
|
+
} catch (error) {
|
|
2374
|
+
results.push({ error })
|
|
2375
|
+
}
|
|
2376
|
+
}
|
|
2377
|
+
let exitError
|
|
2378
|
+
try {
|
|
2379
|
+
if (__wasiExitListenerRegistered) __disposeWasiBindingAtExit()
|
|
2380
|
+
} catch (error) {
|
|
2381
|
+
exitError = error
|
|
2382
|
+
}
|
|
2383
|
+
return {
|
|
2384
|
+
results,
|
|
2385
|
+
samePromise: promises.every((promise) => promise === promises[0]),
|
|
2386
|
+
exitError,
|
|
2387
|
+
}
|
|
2388
|
+
})()
|
|
2389
|
+
`,
|
|
2390
|
+
)
|
|
2391
|
+
const outcome = await run(steps, crash, fatalError, calls, context)
|
|
2392
|
+
return { steps, ...outcome, count: refCounter.count }
|
|
2393
|
+
}
|
|
2394
|
+
|
|
2395
|
+
// The published disposer runs the same wasm teardown as the 'exit' listener,
|
|
2396
|
+
// behind an async-work drain. An app that handled the rethrown worker error
|
|
2397
|
+
// and then disposed blocked in the same raw atomic wait. After a crash it only
|
|
2398
|
+
// stops the workers and rejects, since nothing was cleaned up.
|
|
2399
|
+
test('node WASI public disposer skips the wasm teardown after a thread crash', async (t) => {
|
|
2400
|
+
const code = createWasiBinding('test', '@scope/test')
|
|
2401
|
+
assertValidJS(t, code, 'node cjs')
|
|
2402
|
+
t.true(code.includes('let __wasiThreadCrashDisposePromise\n'))
|
|
2403
|
+
t.true(
|
|
2404
|
+
generatedFunction(code, '__disposeWasiBinding').includes(
|
|
2405
|
+
' if (!__wasiDisposed && __hasWasiThreadCrashed()) {\n return __disposeWasiBindingAfterThreadCrash()\n }\n',
|
|
2406
|
+
),
|
|
2407
|
+
)
|
|
2408
|
+
|
|
2409
|
+
// No crash: the disposal chain is exactly what it was, and it removes the
|
|
2410
|
+
// 'exit' listener when it completes.
|
|
2411
|
+
const clean = await runWasiPublicDisposer(code, {})
|
|
2412
|
+
t.deepEqual(clean.steps, [
|
|
2413
|
+
'async work',
|
|
2414
|
+
'prepare',
|
|
2415
|
+
'drain',
|
|
2416
|
+
'destroy',
|
|
2417
|
+
'terminate',
|
|
2418
|
+
'complete',
|
|
2419
|
+
])
|
|
2420
|
+
t.deepEqual(clean.results, [{ value: undefined }])
|
|
2421
|
+
|
|
2422
|
+
for (const crash of [
|
|
2423
|
+
{ flag: true },
|
|
2424
|
+
{ errorEvent: true },
|
|
2425
|
+
{ fatalError: true },
|
|
2426
|
+
]) {
|
|
2427
|
+
const label = JSON.stringify(crash)
|
|
2428
|
+
// Three calls: every one settles the same way, and the workers are
|
|
2429
|
+
// terminated once by the disposer and once more by the 'exit' listener,
|
|
2430
|
+
// which still takes its short path and does not throw.
|
|
2431
|
+
const crashed = await runWasiPublicDisposer(code, crash, 3)
|
|
2432
|
+
t.deepEqual(crashed.steps, ['port unref', 'terminate', 'terminate'], label)
|
|
2433
|
+
t.true(crashed.samePromise, label)
|
|
2434
|
+
t.is(crashed.exitError, undefined, label)
|
|
2435
|
+
for (const result of crashed.results) {
|
|
2436
|
+
t.true(result.error instanceof Error, label)
|
|
2437
|
+
t.is(
|
|
2438
|
+
result.error.message,
|
|
2439
|
+
'napi-rs: WASI binding cannot be disposed after a worker thread crashed',
|
|
2440
|
+
label,
|
|
2441
|
+
)
|
|
2442
|
+
}
|
|
2443
|
+
if (crash.fatalError) {
|
|
2444
|
+
t.is(crashed.results[0].error.cause.message, 'worker died')
|
|
2445
|
+
}
|
|
2446
|
+
}
|
|
2447
|
+
|
|
2448
|
+
// A termination failure is attached, not swallowed, and the disposer still
|
|
2449
|
+
// does not fall through to the wasm teardown.
|
|
2450
|
+
const failed = await runWasiPublicDisposer(code, {
|
|
2451
|
+
flag: true,
|
|
2452
|
+
terminateThrows: true,
|
|
2453
|
+
})
|
|
2454
|
+
t.deepEqual(failed.steps, ['port unref', 'terminate', 'terminate'])
|
|
2455
|
+
t.regex(failed.results[0].error.message, /worker thread crashed/)
|
|
2456
|
+
t.is(failed.results[0].error.cause.message, 'terminate failed')
|
|
2457
|
+
t.is(failed.exitError, undefined)
|
|
2458
|
+
})
|
|
2459
|
+
|
|
2460
|
+
// emnapi's Node Context refs a MessagePort (refCounter.refHandle) while async
|
|
2461
|
+
// work or threadsafe-function requests are in flight. The dead thread's requests
|
|
2462
|
+
// never finish, so after the crash disposal rejected nothing else was left
|
|
2463
|
+
// running and the port alone kept the process from exiting. The crash path
|
|
2464
|
+
// unrefs it without touching anything else on the context; the no-crash path
|
|
2465
|
+
// never touches it.
|
|
2466
|
+
test('node WASI crash disposal releases the waiting-request port', async (t) => {
|
|
2467
|
+
const code = createWasiBinding('test', '@scope/test')
|
|
2468
|
+
const release = generatedFunction(code, '__releaseEmnapiWaitingRequestHandle')
|
|
2469
|
+
t.true(release.length > 0)
|
|
2470
|
+
t.true(
|
|
2471
|
+
generatedFunction(code, '__disposeWasiBindingAfterThreadCrash').includes(
|
|
2472
|
+
' __releaseEmnapiWaitingRequestHandle()\n let workerResult\n',
|
|
2473
|
+
),
|
|
2474
|
+
'released before the workers are terminated',
|
|
2475
|
+
)
|
|
2476
|
+
// It must not destroy the context: that runs the cleanup hooks in wasm.
|
|
2477
|
+
t.false(release.includes('destroy'))
|
|
2478
|
+
|
|
2479
|
+
// No crash: the port is left to emnapi's own count.
|
|
2480
|
+
const clean = await runWasiPublicDisposer(code, {})
|
|
2481
|
+
t.false(clean.steps.includes('port unref'))
|
|
2482
|
+
t.false(clean.steps.includes('port ref'))
|
|
2483
|
+
t.is(clean.count, 2)
|
|
2484
|
+
|
|
2485
|
+
// Crash: unrefed exactly once across three disposer calls and the 'exit'
|
|
2486
|
+
// listener, never re-refed, and the count is left above zero so a later
|
|
2487
|
+
// request cannot ref the port again. No other context member is read.
|
|
2488
|
+
const crashed = await runWasiPublicDisposer(code, { flag: true }, 3)
|
|
2489
|
+
t.deepEqual(
|
|
2490
|
+
crashed.steps.filter((step) => step.startsWith('port ')),
|
|
2491
|
+
['port unref'],
|
|
2492
|
+
)
|
|
2493
|
+
t.false(crashed.steps.some((step) => step.startsWith('context.')))
|
|
2494
|
+
t.is(crashed.count, 2)
|
|
2495
|
+
|
|
2496
|
+
// A context without the counter (a non-Node host, a future emnapi), one
|
|
2497
|
+
// whose field cannot be read, or no context at all: the disposer still
|
|
2498
|
+
// rejects with the crash error and terminates the workers.
|
|
2499
|
+
for (const context of ['no counter', 'throwing counter', 'none'] as const) {
|
|
2500
|
+
const outcome = await runWasiPublicDisposer(code, { flag: true, context })
|
|
2501
|
+
t.deepEqual(outcome.steps, ['terminate', 'terminate'], context)
|
|
2502
|
+
t.regex(outcome.results[0].error.message, /worker thread crashed/, context)
|
|
2503
|
+
t.is(outcome.exitError, undefined, context)
|
|
2504
|
+
}
|
|
2505
|
+
})
|
|
2506
|
+
|
|
2507
|
+
// A CurrentThread sleep arms a referenced host timeout through the
|
|
2508
|
+
// `@napi-rs/async-runtime` timer host. Only `__disposeCurrentThreadHosts`
|
|
2509
|
+
// released it, from `__destroyEmnapiContext`, which the crash paths never
|
|
2510
|
+
// reach: its unregister calls enter wasm. A dispose() that rejected after a
|
|
2511
|
+
// crash left the process alive until the longest sleep ended. The threaded
|
|
2512
|
+
// asyncRuntime loader now clears those timeouts itself on every crash path.
|
|
2513
|
+
const threadedAsyncRuntimeCode = createWasiBinding(
|
|
2514
|
+
'test',
|
|
2515
|
+
'@scope/test',
|
|
2516
|
+
4000,
|
|
2517
|
+
65536,
|
|
2518
|
+
true,
|
|
2519
|
+
'wasm32-wasi',
|
|
2520
|
+
'test',
|
|
2521
|
+
true,
|
|
2522
|
+
)
|
|
2523
|
+
|
|
2524
|
+
test('node WASI crash paths release the CurrentThread host timers', async (t) => {
|
|
2525
|
+
const code = threadedAsyncRuntimeCode
|
|
2526
|
+
assertValidJS(t, code, 'node cjs + asyncRuntime')
|
|
2527
|
+
t.true(
|
|
2528
|
+
code.includes(
|
|
2529
|
+
'__currentThreadHostsDisposer = __installCurrentThreadHosts(\n __trackCurrentThreadHostTimers(__napiModule.exports),\n )\n',
|
|
2530
|
+
),
|
|
2531
|
+
)
|
|
2532
|
+
t.true(
|
|
2533
|
+
generatedFunction(code, '__disposeWasiBindingAfterThreadCrash').includes(
|
|
2534
|
+
' __releaseEmnapiWaitingRequestHandle()\n __releaseCurrentThreadHostTimers()\n let workerResult\n',
|
|
2535
|
+
),
|
|
2536
|
+
'released before the workers are terminated',
|
|
2537
|
+
)
|
|
2538
|
+
const exitListener = generatedFunction(code, '__disposeWasiBindingAtExit')
|
|
2539
|
+
const crashBranch = exitListener.slice(
|
|
2540
|
+
exitListener.indexOf('if (__hasWasiThreadCrashed()) {'),
|
|
2541
|
+
exitListener.indexOf(' return\n }\n'),
|
|
2542
|
+
)
|
|
2543
|
+
t.true(crashBranch.includes(' __releaseCurrentThreadHostTimers()\n'))
|
|
2544
|
+
// JavaScript only: nothing that unregisters, destroys or reaches the addon.
|
|
2545
|
+
const release = generatedFunction(code, '__releaseCurrentThreadHostTimers')
|
|
2546
|
+
t.true(release.length > 0)
|
|
2547
|
+
for (const forbidden of [
|
|
2548
|
+
'unregister',
|
|
2549
|
+
'__disposeCurrentThreadHosts',
|
|
2550
|
+
'__emnapiContext',
|
|
2551
|
+
'__napiModule',
|
|
2552
|
+
]) {
|
|
2553
|
+
t.false(release.includes(forbidden), forbidden)
|
|
2554
|
+
}
|
|
2555
|
+
|
|
2556
|
+
// The disposer releases once per crash disposal, and the 'exit' listener's
|
|
2557
|
+
// short path releases again (idempotent); without a crash neither does.
|
|
2558
|
+
const clean = await runWasiPublicDisposer(code, {})
|
|
2559
|
+
t.false(clean.steps.includes('release host timers'))
|
|
2560
|
+
const crashed = await runWasiPublicDisposer(code, { flag: true }, 3)
|
|
2561
|
+
t.deepEqual(crashed.steps, [
|
|
2562
|
+
'port unref',
|
|
2563
|
+
'release host timers',
|
|
2564
|
+
'terminate',
|
|
2565
|
+
'release host timers',
|
|
2566
|
+
'terminate',
|
|
2567
|
+
])
|
|
2568
|
+
t.deepEqual(runWasiExitListener(code, {}), ['destroy', 'terminate'])
|
|
2569
|
+
t.deepEqual(runWasiExitListener(code, { flag: true }), [
|
|
2570
|
+
'release host timers',
|
|
2571
|
+
'terminate',
|
|
2572
|
+
])
|
|
2573
|
+
|
|
2574
|
+
// Only the threaded Node loader has the crash latch.
|
|
2575
|
+
for (const { name, code: other } of [
|
|
2576
|
+
{ name: 'node cjs', code: createWasiBinding('test', '@scope/test') },
|
|
2577
|
+
...asyncRuntimeLoaderCases.filter(({ name }) => name !== 'node cjs'),
|
|
2578
|
+
]) {
|
|
2579
|
+
t.false(other.includes('__releaseCurrentThreadHostTimers'), name)
|
|
2580
|
+
t.false(other.includes('__trackCurrentThreadHostTimers'), name)
|
|
2581
|
+
}
|
|
2582
|
+
})
|
|
2583
|
+
|
|
2584
|
+
test('node WASI crash release clears the host timeouts without settling them', async (t) => {
|
|
2585
|
+
const code = threadedAsyncRuntimeCode
|
|
2586
|
+
const stateStart = code.indexOf('const __currentThreadHostTimerCancels = ')
|
|
2587
|
+
const state = code.slice(
|
|
2588
|
+
stateStart,
|
|
2589
|
+
code.indexOf(
|
|
2590
|
+
'let __currentThreadHostTimersReleased = false\n',
|
|
2591
|
+
stateStart,
|
|
2592
|
+
) + 'let __currentThreadHostTimersReleased = false\n'.length,
|
|
2593
|
+
)
|
|
2594
|
+
t.true(stateStart > 0)
|
|
2595
|
+
const { track, release } = new Function(`
|
|
2596
|
+
${state}
|
|
2597
|
+
${generatedFunction(code, '__isThenable')}
|
|
2598
|
+
${generatedFunction(code, '__scheduleCurrentThreadHostTimer')}
|
|
2599
|
+
${generatedFunction(code, '__trackCurrentThreadHostTimers')}
|
|
2600
|
+
${generatedFunction(code, '__releaseCurrentThreadHostTimers')}
|
|
2601
|
+
return {
|
|
2602
|
+
track: __trackCurrentThreadHostTimers,
|
|
2603
|
+
release: __releaseCurrentThreadHostTimers,
|
|
2604
|
+
}
|
|
2605
|
+
`)()
|
|
2606
|
+
|
|
2607
|
+
// A binding the package would reject is passed through untouched.
|
|
2608
|
+
const incomplete = { other: 1 }
|
|
2609
|
+
t.is(track(incomplete), incomplete)
|
|
2610
|
+
const throwing = {
|
|
2611
|
+
get registerTimerHost() {
|
|
2612
|
+
throw new Error('accessor')
|
|
2613
|
+
},
|
|
2614
|
+
}
|
|
2615
|
+
t.is(track(throwing), throwing)
|
|
2616
|
+
|
|
2617
|
+
// What the addon receives from `registerTimerHost`.
|
|
2618
|
+
let registered: any
|
|
2619
|
+
const binding = {
|
|
2620
|
+
reserveCurrentThreadHostRegistration() {},
|
|
2621
|
+
registerTimerHost(...args: any[]) {
|
|
2622
|
+
registered = args
|
|
2623
|
+
return 'registered'
|
|
2624
|
+
},
|
|
2625
|
+
}
|
|
2626
|
+
const view = track(binding)
|
|
2627
|
+
t.is(
|
|
2628
|
+
view.reserveCurrentThreadHostRegistration,
|
|
2629
|
+
binding.reserveCurrentThreadHostRegistration,
|
|
2630
|
+
)
|
|
2631
|
+
|
|
2632
|
+
// The package's timer host, armed timeouts by id. `cancel` clears one and
|
|
2633
|
+
// resolves its promise, as `installCurrentThreadHosts` does.
|
|
2634
|
+
const armed = new Map<
|
|
2635
|
+
number,
|
|
2636
|
+
{ resolve: () => void; reject: (e: Error) => void }
|
|
2637
|
+
>()
|
|
2638
|
+
const log: string[] = []
|
|
2639
|
+
let failCancel = false
|
|
2640
|
+
const schedule = (id: number) =>
|
|
2641
|
+
new Promise<void>((resolve, reject) => {
|
|
2642
|
+
log.push(`arm ${id}`)
|
|
2643
|
+
armed.set(id, { resolve, reject })
|
|
2644
|
+
})
|
|
2645
|
+
const cancel = (id: number) => {
|
|
2646
|
+
const timer = armed.get(id)
|
|
2647
|
+
if (!timer) return
|
|
2648
|
+
armed.delete(id)
|
|
2649
|
+
log.push(`clear ${id}`)
|
|
2650
|
+
if (failCancel) {
|
|
2651
|
+
const error = new Error(`cancel ${id} failed`)
|
|
2652
|
+
timer.reject(error)
|
|
2653
|
+
throw error
|
|
2654
|
+
}
|
|
2655
|
+
timer.resolve()
|
|
2656
|
+
}
|
|
2657
|
+
t.is(view.registerTimerHost(1, 2, schedule, cancel), 'registered')
|
|
2658
|
+
t.is(registered.length, 4)
|
|
2659
|
+
t.deepEqual(registered.slice(0, 2), [1, 2])
|
|
2660
|
+
t.not(registered[2], schedule)
|
|
2661
|
+
t.is(registered[3], cancel, 'cancel is handed to the addon as is')
|
|
2662
|
+
const addonSchedule = registered[2]
|
|
2663
|
+
|
|
2664
|
+
// No crash: settlements pass through.
|
|
2665
|
+
const fired = addonSchedule(1, 10)
|
|
2666
|
+
armed.get(1)!.resolve()
|
|
2667
|
+
t.is(await fired, undefined)
|
|
2668
|
+
const failed = addonSchedule(2, 10)
|
|
2669
|
+
armed.get(2)!.reject(new Error('host failed'))
|
|
2670
|
+
await t.throwsAsync(failed, { message: 'host failed' })
|
|
2671
|
+
const cancelled = addonSchedule(3, 10)
|
|
2672
|
+
cancel(3)
|
|
2673
|
+
t.is(await cancelled, undefined)
|
|
2674
|
+
|
|
2675
|
+
// After a crash: every armed timeout is cleared, and the addon's promise
|
|
2676
|
+
// never settles, so nothing is handed back to wasm.
|
|
2677
|
+
const settled: number[] = []
|
|
2678
|
+
const long = addonSchedule(4, 60_000)
|
|
2679
|
+
long.then(
|
|
2680
|
+
() => settled.push(4),
|
|
2681
|
+
() => settled.push(4),
|
|
2682
|
+
)
|
|
2683
|
+
failCancel = true
|
|
2684
|
+
const failing = addonSchedule(5, 60_000)
|
|
2685
|
+
failing.then(
|
|
2686
|
+
() => settled.push(5),
|
|
2687
|
+
() => settled.push(5),
|
|
2688
|
+
)
|
|
2689
|
+
log.length = 0
|
|
2690
|
+
t.notThrows(() => release())
|
|
2691
|
+
t.deepEqual(log, ['clear 4', 'clear 5'])
|
|
2692
|
+
t.false(armed.has(4))
|
|
2693
|
+
t.false(armed.has(5))
|
|
2694
|
+
// Idempotent, and a sleep armed afterwards is never armed.
|
|
2695
|
+
release()
|
|
2696
|
+
const late = addonSchedule(6, 10)
|
|
2697
|
+
late.then(
|
|
2698
|
+
() => settled.push(6),
|
|
2699
|
+
() => settled.push(6),
|
|
2700
|
+
)
|
|
2701
|
+
t.deepEqual(log, ['clear 4', 'clear 5'])
|
|
2702
|
+
await new Promise((resolve) => setImmediate(resolve))
|
|
2703
|
+
t.deepEqual(settled, [])
|
|
2704
|
+
})
|
|
2705
|
+
|
|
2706
|
+
/**
|
|
2707
|
+
* Runs the node loader's real disposal chain — the async-work drain, its
|
|
2708
|
+
* keep-alive interval, the barrier's two-phase poll, the step boundaries and
|
|
2709
|
+
* the public disposer — against a stubbed addon on real timers, so a crash can
|
|
2710
|
+
* land while a poll is waiting. The stubs record every wasm entry and every
|
|
2711
|
+
* keep-alive interval, and the counters report how often each poll read.
|
|
2712
|
+
*/
|
|
2713
|
+
function createInflightWasiDisposal(
|
|
2714
|
+
code: string,
|
|
2715
|
+
addon: {
|
|
2716
|
+
asyncWorkPending: number
|
|
2717
|
+
runtimeWorkPending: number
|
|
2718
|
+
onRuntimeWorkRead?: () => void
|
|
2719
|
+
},
|
|
2720
|
+
) {
|
|
2721
|
+
const latchStart = code.indexOf('const __wasiThreadCrashFlag = ')
|
|
2722
|
+
const latchState = code.slice(
|
|
2723
|
+
latchStart,
|
|
2724
|
+
code.indexOf('let __wasiThreadCrashed = false\n', latchStart) +
|
|
2725
|
+
'let __wasiThreadCrashed = false\n'.length,
|
|
2726
|
+
)
|
|
2727
|
+
const steps: string[] = []
|
|
2728
|
+
const reads = { asyncWork: 0, runtimeWork: 0 }
|
|
2729
|
+
const intervals = new Set<ReturnType<typeof setInterval>>()
|
|
2730
|
+
// Stands in for the loader's global: timers are real, keep-alive intervals
|
|
2731
|
+
// are recorded.
|
|
2732
|
+
const host = {
|
|
2733
|
+
setTimeout,
|
|
2734
|
+
clearTimeout,
|
|
2735
|
+
WebAssembly,
|
|
2736
|
+
setInterval(callback: () => void, delay: number) {
|
|
2737
|
+
const timer = setInterval(callback, delay)
|
|
2738
|
+
intervals.add(timer)
|
|
2739
|
+
steps.push('interval set')
|
|
2740
|
+
return timer
|
|
2741
|
+
},
|
|
2742
|
+
clearInterval(timer: ReturnType<typeof setInterval>) {
|
|
2743
|
+
clearInterval(timer)
|
|
2744
|
+
if (intervals.delete(timer)) {
|
|
2745
|
+
steps.push('interval cleared')
|
|
2746
|
+
}
|
|
2747
|
+
},
|
|
2748
|
+
}
|
|
2749
|
+
const exports = {
|
|
2750
|
+
napi_wasm_async_work_pending() {
|
|
2751
|
+
reads.asyncWork += 1
|
|
2752
|
+
return addon.asyncWorkPending
|
|
2753
|
+
},
|
|
2754
|
+
napi_wasm_cancel_pending_async_work() {
|
|
2755
|
+
steps.push('cancel')
|
|
2756
|
+
},
|
|
2757
|
+
napi_prepare_wasm_env_cleanup_begin() {
|
|
2758
|
+
steps.push('begin')
|
|
2759
|
+
return 1
|
|
2760
|
+
},
|
|
2761
|
+
napi_prepare_wasm_env_cleanup_finish() {
|
|
2762
|
+
steps.push('finish')
|
|
2763
|
+
},
|
|
2764
|
+
napi_wasm_runtime_work_pending() {
|
|
2765
|
+
reads.runtimeWork += 1
|
|
2766
|
+
addon.onRuntimeWorkRead?.()
|
|
2767
|
+
return addon.runtimeWorkPending
|
|
2768
|
+
},
|
|
2769
|
+
}
|
|
2770
|
+
const context = {
|
|
2771
|
+
refCounter: {
|
|
2772
|
+
refHandle: {
|
|
2773
|
+
ref() {
|
|
2774
|
+
steps.push('port ref')
|
|
2775
|
+
},
|
|
2776
|
+
unref() {
|
|
2777
|
+
steps.push('port unref')
|
|
2778
|
+
},
|
|
2779
|
+
},
|
|
2780
|
+
},
|
|
2781
|
+
}
|
|
2782
|
+
const loader = new Function(
|
|
2783
|
+
'globalThis',
|
|
2784
|
+
'steps',
|
|
2785
|
+
'__napiInstance',
|
|
2786
|
+
'__emnapiContext',
|
|
2787
|
+
`
|
|
2788
|
+
let __wasiDisposed = false
|
|
2789
|
+
let __wasiDisposePromise
|
|
2790
|
+
let __wasiThreadCrashDisposePromise
|
|
2791
|
+
let __wasiAsyncWorkDrainPromise
|
|
2792
|
+
let __emnapiWasmEnvCleanupPrepared = false
|
|
2793
|
+
let __emnapiWasmEnvCleanupPreparing = false
|
|
2794
|
+
let __emnapiWasmEnvCleanupYielding = false
|
|
2795
|
+
let __emnapiWasmEnvCleanupRan = false
|
|
2796
|
+
let __finishParkedWasmEnvCleanup
|
|
2797
|
+
let __completeWasiDisposal = function () {
|
|
2798
|
+
steps.push('complete')
|
|
2799
|
+
}
|
|
2800
|
+
let __retainWasiRollbackForRetry = function () {
|
|
2801
|
+
steps.push('retain')
|
|
2802
|
+
}
|
|
2803
|
+
const __wasiRollbackRegistry = new Map()
|
|
2804
|
+
const __wasiRollbackRegistryKey = 'test.cjs'
|
|
2805
|
+
${latchState}
|
|
2806
|
+
const __WASI_ASYNC_WORK_POLL_INTERVAL_MS = 1
|
|
2807
|
+
function __getWasiThreadManager() {
|
|
2808
|
+
return { terminateWorker() {} }
|
|
2809
|
+
}
|
|
2810
|
+
function __isThenable(value) {
|
|
2811
|
+
return value !== null && typeof value === 'object' && typeof value.then === 'function'
|
|
2812
|
+
}
|
|
2813
|
+
function __scheduleMacrotask(callback) {
|
|
2814
|
+
setImmediate(callback)
|
|
2815
|
+
}
|
|
2816
|
+
function __prepareWasmEnvCleanup() {
|
|
2817
|
+
steps.push('prepare (single call)')
|
|
2818
|
+
}
|
|
2819
|
+
function __createWasmRuntimePollPace() {
|
|
2820
|
+
return {}
|
|
2821
|
+
}
|
|
2822
|
+
function __yieldWasmRuntimePollTurn() {
|
|
2823
|
+
return new Promise((resolve) => globalThis.setTimeout(resolve, 1))
|
|
2824
|
+
}
|
|
2825
|
+
function __drainWasmEnvCleanup() {
|
|
2826
|
+
steps.push('drain')
|
|
2827
|
+
}
|
|
2828
|
+
function __destroyEmnapiContext() {
|
|
2829
|
+
steps.push('destroy')
|
|
2830
|
+
}
|
|
2831
|
+
function __terminateWasiWorkers() {
|
|
2832
|
+
steps.push('terminate')
|
|
2833
|
+
return Promise.resolve()
|
|
2834
|
+
}
|
|
2835
|
+
${generatedFunction(code, '__createCleanupError')}
|
|
2836
|
+
${generatedFunction(code, '__attachCleanupErrors')}
|
|
2837
|
+
${generatedFunction(code, '__hasWasiThreadCrashed')}
|
|
2838
|
+
${generatedFunction(code, '__readWasiThreadCrashReport')}
|
|
2839
|
+
${generatedFunction(code, '__recordWasiThreadCrashError')}
|
|
2840
|
+
${generatedFunction(code, '__getWasiThreadCrashError')}
|
|
2841
|
+
${generatedFunction(code, '__fillWasiThreadCrashError')}
|
|
2842
|
+
${generatedFunction(code, '__releaseEmnapiWaitingRequestHandle')}
|
|
2843
|
+
${generatedFunction(code, '__disposeWasiBindingAfterThreadCrash')}
|
|
2844
|
+
${generatedFunction(code, '__abortWasiDisposalIfThreadCrashed')}
|
|
2845
|
+
${generatedFunction(code, '__settleWasiDisposalAfterThreadCrash')}
|
|
2846
|
+
${generatedFunction(code, '__scheduleTimer')}
|
|
2847
|
+
${generatedFunction(code, '__keepEventLoopAliveUntil')}
|
|
2848
|
+
${generatedFunction(code, '__drainWasiAsyncWork')}
|
|
2849
|
+
${generatedFunction(code, '__prepareWasmEnvCleanupWithTurns')}
|
|
2850
|
+
${generatedFunction(code, '__finishWasiDisposal')}
|
|
2851
|
+
${generatedFunction(code, '__continueWasiDisposal')}
|
|
2852
|
+
${generatedFunction(code, '__drainWasmEnvForWasiDisposal')}
|
|
2853
|
+
${generatedFunction(code, '__cleanUpWasmEnvForWasiDisposal')}
|
|
2854
|
+
${generatedFunction(code, '__startWasiDisposal')}
|
|
2855
|
+
${generatedFunction(code, '__disposeWasiBinding')}
|
|
2856
|
+
${generatedFunction(code, '__finishWasiInitializationRollback')}
|
|
2857
|
+
${generatedFunction(code, '__destroyContextForWasiRollback')}
|
|
2858
|
+
${generatedFunction(code, '__retainFailedWasiRollback')}
|
|
2859
|
+
${generatedFunction(code, '__runWasiInitializationRollbackSteps')}
|
|
2860
|
+
${generatedFunction(code, '__rollbackWasiInitialization')}
|
|
2861
|
+
${generatedFunction(code, '__rollbackWasiInitializationAfterThreadCrash')}
|
|
2862
|
+
${generatedFunction(code, '__completeWasiInitializationRollback')}
|
|
2863
|
+
${generatedFunction(code, '__runWasiInitializationRollback')}
|
|
2864
|
+
return {
|
|
2865
|
+
dispose: __disposeWasiBinding,
|
|
2866
|
+
drainAsyncWork: __drainWasiAsyncWork,
|
|
2867
|
+
rollback: __rollbackWasiInitialization,
|
|
2868
|
+
// What the loader's catch does when instantiation fails: start the rollback
|
|
2869
|
+
// and throw the initialization error itself.
|
|
2870
|
+
failInitialization(error) {
|
|
2871
|
+
const record = {
|
|
2872
|
+
active: false,
|
|
2873
|
+
error,
|
|
2874
|
+
promise: undefined,
|
|
2875
|
+
rollback: __rollbackWasiInitialization,
|
|
2876
|
+
}
|
|
2877
|
+
__wasiRollbackRegistry.set(__wasiRollbackRegistryKey, record)
|
|
2878
|
+
__runWasiInitializationRollback(record)
|
|
2879
|
+
return record
|
|
2880
|
+
},
|
|
2881
|
+
registered() {
|
|
2882
|
+
return __wasiRollbackRegistry.has(__wasiRollbackRegistryKey)
|
|
2883
|
+
},
|
|
2884
|
+
// The loader's own 'error' listener.
|
|
2885
|
+
workerError(error, threadId) {
|
|
2886
|
+
__wasiThreadCrashed = true
|
|
2887
|
+
__recordWasiThreadCrashError(error, threadId)
|
|
2888
|
+
},
|
|
2889
|
+
crash() {
|
|
2890
|
+
Atomics.store(__wasiThreadCrashFlag, 0, 1)
|
|
2891
|
+
},
|
|
2892
|
+
parked() {
|
|
2893
|
+
return __finishParkedWasmEnvCleanup !== undefined
|
|
2894
|
+
},
|
|
2895
|
+
}
|
|
2896
|
+
`,
|
|
2897
|
+
)(host, steps, { exports }, context)
|
|
2898
|
+
return {
|
|
2899
|
+
...(loader as {
|
|
2900
|
+
dispose: () => Promise<unknown>
|
|
2901
|
+
drainAsyncWork: () => Promise<unknown> | undefined
|
|
2902
|
+
rollback: () => unknown
|
|
2903
|
+
failInitialization: (error: unknown) => {
|
|
2904
|
+
active: boolean
|
|
2905
|
+
error: any
|
|
2906
|
+
promise: Promise<void> | undefined
|
|
2907
|
+
}
|
|
2908
|
+
registered: () => boolean
|
|
2909
|
+
workerError: (error: unknown, threadId: number) => void
|
|
2910
|
+
crash: () => void
|
|
2911
|
+
parked: () => boolean
|
|
2912
|
+
}),
|
|
2913
|
+
steps,
|
|
2914
|
+
reads,
|
|
2915
|
+
liveIntervals: () => intervals.size,
|
|
2916
|
+
}
|
|
2917
|
+
}
|
|
2918
|
+
|
|
2919
|
+
async function waitUntil(condition: () => boolean, label: string) {
|
|
2920
|
+
const deadline = Date.now() + 5000
|
|
2921
|
+
while (!condition()) {
|
|
2922
|
+
if (Date.now() > deadline) {
|
|
2923
|
+
throw new Error(`timed out waiting for ${label}`)
|
|
2924
|
+
}
|
|
2925
|
+
await new Promise((resolve) => setTimeout(resolve, 1))
|
|
2926
|
+
}
|
|
2927
|
+
}
|
|
2928
|
+
|
|
2929
|
+
async function settleWithin(promise: Promise<unknown>, ms = 2000) {
|
|
2930
|
+
let timer: ReturnType<typeof setTimeout> | undefined
|
|
2931
|
+
const timeout = new Promise((resolve) => {
|
|
2932
|
+
timer = setTimeout(() => resolve({ pending: true }), ms)
|
|
2933
|
+
})
|
|
2934
|
+
try {
|
|
2935
|
+
return await Promise.race([
|
|
2936
|
+
promise.then(
|
|
2937
|
+
(value) => ({ value }),
|
|
2938
|
+
(error) => ({ error }),
|
|
2939
|
+
),
|
|
2940
|
+
timeout,
|
|
2941
|
+
])
|
|
2942
|
+
} finally {
|
|
2943
|
+
clearTimeout(timer)
|
|
2944
|
+
}
|
|
2945
|
+
}
|
|
2946
|
+
|
|
2947
|
+
const CRASH_DISPOSAL_MESSAGE =
|
|
2948
|
+
'napi-rs: WASI binding cannot be disposed after a worker thread crashed'
|
|
2949
|
+
|
|
2950
|
+
// The entry check only sees a crash that came first. A worker that dies while
|
|
2951
|
+
// dispose() is waiting leaves the async-work drain polling a count the dead
|
|
2952
|
+
// thread never releases, and its 1 ms timer plus the 50 ms keep-alive interval
|
|
2953
|
+
// held the process open for good with the promise pending. The drain now stops
|
|
2954
|
+
// on the next turn and the disposer rejects through the crash disposal.
|
|
2955
|
+
test('node WASI disposal stops the async-work drain when a worker crashes mid-flight', async (t) => {
|
|
2956
|
+
const code = createWasiBinding('test', '@scope/test')
|
|
2957
|
+
const binding = createInflightWasiDisposal(code, {
|
|
2958
|
+
asyncWorkPending: 1,
|
|
2959
|
+
runtimeWorkPending: 0,
|
|
2960
|
+
})
|
|
2961
|
+
const first = binding.dispose()
|
|
2962
|
+
await waitUntil(() => binding.reads.asyncWork >= 5, 'the drain to poll')
|
|
2963
|
+
t.is(binding.liveIntervals(), 1, 'the keep-alive interval is armed')
|
|
2964
|
+
binding.crash()
|
|
2965
|
+
// A caller that arrives before the poll notices joins the same disposal.
|
|
2966
|
+
const joined = binding.dispose()
|
|
2967
|
+
const outcome: any = await settleWithin(first)
|
|
2968
|
+
t.falsy(outcome.pending, 'the original promise settles')
|
|
2969
|
+
t.true(outcome.error instanceof Error)
|
|
2970
|
+
t.is(outcome.error.message, CRASH_DISPOSAL_MESSAGE)
|
|
2971
|
+
t.is(joined, first)
|
|
2972
|
+
// Every later call gets the same promise, and it stays rejected.
|
|
2973
|
+
const later = binding.dispose()
|
|
2974
|
+
t.is(later, first)
|
|
2975
|
+
t.is(((await settleWithin(later)) as any).error, outcome.error)
|
|
2976
|
+
|
|
2977
|
+
t.is(binding.liveIntervals(), 0, 'the keep-alive interval is cleared')
|
|
2978
|
+
const readsAfter = binding.reads.asyncWork
|
|
2979
|
+
await new Promise((resolve) => setTimeout(resolve, 20))
|
|
2980
|
+
t.is(binding.reads.asyncWork, readsAfter, 'the drain stopped polling')
|
|
2981
|
+
// Nothing re-entered the barrier, the context or the addon after the crash;
|
|
2982
|
+
// the port is released and the workers terminated exactly once.
|
|
2983
|
+
t.deepEqual(binding.steps, [
|
|
2984
|
+
'cancel',
|
|
2985
|
+
'interval set',
|
|
2986
|
+
'interval cleared',
|
|
2987
|
+
'port unref',
|
|
2988
|
+
'terminate',
|
|
2989
|
+
])
|
|
2990
|
+
})
|
|
2991
|
+
|
|
2992
|
+
// Same race one step later: the drain passed, the barrier's two-phase form
|
|
2993
|
+
// began, and its poll waits for runtime work the dead thread still counts.
|
|
2994
|
+
// `…_finish` would join that work and block this thread for good, so the
|
|
2995
|
+
// barrier is left parked and never finished.
|
|
2996
|
+
test('node WASI disposal stops the barrier poll when a worker crashes mid-flight', async (t) => {
|
|
2997
|
+
const code = createWasiBinding('test', '@scope/test')
|
|
2998
|
+
for (const workDrainsWithCrash of [false, true]) {
|
|
2999
|
+
const label = `work drains in the crash turn: ${workDrainsWithCrash}`
|
|
3000
|
+
const addon: Parameters<typeof createInflightWasiDisposal>[1] = {
|
|
3001
|
+
asyncWorkPending: 0,
|
|
3002
|
+
runtimeWorkPending: 1,
|
|
3003
|
+
}
|
|
3004
|
+
const binding = createInflightWasiDisposal(code, addon)
|
|
3005
|
+
const first = binding.dispose()
|
|
3006
|
+
await waitUntil(() => binding.reads.runtimeWork >= 5, 'the barrier to poll')
|
|
3007
|
+
t.true(binding.parked(), label)
|
|
3008
|
+
if (workDrainsWithCrash) {
|
|
3009
|
+
// The thread dies while the poll is reading, and the count it reads is
|
|
3010
|
+
// zero: the poll returns normally and would go straight on to
|
|
3011
|
+
// `…_finish`.
|
|
3012
|
+
await new Promise<void>((resolve) => {
|
|
3013
|
+
addon.onRuntimeWorkRead = () => {
|
|
3014
|
+
addon.onRuntimeWorkRead = undefined
|
|
3015
|
+
binding.crash()
|
|
3016
|
+
addon.runtimeWorkPending = 0
|
|
3017
|
+
resolve()
|
|
3018
|
+
}
|
|
3019
|
+
})
|
|
3020
|
+
} else {
|
|
3021
|
+
binding.crash()
|
|
3022
|
+
}
|
|
3023
|
+
const outcome: any = await settleWithin(first)
|
|
3024
|
+
t.falsy(outcome.pending, label)
|
|
3025
|
+
t.is(outcome.error?.message, CRASH_DISPOSAL_MESSAGE, label)
|
|
3026
|
+
t.is(binding.dispose(), first, label)
|
|
3027
|
+
t.false(binding.parked(), label)
|
|
3028
|
+
const readsAfter = binding.reads.runtimeWork
|
|
3029
|
+
await new Promise((resolve) => setTimeout(resolve, 20))
|
|
3030
|
+
t.is(binding.reads.runtimeWork, readsAfter, label)
|
|
3031
|
+
t.is(binding.liveIntervals(), 0, label)
|
|
3032
|
+
t.deepEqual(binding.steps, ['begin', 'port unref', 'terminate'], label)
|
|
3033
|
+
}
|
|
3034
|
+
})
|
|
3035
|
+
|
|
3036
|
+
// Without a crash the same chain runs every step exactly as before and
|
|
3037
|
+
// resolves; the keep-alive interval lives only as long as the drain.
|
|
3038
|
+
test('node WASI disposal without a crash runs the whole chain on real timers', async (t) => {
|
|
3039
|
+
const code = createWasiBinding('test', '@scope/test')
|
|
3040
|
+
const addon = { asyncWorkPending: 1, runtimeWorkPending: 1 }
|
|
3041
|
+
const binding = createInflightWasiDisposal(code, addon)
|
|
3042
|
+
const first = binding.dispose()
|
|
3043
|
+
await waitUntil(() => binding.reads.asyncWork >= 3, 'the drain to poll')
|
|
3044
|
+
addon.asyncWorkPending = 0
|
|
3045
|
+
await waitUntil(() => binding.reads.runtimeWork >= 3, 'the barrier to poll')
|
|
3046
|
+
addon.runtimeWorkPending = 0
|
|
3047
|
+
t.deepEqual(await settleWithin(first), { value: undefined })
|
|
3048
|
+
t.deepEqual(binding.steps, [
|
|
3049
|
+
'cancel',
|
|
3050
|
+
'interval set',
|
|
3051
|
+
'interval cleared',
|
|
3052
|
+
'begin',
|
|
3053
|
+
'finish',
|
|
3054
|
+
'drain',
|
|
3055
|
+
'destroy',
|
|
3056
|
+
'terminate',
|
|
3057
|
+
'complete',
|
|
3058
|
+
])
|
|
3059
|
+
t.is(binding.liveIntervals(), 0)
|
|
3060
|
+
})
|
|
3061
|
+
|
|
3062
|
+
// The crash check fires only while a public disposal or the initialization
|
|
3063
|
+
// rollback is in flight. A poll started by neither keeps polling.
|
|
3064
|
+
test('node WASI crash abort leaves a poll outside disposal and rollback alone', async (t) => {
|
|
3065
|
+
const code = createWasiBinding('test', '@scope/test')
|
|
3066
|
+
const addon = { asyncWorkPending: 1, runtimeWorkPending: 0 }
|
|
3067
|
+
const binding = createInflightWasiDisposal(code, addon)
|
|
3068
|
+
const drain = binding.drainAsyncWork()
|
|
3069
|
+
t.truthy(drain)
|
|
3070
|
+
binding.crash()
|
|
3071
|
+
const readsAtCrash = binding.reads.asyncWork
|
|
3072
|
+
await waitUntil(
|
|
3073
|
+
() => binding.reads.asyncWork >= readsAtCrash + 5,
|
|
3074
|
+
'the drain to keep polling',
|
|
3075
|
+
)
|
|
3076
|
+
addon.asyncWorkPending = 0
|
|
3077
|
+
t.deepEqual(await settleWithin(drain!), { value: undefined })
|
|
3078
|
+
t.false(binding.steps.includes('terminate'))
|
|
3079
|
+
t.false(binding.steps.includes('port unref'))
|
|
3080
|
+
})
|
|
3081
|
+
|
|
3082
|
+
// The initialization rollback runs the same drain as dispose(). A worker that
|
|
3083
|
+
// died while it polled left the count above zero, and the rollback polled it
|
|
3084
|
+
// forever with its timers holding the process open. It now stops, skips the
|
|
3085
|
+
// barrier and the destroy, and lets the initialization error through with the
|
|
3086
|
+
// crash attached.
|
|
3087
|
+
test('node WASI initialization rollback stops its async-work drain when a worker crashes', async (t) => {
|
|
3088
|
+
const code = createWasiBinding('test', '@scope/test')
|
|
3089
|
+
const binding = createInflightWasiDisposal(code, {
|
|
3090
|
+
asyncWorkPending: 1,
|
|
3091
|
+
runtimeWorkPending: 0,
|
|
3092
|
+
})
|
|
3093
|
+
const initError = new Error('instantiate failed')
|
|
3094
|
+
const record = binding.failInitialization(initError)
|
|
3095
|
+
const rollback = record.promise
|
|
3096
|
+
t.truthy(rollback, 'the rollback waits for the drain')
|
|
3097
|
+
await waitUntil(() => binding.reads.asyncWork >= 5, 'the rollback to poll')
|
|
3098
|
+
t.is(binding.liveIntervals(), 1)
|
|
3099
|
+
binding.crash()
|
|
3100
|
+
t.falsy(((await settleWithin(rollback!)) as any).pending)
|
|
3101
|
+
|
|
3102
|
+
// The error the loader threw is the initialization error, and the crash is
|
|
3103
|
+
// its cause.
|
|
3104
|
+
t.is(record.error, initError)
|
|
3105
|
+
t.true(record.error.cause instanceof Error)
|
|
3106
|
+
t.is(record.error.cause.message, CRASH_DISPOSAL_MESSAGE)
|
|
3107
|
+
t.false(record.active)
|
|
3108
|
+
t.true(binding.registered(), 'kept: the context was not destroyed')
|
|
3109
|
+
|
|
3110
|
+
t.is(binding.liveIntervals(), 0, 'the keep-alive interval is cleared')
|
|
3111
|
+
const readsAfter = binding.reads.asyncWork
|
|
3112
|
+
await new Promise((resolve) => setTimeout(resolve, 20))
|
|
3113
|
+
t.is(binding.reads.asyncWork, readsAfter, 'the drain stopped polling')
|
|
3114
|
+
// No barrier, no settlement drain, no destroy: the port is released and the
|
|
3115
|
+
// workers are terminated once.
|
|
3116
|
+
t.deepEqual(binding.steps, [
|
|
3117
|
+
'cancel',
|
|
3118
|
+
'interval set',
|
|
3119
|
+
'interval cleared',
|
|
3120
|
+
'retain',
|
|
3121
|
+
'port unref',
|
|
3122
|
+
'terminate',
|
|
3123
|
+
])
|
|
3124
|
+
|
|
3125
|
+
// The worker's 'error' event arrives after the rollback ended: the crash
|
|
3126
|
+
// error it already attached gets the worker's error as its cause.
|
|
3127
|
+
t.is(record.error.cause.cause, undefined)
|
|
3128
|
+
const workerError = new Error('trap in worker')
|
|
3129
|
+
binding.workerError(workerError, 7)
|
|
3130
|
+
t.is(record.error.cause.cause, workerError)
|
|
3131
|
+
t.is(record.error.cause.workerThreadId, 7)
|
|
3132
|
+
})
|
|
3133
|
+
|
|
3134
|
+
// Same race one step later: the barrier began and its poll waits for runtime
|
|
3135
|
+
// work the dead thread still counts. `…_finish` would join that work, so the
|
|
3136
|
+
// barrier stays parked and is never finished.
|
|
3137
|
+
test('node WASI initialization rollback stops the barrier poll when a worker crashes', async (t) => {
|
|
3138
|
+
const code = createWasiBinding('test', '@scope/test')
|
|
3139
|
+
const binding = createInflightWasiDisposal(code, {
|
|
3140
|
+
asyncWorkPending: 0,
|
|
3141
|
+
runtimeWorkPending: 1,
|
|
3142
|
+
})
|
|
3143
|
+
const initError = new Error('instantiate failed')
|
|
3144
|
+
const record = binding.failInitialization(initError)
|
|
3145
|
+
const rollback = record.promise
|
|
3146
|
+
t.truthy(rollback)
|
|
3147
|
+
await waitUntil(() => binding.reads.runtimeWork >= 5, 'the barrier to poll')
|
|
3148
|
+
t.true(binding.parked())
|
|
3149
|
+
binding.crash()
|
|
3150
|
+
t.falsy(((await settleWithin(rollback!)) as any).pending)
|
|
3151
|
+
t.is(record.error, initError)
|
|
3152
|
+
t.is(record.error.cause?.message, CRASH_DISPOSAL_MESSAGE)
|
|
3153
|
+
t.false(binding.parked())
|
|
3154
|
+
const readsAfter = binding.reads.runtimeWork
|
|
3155
|
+
await new Promise((resolve) => setTimeout(resolve, 20))
|
|
3156
|
+
t.is(binding.reads.runtimeWork, readsAfter)
|
|
3157
|
+
t.deepEqual(binding.steps, ['begin', 'retain', 'port unref', 'terminate'])
|
|
3158
|
+
})
|
|
3159
|
+
|
|
3160
|
+
// A thread that died before instantiation failed: the rollback does not enter
|
|
3161
|
+
// wasm at all, and it stays synchronous, so the error the loader throws right
|
|
3162
|
+
// away already carries the crash.
|
|
3163
|
+
test('node WASI initialization rollback after an earlier crash skips wasm', (t) => {
|
|
3164
|
+
const code = createWasiBinding('test', '@scope/test')
|
|
3165
|
+
const binding = createInflightWasiDisposal(code, {
|
|
3166
|
+
asyncWorkPending: 1,
|
|
3167
|
+
runtimeWorkPending: 1,
|
|
3168
|
+
})
|
|
3169
|
+
binding.crash()
|
|
3170
|
+
const initError = new Error('instantiate failed')
|
|
3171
|
+
const record = binding.failInitialization(initError)
|
|
3172
|
+
t.is(record.promise, undefined, 'settled synchronously')
|
|
3173
|
+
t.is(record.error, initError)
|
|
3174
|
+
t.is(record.error.cause?.message, CRASH_DISPOSAL_MESSAGE)
|
|
3175
|
+
t.is(binding.reads.asyncWork, 0)
|
|
3176
|
+
t.is(binding.reads.runtimeWork, 0)
|
|
3177
|
+
t.deepEqual(binding.steps, ['port unref', 'terminate'])
|
|
3178
|
+
|
|
3179
|
+
// An initialization error that already has a cause keeps it; the crash is
|
|
3180
|
+
// listed with the cleanup errors instead. The crash error is shared, and the
|
|
3181
|
+
// workers are not terminated a second time.
|
|
3182
|
+
const withCause = new Error('instantiate failed', {
|
|
3183
|
+
cause: new Error('link error'),
|
|
3184
|
+
})
|
|
3185
|
+
const second = binding.failInitialization(withCause)
|
|
3186
|
+
t.is(second.error, withCause)
|
|
3187
|
+
t.is(second.error.cause.message, 'link error')
|
|
3188
|
+
t.is(second.error.cleanupErrors[0], record.error.cause)
|
|
3189
|
+
t.deepEqual(binding.steps, ['port unref', 'terminate'])
|
|
3190
|
+
})
|
|
3191
|
+
|
|
3192
|
+
// Without a crash the rollback runs every step exactly as before, resolves
|
|
3193
|
+
// with no cleanup errors and leaves the initialization error untouched.
|
|
3194
|
+
test('node WASI initialization rollback without a crash is unchanged', async (t) => {
|
|
3195
|
+
const code = createWasiBinding('test', '@scope/test')
|
|
3196
|
+
const addon = { asyncWorkPending: 1, runtimeWorkPending: 1 }
|
|
3197
|
+
const binding = createInflightWasiDisposal(code, addon)
|
|
3198
|
+
const initError = new Error('instantiate failed')
|
|
3199
|
+
const record = binding.failInitialization(initError)
|
|
3200
|
+
const rollback = record.promise
|
|
3201
|
+
t.truthy(rollback)
|
|
3202
|
+
await waitUntil(() => binding.reads.asyncWork >= 3, 'the drain to poll')
|
|
3203
|
+
addon.asyncWorkPending = 0
|
|
3204
|
+
await waitUntil(() => binding.reads.runtimeWork >= 3, 'the barrier to poll')
|
|
3205
|
+
addon.runtimeWorkPending = 0
|
|
3206
|
+
t.deepEqual(await settleWithin(rollback!), { value: undefined })
|
|
3207
|
+
t.is(record.error, initError)
|
|
3208
|
+
t.is(record.error.cause, undefined)
|
|
3209
|
+
t.is(record.error.cleanupErrors, undefined)
|
|
3210
|
+
t.false(binding.registered(), 'a clean rollback is not kept')
|
|
3211
|
+
t.deepEqual(binding.steps, [
|
|
3212
|
+
'cancel',
|
|
3213
|
+
'interval set',
|
|
3214
|
+
'interval cleared',
|
|
3215
|
+
'begin',
|
|
3216
|
+
'finish',
|
|
3217
|
+
'drain',
|
|
3218
|
+
'destroy',
|
|
3219
|
+
'terminate',
|
|
3220
|
+
])
|
|
3221
|
+
t.is(binding.liveIntervals(), 0)
|
|
3222
|
+
})
|
|
3223
|
+
|
|
3224
|
+
// The shared flag is raised before this thread has processed the worker's
|
|
3225
|
+
// 'error' event, and once the workers are terminated emnapi ignores that event,
|
|
3226
|
+
// so `_fatalError` was often still unset when the crash error was built and
|
|
3227
|
+
// the cause was lost. The loader's own listener keeps the first error, and the
|
|
3228
|
+
// crash error picks it up whenever it arrives.
|
|
3229
|
+
test('node WASI crash error takes its cause from the worker error listener', async (t) => {
|
|
3230
|
+
const code = createWasiBinding('test', '@scope/test')
|
|
3231
|
+
|
|
3232
|
+
// The event lands while the crash disposal is terminating the workers.
|
|
3233
|
+
{
|
|
3234
|
+
const binding = createInflightWasiDisposal(code, {
|
|
3235
|
+
asyncWorkPending: 1,
|
|
3236
|
+
runtimeWorkPending: 0,
|
|
3237
|
+
})
|
|
3238
|
+
const first = binding.dispose()
|
|
3239
|
+
await waitUntil(() => binding.reads.asyncWork >= 3, 'the drain to poll')
|
|
3240
|
+
binding.crash()
|
|
3241
|
+
const workerError = new Error('trap in worker')
|
|
3242
|
+
binding.workerError(workerError, 3)
|
|
3243
|
+
// Only the first error is kept.
|
|
3244
|
+
binding.workerError(new Error('second worker'), 4)
|
|
3245
|
+
const outcome: any = await settleWithin(first)
|
|
3246
|
+
t.is(outcome.error?.message, CRASH_DISPOSAL_MESSAGE)
|
|
3247
|
+
t.is(outcome.error.cause, workerError)
|
|
3248
|
+
t.is(outcome.error.workerThreadId, 3)
|
|
3249
|
+
}
|
|
3250
|
+
|
|
3251
|
+
// The event lands after the disposal already rejected: the one shared crash
|
|
3252
|
+
// error is completed in place, so every holder of it sees the cause.
|
|
3253
|
+
{
|
|
3254
|
+
const binding = createInflightWasiDisposal(code, {
|
|
3255
|
+
asyncWorkPending: 1,
|
|
3256
|
+
runtimeWorkPending: 0,
|
|
3257
|
+
})
|
|
3258
|
+
const first = binding.dispose()
|
|
3259
|
+
await waitUntil(() => binding.reads.asyncWork >= 3, 'the drain to poll')
|
|
3260
|
+
binding.crash()
|
|
3261
|
+
const outcome: any = await settleWithin(first)
|
|
3262
|
+
t.is(outcome.error?.message, CRASH_DISPOSAL_MESSAGE)
|
|
3263
|
+
t.is(outcome.error.cause, undefined)
|
|
3264
|
+
const workerError = new Error('trap in worker')
|
|
3265
|
+
binding.workerError(workerError, 9)
|
|
3266
|
+
t.is(outcome.error.cause, workerError)
|
|
3267
|
+
t.is(outcome.error.workerThreadId, 9)
|
|
3268
|
+
const later: any = await settleWithin(binding.dispose())
|
|
3269
|
+
t.is(later.error, outcome.error)
|
|
3270
|
+
}
|
|
3271
|
+
})
|
|
3272
|
+
|
|
3273
|
+
test('WASI loaders without the crash latch keep their disposal polls unchanged', (t) => {
|
|
3274
|
+
const loaders = {
|
|
3275
|
+
'threadless node': createWasiBinding(
|
|
3276
|
+
'test',
|
|
3277
|
+
'@scope/test',
|
|
3278
|
+
4000,
|
|
3279
|
+
65536,
|
|
3280
|
+
false,
|
|
3281
|
+
),
|
|
3282
|
+
'threaded browser': createWasiBrowserBinding('test-wasi'),
|
|
3283
|
+
'threadless browser': createWasiBrowserBinding(
|
|
3284
|
+
'test-wasi',
|
|
3285
|
+
4000,
|
|
3286
|
+
65536,
|
|
3287
|
+
false,
|
|
3288
|
+
false,
|
|
3289
|
+
false,
|
|
3290
|
+
false,
|
|
3291
|
+
false,
|
|
3292
|
+
),
|
|
3293
|
+
}
|
|
3294
|
+
for (const [label, code] of Object.entries(loaders)) {
|
|
3295
|
+
t.false(code.includes('__abortWasiDisposalIfThreadCrashed'), label)
|
|
3296
|
+
t.false(code.includes('__settleWasiDisposalAfterThreadCrash'), label)
|
|
3297
|
+
t.false(code.includes('finishCleanupUnlessCrashed'), label)
|
|
3298
|
+
t.false(code.includes('__wasiInitializationRollbackActive'), label)
|
|
3299
|
+
t.false(code.includes('__runWasiInitializationRollbackSteps'), label)
|
|
3300
|
+
t.false(
|
|
3301
|
+
code.includes('__rollbackWasiInitializationAfterThreadCrash'),
|
|
3302
|
+
label,
|
|
3303
|
+
)
|
|
3304
|
+
t.false(code.includes('__getWasiThreadCrashError'), label)
|
|
3305
|
+
t.false(code.includes('__recordWasiThreadCrashError'), label)
|
|
3306
|
+
t.false(code.includes('__readWasiThreadCrashReport'), label)
|
|
3307
|
+
t.true(code.includes('function __rollbackWasiInitialization() {\n'), label)
|
|
3308
|
+
t.true(
|
|
3309
|
+
generatedFunction(code, '__prepareWasmEnvCleanupWithTurns').includes(
|
|
3310
|
+
' })().then(finishCleanup, finishCleanup)\n',
|
|
3311
|
+
),
|
|
3312
|
+
label,
|
|
3313
|
+
)
|
|
3314
|
+
}
|
|
3315
|
+
// The threaded node loader checks the latch in every disposal poll and step,
|
|
3316
|
+
// and in the rollback's steps.
|
|
3317
|
+
const threaded = createWasiBinding('test', '@scope/test')
|
|
3318
|
+
for (const name of [
|
|
3319
|
+
'__drainWasiAsyncWork',
|
|
3320
|
+
'__prepareWasmEnvCleanupWithTurns',
|
|
3321
|
+
'__cleanUpWasmEnvForWasiDisposal',
|
|
3322
|
+
'__drainWasmEnvForWasiDisposal',
|
|
3323
|
+
'__continueWasiDisposal',
|
|
3324
|
+
'__destroyContextForWasiRollback',
|
|
3325
|
+
]) {
|
|
3326
|
+
t.true(
|
|
3327
|
+
generatedFunction(threaded, name).includes(
|
|
3328
|
+
'__abortWasiDisposalIfThreadCrashed()',
|
|
3329
|
+
),
|
|
3330
|
+
name,
|
|
3331
|
+
)
|
|
3332
|
+
}
|
|
3333
|
+
const steps = generatedFunction(
|
|
3334
|
+
threaded,
|
|
3335
|
+
'__runWasiInitializationRollbackSteps',
|
|
3336
|
+
)
|
|
3337
|
+
t.is(
|
|
3338
|
+
steps.split(' __abortWasiDisposalIfThreadCrashed()\n').length - 1,
|
|
3339
|
+
2,
|
|
3340
|
+
'before the barrier and before the settlement drain',
|
|
3341
|
+
)
|
|
3342
|
+
})
|
|
3343
|
+
|
|
3344
|
+
test('node WASI loader shares the crash flag with its pool workers', (t) => {
|
|
3345
|
+
const code = createWasiBinding('test', '@scope/test')
|
|
3346
|
+
const createWorker = generatedFunction(code, '__createWasiWorker')
|
|
3347
|
+
t.true(createWorker.includes('crashFlag: __wasiThreadCrashFlag,'))
|
|
3348
|
+
t.true(createWorker.includes('crashReport: __wasiThreadCrashReport,'))
|
|
3349
|
+
const onCreateWorker = code.slice(
|
|
3350
|
+
code.indexOf('onCreateWorker() {'),
|
|
3351
|
+
code.indexOf('return worker\n', code.indexOf('onCreateWorker() {')),
|
|
3352
|
+
)
|
|
3353
|
+
t.true(
|
|
3354
|
+
onCreateWorker.includes("worker.on('error', (error) => {"),
|
|
3355
|
+
'the loader has to see a worker error even when the worker never set the flag',
|
|
3356
|
+
)
|
|
3357
|
+
t.true(onCreateWorker.includes('__wasiThreadCrashed = true'))
|
|
3358
|
+
t.true(
|
|
3359
|
+
onCreateWorker.includes(
|
|
3360
|
+
'__recordWasiThreadCrashError(error, worker.threadId)',
|
|
3361
|
+
),
|
|
3362
|
+
'and keeps the error it carries for the crash error',
|
|
3363
|
+
)
|
|
3364
|
+
})
|
|
3365
|
+
|
|
3366
|
+
// The threadless flavor has no pool workers and must not reference the latch:
|
|
3367
|
+
// a name the exit listener cannot resolve would throw on every process exit.
|
|
3368
|
+
test('threadless node WASI loader keeps its exit teardown unconditional', (t) => {
|
|
3369
|
+
const code = createWasiBinding('test', '@scope/test', 4000, 65536, false)
|
|
3370
|
+
t.false(code.includes('__hasWasiThreadCrashed'))
|
|
3371
|
+
t.false(code.includes('__wasiThreadCrash'))
|
|
3372
|
+
t.false(code.includes('SharedArrayBuffer'))
|
|
3373
|
+
t.true(generatedFunction(code, '__disposeWasiBindingAtExit').length > 0)
|
|
3374
|
+
t.false(code.includes('__disposeWasiBindingAfterThreadCrash'))
|
|
3375
|
+
t.false(code.includes('__releaseEmnapiWaitingRequestHandle'))
|
|
3376
|
+
t.true(
|
|
3377
|
+
generatedFunction(code, '__disposeWasiBinding').startsWith(
|
|
3378
|
+
'function __disposeWasiBinding() {\n if (__wasiDisposePromise) {\n',
|
|
3379
|
+
),
|
|
3380
|
+
)
|
|
3381
|
+
})
|
|
3382
|
+
|
|
3383
|
+
// The browser loader has no 'exit' teardown and no crash latch; its public
|
|
3384
|
+
// disposer is unchanged.
|
|
3385
|
+
test('browser WASI loader public disposer has no crash latch', (t) => {
|
|
3386
|
+
for (const threads of [true, false]) {
|
|
3387
|
+
const code = createWasiBrowserBinding(
|
|
3388
|
+
'test-wasi',
|
|
3389
|
+
4000,
|
|
3390
|
+
65536,
|
|
3391
|
+
false,
|
|
3392
|
+
false,
|
|
3393
|
+
false,
|
|
3394
|
+
false,
|
|
3395
|
+
threads,
|
|
3396
|
+
)
|
|
3397
|
+
t.false(code.includes('__hasWasiThreadCrashed'), `threads=${threads}`)
|
|
3398
|
+
t.false(
|
|
3399
|
+
code.includes('__disposeWasiBindingAfterThreadCrash'),
|
|
3400
|
+
`threads=${threads}`,
|
|
3401
|
+
)
|
|
3402
|
+
}
|
|
3403
|
+
})
|
|
3404
|
+
|
|
3405
|
+
/**
|
|
3406
|
+
* The column-zero `if` of the Node worker that wraps emnapi's
|
|
3407
|
+
* `beforeReportError`, with the helpers it calls: a function body over
|
|
3408
|
+
* `workerData`, `handler` and `threadId`.
|
|
3409
|
+
*/
|
|
3410
|
+
function wasiWorkerCrashHook(): string {
|
|
3411
|
+
const start =
|
|
3412
|
+
WASI_WORKER_TEMPLATE.indexOf(
|
|
3413
|
+
'\nif (workerData && workerData.crashFlag instanceof Int32Array) {\n',
|
|
3414
|
+
) + 1
|
|
3415
|
+
return (
|
|
3416
|
+
WASI_WORKER_TEMPLATE.slice(
|
|
3417
|
+
start,
|
|
3418
|
+
WASI_WORKER_TEMPLATE.indexOf('\n}\n', start) + 2,
|
|
3419
|
+
) +
|
|
3420
|
+
generatedFunction(WASI_WORKER_TEMPLATE, '__raiseWasiThreadCrashFlags') +
|
|
3421
|
+
generatedFunction(WASI_WORKER_TEMPLATE, '__writeCrashReport')
|
|
3422
|
+
)
|
|
3423
|
+
}
|
|
3424
|
+
|
|
3425
|
+
test('WASI worker sets the crash flag before emnapi reports the error', (t) => {
|
|
3426
|
+
assertValidJS(t, WASI_WORKER_TEMPLATE, 'Node WASI worker')
|
|
3427
|
+
const start = WASI_WORKER_TEMPLATE.indexOf(
|
|
3428
|
+
'\nif (workerData && workerData.crashFlag instanceof Int32Array) {',
|
|
3429
|
+
)
|
|
3430
|
+
t.true(start > 0)
|
|
3431
|
+
t.true(
|
|
3432
|
+
start < WASI_WORKER_TEMPLATE.indexOf('globalThis.onmessage = function'),
|
|
3433
|
+
'the hook has to be in place before the first message is handled',
|
|
3434
|
+
)
|
|
3435
|
+
const latch = wasiWorkerCrashHook()
|
|
3436
|
+
|
|
3437
|
+
const run = (workerData: { crashFlag?: Int32Array } & object) => {
|
|
3438
|
+
const reports: string[] = []
|
|
3439
|
+
const handler = {
|
|
3440
|
+
// emnapi's own hook, which lets the main thread start exiting.
|
|
3441
|
+
beforeReportError(this: unknown, error: Error, type: string) {
|
|
3442
|
+
const flag = workerData.crashFlag
|
|
3443
|
+
? Atomics.load(workerData.crashFlag, 0)
|
|
3444
|
+
: 'none'
|
|
3445
|
+
reports.push(
|
|
3446
|
+
`${error.message} ${type} flag=${flag} ${this === handler}`,
|
|
3447
|
+
)
|
|
3448
|
+
},
|
|
3449
|
+
}
|
|
3450
|
+
const original = handler.beforeReportError
|
|
3451
|
+
new Function('workerData', 'handler', 'threadId', latch)(
|
|
3452
|
+
workerData,
|
|
3453
|
+
handler,
|
|
3454
|
+
1,
|
|
3455
|
+
)
|
|
3456
|
+
return { handler, original, reports }
|
|
3457
|
+
}
|
|
3458
|
+
|
|
3459
|
+
const crashFlag = new Int32Array(new SharedArrayBuffer(4))
|
|
3460
|
+
const crashed = run({ crashFlag })
|
|
3461
|
+
t.not(crashed.handler.beforeReportError, crashed.original)
|
|
3462
|
+
crashed.handler.beforeReportError(new Error('trap'), 'start')
|
|
3463
|
+
// Set before emnapi's hook runs, and emnapi's hook still runs on the handler.
|
|
3464
|
+
t.deepEqual(crashed.reports, ['trap start flag=1 true'])
|
|
3465
|
+
t.is(Atomics.load(crashFlag, 0), 1)
|
|
3466
|
+
|
|
3467
|
+
// A loader that predates the flag passes none: the hook stays emnapi's own.
|
|
3468
|
+
const legacy = run({ hostRoot: '/', rootDir: '/' } as object)
|
|
3469
|
+
t.is(legacy.handler.beforeReportError, legacy.original)
|
|
3470
|
+
})
|
|
3471
|
+
|
|
3472
|
+
// The loader thread can be inside a wasm cleanup call that waits on the dead
|
|
3473
|
+
// thread. The addon's flag makes those waits trap; the loader must already see
|
|
3474
|
+
// its own flag when the trap reaches it.
|
|
3475
|
+
test('WASI worker raises the addon crash flag after the loader flag', (t) => {
|
|
3476
|
+
const latch = wasiWorkerCrashHook()
|
|
3477
|
+
const crash = (
|
|
3478
|
+
instance: unknown,
|
|
3479
|
+
workerData: { crashFlag: Int32Array; addonCrashFlag?: unknown },
|
|
3480
|
+
) => {
|
|
3481
|
+
const calls: string[] = []
|
|
3482
|
+
const handler = {
|
|
3483
|
+
instance,
|
|
3484
|
+
beforeReportError() {
|
|
3485
|
+
calls.push(`emnapi flag=${Atomics.load(workerData.crashFlag, 0)}`)
|
|
3486
|
+
},
|
|
3487
|
+
}
|
|
3488
|
+
new Function('workerData', 'handler', 'threadId', latch)(
|
|
3489
|
+
workerData,
|
|
3490
|
+
handler,
|
|
3491
|
+
1,
|
|
3492
|
+
)
|
|
3493
|
+
;(handler.beforeReportError as (...args: unknown[]) => void)(
|
|
3494
|
+
new Error('trap'),
|
|
3495
|
+
'start',
|
|
3496
|
+
)
|
|
3497
|
+
return calls
|
|
3498
|
+
}
|
|
3499
|
+
|
|
3500
|
+
// The loader's view of the addon's flag: one word of the shared wasm memory,
|
|
3501
|
+
// at an offset. A worker that failed while loading has no instance.
|
|
3502
|
+
const memory = new WebAssembly.Memory({
|
|
3503
|
+
initial: 1,
|
|
3504
|
+
maximum: 1,
|
|
3505
|
+
shared: true,
|
|
3506
|
+
})
|
|
3507
|
+
const word = new Int32Array(memory.buffer, 64, 1)
|
|
3508
|
+
const crashFlag = new Int32Array(new SharedArrayBuffer(4))
|
|
3509
|
+
const calls: string[] = []
|
|
3510
|
+
const workerData = {
|
|
3511
|
+
crashFlag,
|
|
3512
|
+
get addonCrashFlag() {
|
|
3513
|
+
calls.push(`view read, loader flag=${Atomics.load(crashFlag, 0)}`)
|
|
3514
|
+
return word
|
|
3515
|
+
},
|
|
3516
|
+
}
|
|
3517
|
+
calls.push(...crash(undefined, workerData))
|
|
3518
|
+
t.deepEqual(calls, ['view read, loader flag=1', 'emnapi flag=1'])
|
|
3519
|
+
t.is(Atomics.load(new Int32Array(memory.buffer), 16), 1)
|
|
3520
|
+
t.is(Atomics.load(new Int32Array(memory.buffer), 15), 0)
|
|
3521
|
+
t.is(Atomics.load(new Int32Array(memory.buffer), 17), 0)
|
|
3522
|
+
|
|
3523
|
+
// With a view the export is not needed: it is not called.
|
|
3524
|
+
const exported: string[] = []
|
|
3525
|
+
const withInstance = crash(
|
|
3526
|
+
{
|
|
3527
|
+
exports: {
|
|
3528
|
+
napi_wasm_thread_crashed() {
|
|
3529
|
+
exported.push('napi')
|
|
3530
|
+
},
|
|
3531
|
+
},
|
|
3532
|
+
},
|
|
3533
|
+
{
|
|
3534
|
+
crashFlag: new Int32Array(new SharedArrayBuffer(4)),
|
|
3535
|
+
addonCrashFlag: new Int32Array(new SharedArrayBuffer(4)),
|
|
3536
|
+
},
|
|
3537
|
+
)
|
|
3538
|
+
t.deepEqual(withInstance, ['emnapi flag=1'])
|
|
3539
|
+
t.deepEqual(exported, [])
|
|
3540
|
+
|
|
3541
|
+
// No view (an addon built with an older napi): the export, when the worker
|
|
3542
|
+
// has an instance, after the loader flag.
|
|
3543
|
+
const noViewFlag = new Int32Array(new SharedArrayBuffer(4))
|
|
3544
|
+
const noView: string[] = []
|
|
3545
|
+
noView.push(
|
|
3546
|
+
...crash(
|
|
3547
|
+
{
|
|
3548
|
+
exports: {
|
|
3549
|
+
napi_wasm_thread_crashed() {
|
|
3550
|
+
noView.push(`napi flag=${Atomics.load(noViewFlag, 0)}`)
|
|
3551
|
+
},
|
|
3552
|
+
},
|
|
3553
|
+
},
|
|
3554
|
+
{ crashFlag: noViewFlag },
|
|
3555
|
+
),
|
|
3556
|
+
)
|
|
3557
|
+
t.deepEqual(noView, ['napi flag=1', 'emnapi flag=1'])
|
|
3558
|
+
|
|
3559
|
+
// A worker that never loaded, an addon without the export, an export that
|
|
3560
|
+
// throws, a view that is not an Int32Array: emnapi's hook still runs.
|
|
3561
|
+
for (const [instance, addonCrashFlag] of [
|
|
3562
|
+
[undefined, undefined],
|
|
3563
|
+
[{ exports: {} }, undefined],
|
|
3564
|
+
[
|
|
3565
|
+
{
|
|
3566
|
+
exports: {
|
|
3567
|
+
napi_wasm_thread_crashed() {
|
|
3568
|
+
throw new Error('boom')
|
|
3569
|
+
},
|
|
3570
|
+
},
|
|
3571
|
+
},
|
|
3572
|
+
undefined,
|
|
3573
|
+
],
|
|
3574
|
+
[undefined, new Uint8Array(new SharedArrayBuffer(4))],
|
|
3575
|
+
]) {
|
|
3576
|
+
t.deepEqual(
|
|
3577
|
+
crash(instance, {
|
|
3578
|
+
crashFlag: new Int32Array(new SharedArrayBuffer(4)),
|
|
3579
|
+
addonCrashFlag,
|
|
3580
|
+
}),
|
|
3581
|
+
['emnapi flag=1'],
|
|
3582
|
+
)
|
|
3583
|
+
}
|
|
3584
|
+
})
|
|
3585
|
+
|
|
3586
|
+
/**
|
|
3587
|
+
* A shared wasm memory with a view of one word at `offset`, the way the loaders
|
|
3588
|
+
* build the addon's crash flag view.
|
|
3589
|
+
*/
|
|
3590
|
+
function addonCrashFlagView(offset = 64) {
|
|
3591
|
+
const memory = new WebAssembly.Memory({
|
|
3592
|
+
initial: 1,
|
|
3593
|
+
maximum: 1,
|
|
3594
|
+
shared: true,
|
|
3595
|
+
})
|
|
3596
|
+
return { memory, word: new Int32Array(memory.buffer, offset, 1) }
|
|
3597
|
+
}
|
|
3598
|
+
|
|
3599
|
+
// A worker whose setup throws before the crash hook exists (here: the runtime
|
|
3600
|
+
// package cannot be resolved) never gets to emnapi's beforeReportError. Its
|
|
3601
|
+
// thread spawn has already returned, so the loader may be waiting on it inside
|
|
3602
|
+
// wasm: it has to raise both flags itself, then fail as before.
|
|
3603
|
+
test('WASI worker raises both crash flags when its setup throws', (t) => {
|
|
3604
|
+
const anchor = '\nlet handler\ntry {\n'
|
|
3605
|
+
const start = WASI_WORKER_TEMPLATE.indexOf(anchor) + 1
|
|
3606
|
+
t.true(start > 0)
|
|
3607
|
+
const catchStart = WASI_WORKER_TEMPLATE.indexOf('} catch (error) {\n', start)
|
|
3608
|
+
const setup =
|
|
3609
|
+
WASI_WORKER_TEMPLATE.slice(
|
|
3610
|
+
start,
|
|
3611
|
+
WASI_WORKER_TEMPLATE.indexOf('\n}\n', catchStart) + 2,
|
|
3612
|
+
) +
|
|
3613
|
+
generatedFunction(WASI_WORKER_TEMPLATE, '__raiseWasiThreadCrashFlags') +
|
|
3614
|
+
generatedFunction(WASI_WORKER_TEMPLATE, '__writeCrashReport')
|
|
3615
|
+
t.true(
|
|
3616
|
+
setup.indexOf("require('@napi-rs/wasm-runtime')") <
|
|
3617
|
+
setup.indexOf('handler = new MessageHandler('),
|
|
3618
|
+
)
|
|
3619
|
+
const missing = new Error("Cannot find module '@napi-rs/wasm-runtime'")
|
|
3620
|
+
const run = (workerData: object | undefined) =>
|
|
3621
|
+
new Function('require', 'workerData', 'threadId', setup)(
|
|
3622
|
+
() => {
|
|
3623
|
+
throw missing
|
|
3624
|
+
},
|
|
3625
|
+
workerData,
|
|
3626
|
+
3,
|
|
3627
|
+
)
|
|
3628
|
+
|
|
3629
|
+
const { memory, word } = addonCrashFlagView()
|
|
3630
|
+
const crashFlag = new Int32Array(new SharedArrayBuffer(4))
|
|
3631
|
+
const crashReport = new SharedArrayBuffer(4096)
|
|
3632
|
+
const order: number[] = []
|
|
3633
|
+
const workerData = {
|
|
3634
|
+
crashFlag,
|
|
3635
|
+
crashReport,
|
|
3636
|
+
get addonCrashFlag() {
|
|
3637
|
+
order.push(Atomics.load(crashFlag, 0))
|
|
3638
|
+
return word
|
|
3639
|
+
},
|
|
3640
|
+
}
|
|
3641
|
+
t.is(
|
|
3642
|
+
t.throws(() => run(workerData)),
|
|
3643
|
+
missing,
|
|
3644
|
+
)
|
|
3645
|
+
t.is(Atomics.load(crashFlag, 0), 1)
|
|
3646
|
+
t.deepEqual(order, [1], 'the loader flag goes up before the addon flag')
|
|
3647
|
+
t.is(Atomics.load(new Int32Array(memory.buffer), 16), 1)
|
|
3648
|
+
const header = new Int32Array(crashReport, 0, 3)
|
|
3649
|
+
t.is(Atomics.load(header, 0), 2)
|
|
3650
|
+
t.is(Atomics.load(header, 2), 3)
|
|
3651
|
+
|
|
3652
|
+
// A loader that predates the flags: the error is only rethrown.
|
|
3653
|
+
t.is(
|
|
3654
|
+
t.throws(() => run({ hostRoot: '/', rootDir: '/' })),
|
|
3655
|
+
missing,
|
|
3656
|
+
)
|
|
3657
|
+
t.is(
|
|
3658
|
+
t.throws(() => run(undefined)),
|
|
3659
|
+
missing,
|
|
3660
|
+
)
|
|
3661
|
+
})
|
|
3662
|
+
|
|
3663
|
+
// The same through a real worker thread: the generated worker, written where
|
|
3664
|
+
// '@napi-rs/wasm-runtime' cannot be resolved, gets the view in workerData — a
|
|
3665
|
+
// structured clone that keeps sharing the wasm memory — and raises the word.
|
|
3666
|
+
test('WASI worker that fails to load raises the flags from a real thread', async (t) => {
|
|
3667
|
+
const directory = await mkdtemp(join(tmpdir(), 'napi-rs-wasi-worker-'))
|
|
3668
|
+
t.teardown(() => rm(directory, { recursive: true, force: true }))
|
|
3669
|
+
const workerPath = join(directory, 'wasi-worker.mjs')
|
|
3670
|
+
await writeFile(workerPath, WASI_WORKER_TEMPLATE)
|
|
3671
|
+
const { memory, word } = addonCrashFlagView(128)
|
|
3672
|
+
const crashFlag = new Int32Array(new SharedArrayBuffer(4))
|
|
3673
|
+
const crashReport = new SharedArrayBuffer(4096)
|
|
3674
|
+
const worker = new Worker(workerPath, {
|
|
3675
|
+
workerData: { crashFlag, crashReport, addonCrashFlag: word },
|
|
3676
|
+
})
|
|
3677
|
+
const error = await new Promise<Error>((resolve, reject) => {
|
|
3678
|
+
worker.once('error', resolve)
|
|
3679
|
+
worker.once('exit', (code) =>
|
|
3680
|
+
reject(new Error(`the worker exited with ${code} and no error`)),
|
|
3681
|
+
)
|
|
3682
|
+
})
|
|
3683
|
+
t.regex(error.message, /@napi-rs\/wasm-runtime/)
|
|
3684
|
+
t.is(Atomics.load(crashFlag, 0), 1)
|
|
3685
|
+
t.is(Atomics.load(new Int32Array(memory.buffer), 32), 1)
|
|
3686
|
+
t.is(Atomics.load(new Int32Array(crashReport, 0, 1), 0), 2)
|
|
3687
|
+
})
|
|
3688
|
+
|
|
3689
|
+
/**
|
|
3690
|
+
* The Node loader's view capture and worker creation, over a real shared wasm
|
|
3691
|
+
* memory and a stub `Worker` that keeps its options.
|
|
3692
|
+
*/
|
|
3693
|
+
function createNodeLoaderAddonCrashFlag() {
|
|
3694
|
+
const code = createWasiBinding('test', '@scope/test')
|
|
3695
|
+
const created: Array<{ filename: string; options: any }> = []
|
|
3696
|
+
class StubWorker {
|
|
3697
|
+
constructor(filename: string, options: unknown) {
|
|
3698
|
+
created.push({ filename, options })
|
|
3699
|
+
}
|
|
3700
|
+
}
|
|
3701
|
+
const loader = new Function(
|
|
3702
|
+
'Worker',
|
|
3703
|
+
`
|
|
3704
|
+
const __sharedMemory = new WebAssembly.Memory({ initial: 1, maximum: 2, shared: true })
|
|
3705
|
+
const __hostRoot = '/'
|
|
3706
|
+
const __rootDir = '/'
|
|
3707
|
+
const __wasiThreadCrashFlag = new Int32Array(new SharedArrayBuffer(4))
|
|
3708
|
+
const __wasiThreadCrashReport = new SharedArrayBuffer(16)
|
|
3709
|
+
let __wasiAddonCrashFlag
|
|
3710
|
+
${generatedFunction(code, '__captureWasiAddonCrashFlag')}
|
|
3711
|
+
${generatedFunction(code, '__getWasiWorkerExecArgv')}
|
|
3712
|
+
${generatedFunction(code, '__createWasiWorker')}
|
|
3713
|
+
return {
|
|
3714
|
+
memory: __sharedMemory,
|
|
3715
|
+
capture: __captureWasiAddonCrashFlag,
|
|
3716
|
+
createWorker: __createWasiWorker,
|
|
3717
|
+
view: () => __wasiAddonCrashFlag,
|
|
3718
|
+
}
|
|
3719
|
+
`,
|
|
3720
|
+
)(StubWorker) as {
|
|
3721
|
+
memory: WebAssembly.Memory
|
|
3722
|
+
capture: (instance: unknown) => void
|
|
3723
|
+
createWorker: (filename: string) => unknown
|
|
3724
|
+
view: () => Int32Array | undefined
|
|
3725
|
+
}
|
|
3726
|
+
return { code, created, ...loader }
|
|
3727
|
+
}
|
|
3728
|
+
|
|
3729
|
+
test('node WASI loader reads the addon crash flag address before registration', (t) => {
|
|
3730
|
+
const { code } = createNodeLoaderAddonCrashFlag()
|
|
3731
|
+
const beforeInit = code.slice(code.indexOf(' beforeInit({ instance }) {'))
|
|
3732
|
+
const capture = beforeInit.indexOf('__captureWasiAddonCrashFlag(instance)')
|
|
3733
|
+
t.true(capture > 0)
|
|
3734
|
+
t.true(capture < beforeInit.indexOf("name.startsWith('__napi_register__')"))
|
|
3735
|
+
t.true(
|
|
3736
|
+
generatedFunction(code, '__createWasiWorker').includes(
|
|
3737
|
+
'addonCrashFlag: __wasiAddonCrashFlag,',
|
|
3738
|
+
),
|
|
3739
|
+
)
|
|
3740
|
+
// The threadless loader has no workers and reads nothing.
|
|
3741
|
+
const threadless = createWasiBinding(
|
|
3742
|
+
'test',
|
|
3743
|
+
'@scope/test',
|
|
3744
|
+
4000,
|
|
3745
|
+
65536,
|
|
3746
|
+
false,
|
|
3747
|
+
)
|
|
3748
|
+
t.false(threadless.includes('__captureWasiAddonCrashFlag'))
|
|
3749
|
+
t.false(threadless.includes('napi_wasm_thread_crash_flag_address'))
|
|
3750
|
+
})
|
|
3751
|
+
|
|
3752
|
+
test('node WASI loader passes each pool worker a view of the addon crash flag', (t) => {
|
|
3753
|
+
const loader = createNodeLoaderAddonCrashFlag()
|
|
3754
|
+
// Before the capture (or for an addon built with an older napi): no view.
|
|
3755
|
+
loader.createWorker('/w.mjs')
|
|
3756
|
+
t.is(loader.created[0].options.workerData.addonCrashFlag, undefined)
|
|
3757
|
+
|
|
3758
|
+
loader.capture({
|
|
3759
|
+
exports: { napi_wasm_thread_crash_flag_address: () => 1024 },
|
|
3760
|
+
})
|
|
3761
|
+
const view = loader.view()
|
|
3762
|
+
t.true(view instanceof Int32Array)
|
|
3763
|
+
t.is(view!.buffer, loader.memory.buffer)
|
|
3764
|
+
t.is(view!.byteOffset, 1024)
|
|
3765
|
+
t.is(view!.length, 1)
|
|
3766
|
+
loader.createWorker('/w.mjs')
|
|
3767
|
+
t.is(loader.created[1].options.workerData.addonCrashFlag, view)
|
|
3768
|
+
t.is(
|
|
3769
|
+
loader.created[1].options.workerData.crashFlag instanceof Int32Array,
|
|
3770
|
+
true,
|
|
3771
|
+
)
|
|
3772
|
+
// The worker's store lands in the word the addon reads.
|
|
3773
|
+
Atomics.store(view!, 0, 1)
|
|
3774
|
+
t.is(Atomics.load(new Int32Array(loader.memory.buffer), 256), 1)
|
|
3775
|
+
|
|
3776
|
+
// Addresses the loader cannot trust are ignored.
|
|
3777
|
+
for (const exports of [
|
|
3778
|
+
{},
|
|
3779
|
+
{ napi_wasm_thread_crash_flag_address: 1024 },
|
|
3780
|
+
{ napi_wasm_thread_crash_flag_address: () => 0 },
|
|
3781
|
+
{ napi_wasm_thread_crash_flag_address: () => 1026 },
|
|
3782
|
+
{ napi_wasm_thread_crash_flag_address: () => 65536 },
|
|
3783
|
+
{ napi_wasm_thread_crash_flag_address: () => -4 },
|
|
3784
|
+
{
|
|
3785
|
+
napi_wasm_thread_crash_flag_address() {
|
|
3786
|
+
throw new Error('boom')
|
|
3787
|
+
},
|
|
3788
|
+
},
|
|
3789
|
+
]) {
|
|
3790
|
+
const fresh = createNodeLoaderAddonCrashFlag()
|
|
3791
|
+
t.notThrows(() => fresh.capture({ exports }))
|
|
3792
|
+
t.is(fresh.view(), undefined)
|
|
3793
|
+
}
|
|
3794
|
+
})
|
|
3795
|
+
|
|
3796
|
+
// The browser pool is created before the wasm is instantiated, so its workers
|
|
3797
|
+
// get the view by message once the instance exists; a worker created later
|
|
3798
|
+
// gets it right away. A browser Worker has no workerData.
|
|
3799
|
+
test('browser WASI loader posts the addon crash flag view to every pool worker', (t) => {
|
|
3800
|
+
const code = createWasiBrowserBinding(
|
|
3801
|
+
'test-wasi',
|
|
3802
|
+
4000,
|
|
3803
|
+
65536,
|
|
3804
|
+
false,
|
|
3805
|
+
false,
|
|
3806
|
+
false,
|
|
3807
|
+
false,
|
|
3808
|
+
true,
|
|
3809
|
+
)
|
|
3810
|
+
assertValidJS(t, code, 'browser threads')
|
|
3811
|
+
const beforeInitStart = code.indexOf(
|
|
3812
|
+
' beforeInit({ instance }) {\n __napiInstance = instance\n',
|
|
3813
|
+
)
|
|
3814
|
+
t.true(beforeInitStart > 0)
|
|
3815
|
+
const beforeInitBody = code.slice(
|
|
3816
|
+
code.indexOf(' __napiInstance = instance\n', beforeInitStart),
|
|
3817
|
+
code.indexOf(
|
|
3818
|
+
' for (const name of Object.keys(instance.exports)) {',
|
|
3819
|
+
beforeInitStart,
|
|
3820
|
+
),
|
|
3821
|
+
)
|
|
3822
|
+
t.true(beforeInitBody.includes('__captureWasiAddonCrashFlag(instance)'))
|
|
3823
|
+
t.true(
|
|
3824
|
+
code.includes(
|
|
3825
|
+
' __wasiWorkers.add(worker)\n __shareWasiAddonCrashFlag(worker)\n',
|
|
3826
|
+
),
|
|
3827
|
+
)
|
|
3828
|
+
|
|
3829
|
+
const posted: Array<[string, unknown]> = []
|
|
3830
|
+
const run = new Function(
|
|
3831
|
+
'posted',
|
|
3832
|
+
`
|
|
3833
|
+
const __sharedMemory = new WebAssembly.Memory({ initial: 1, maximum: 1, shared: true })
|
|
3834
|
+
const __wasiWorkers = new Set()
|
|
3835
|
+
let __napiInstance
|
|
3836
|
+
${generatedFunction(code, '__captureWasiAddonCrashFlag')}
|
|
3837
|
+
${generatedFunction(code, '__shareWasiAddonCrashFlag')}
|
|
3838
|
+
let __wasiAddonCrashFlag
|
|
3839
|
+
function onCreateWorker(name) {
|
|
3840
|
+
const worker = {
|
|
3841
|
+
postMessage(message) {
|
|
3842
|
+
posted.push([name, message])
|
|
3843
|
+
},
|
|
3844
|
+
}
|
|
3845
|
+
__wasiWorkers.add(worker)
|
|
3846
|
+
__shareWasiAddonCrashFlag(worker)
|
|
3847
|
+
return worker
|
|
3848
|
+
}
|
|
3849
|
+
function beforeInit({ instance }) {
|
|
3850
|
+
${beforeInitBody}
|
|
3851
|
+
}
|
|
3852
|
+
onCreateWorker('pool-1')
|
|
3853
|
+
onCreateWorker('pool-2')
|
|
3854
|
+
beforeInit({ instance: { exports: { napi_wasm_thread_crash_flag_address: () => 512 } } })
|
|
3855
|
+
onCreateWorker('later')
|
|
3856
|
+
return { memory: __sharedMemory, view: __wasiAddonCrashFlag }
|
|
3857
|
+
`,
|
|
3858
|
+
)
|
|
3859
|
+
const { memory, view } = run(posted)
|
|
3860
|
+
t.true(view instanceof Int32Array)
|
|
3861
|
+
t.is(view.buffer, memory.buffer)
|
|
3862
|
+
t.is(view.byteOffset, 512)
|
|
3863
|
+
t.deepEqual(
|
|
3864
|
+
posted.map(([name]) => name),
|
|
3865
|
+
['pool-1', 'pool-2', 'later'],
|
|
3866
|
+
)
|
|
3867
|
+
for (const [, message] of posted) {
|
|
3868
|
+
t.deepEqual(Object.keys(message as object), ['__napiRsAddonCrashFlag'])
|
|
3869
|
+
t.is(
|
|
3870
|
+
(message as { __napiRsAddonCrashFlag: unknown }).__napiRsAddonCrashFlag,
|
|
3871
|
+
view,
|
|
3872
|
+
)
|
|
3873
|
+
}
|
|
3874
|
+
|
|
3875
|
+
// The threadless browser loader has no workers.
|
|
3876
|
+
const threadless = createWasiBrowserBinding(
|
|
3877
|
+
'test-wasi',
|
|
3878
|
+
4000,
|
|
3879
|
+
65536,
|
|
3880
|
+
false,
|
|
3881
|
+
false,
|
|
3882
|
+
false,
|
|
3883
|
+
false,
|
|
3884
|
+
false,
|
|
3885
|
+
)
|
|
3886
|
+
t.false(threadless.includes('__captureWasiAddonCrashFlag'))
|
|
3887
|
+
t.false(threadless.includes('__shareWasiAddonCrashFlag'))
|
|
3888
|
+
})
|
|
3889
|
+
|
|
3890
|
+
test('browser WASI worker raises the addon crash flag it was posted', (t) => {
|
|
3891
|
+
for (const [fs, errorEvent] of [
|
|
3892
|
+
[false, false],
|
|
3893
|
+
[true, true],
|
|
3894
|
+
]) {
|
|
3895
|
+
const code = createWasiBrowserWorkerBinding(fs, errorEvent)
|
|
3896
|
+
const start = code.indexOf('let __addonCrashFlag\n')
|
|
3897
|
+
t.true(start > 0)
|
|
3898
|
+
const bridge = code.slice(start)
|
|
3899
|
+
const calls: string[] = []
|
|
3900
|
+
const { memory, word } = addonCrashFlagView(256)
|
|
3901
|
+
const handler: {
|
|
3902
|
+
instance?: unknown
|
|
3903
|
+
beforeReportError: (...args: unknown[]) => unknown
|
|
3904
|
+
handle: (event: { data: unknown }) => void
|
|
3905
|
+
} = {
|
|
3906
|
+
instance: undefined,
|
|
3907
|
+
beforeReportError(this: unknown, error: unknown) {
|
|
3908
|
+
calls.push(
|
|
3909
|
+
`emnapi ${(error as Error).message} word=${Atomics.load(word, 0)} ${this === handler}`,
|
|
3910
|
+
)
|
|
3911
|
+
},
|
|
3912
|
+
handle(event) {
|
|
3913
|
+
calls.push(`handle ${JSON.stringify(event.data)}`)
|
|
3914
|
+
},
|
|
3915
|
+
}
|
|
3916
|
+
const globals: { onmessage?: (event: { data: unknown }) => void } = {}
|
|
3917
|
+
new Function('handler', 'globalThis', bridge)(handler, globals)
|
|
3918
|
+
|
|
3919
|
+
// No view yet: emnapi's hook still runs, nothing is raised.
|
|
3920
|
+
handler.beforeReportError(new Error('early'), 'load')
|
|
3921
|
+
// The view is kept, not handed to emnapi; emnapi's messages still are.
|
|
3922
|
+
globals.onmessage!({ data: { __napiRsAddonCrashFlag: word } })
|
|
3923
|
+
globals.onmessage!({ data: { __emnapi__: { type: 'load' } } })
|
|
3924
|
+
// A wasm thread that died, with or without an instance.
|
|
3925
|
+
handler.beforeReportError(new Error('trap'), 'start')
|
|
3926
|
+
t.deepEqual(calls, [
|
|
3927
|
+
'emnapi early word=0 true',
|
|
3928
|
+
'handle {"__emnapi__":{"type":"load"}}',
|
|
3929
|
+
'emnapi trap word=1 true',
|
|
3930
|
+
])
|
|
3931
|
+
t.is(Atomics.load(new Int32Array(memory.buffer), 64), 1)
|
|
3932
|
+
}
|
|
3933
|
+
})
|
|
3934
|
+
|
|
3935
|
+
/**
|
|
3936
|
+
* Wires the worker's crash hook to the loader's crash error over one shared
|
|
3937
|
+
* report, the way `__createWasiWorker` passes it, and returns both ends.
|
|
3938
|
+
*/
|
|
3939
|
+
function createWasiCrashReportPair() {
|
|
3940
|
+
const code = createWasiBinding('test', '@scope/test')
|
|
3941
|
+
const latchStart = code.indexOf('const __wasiThreadCrashFlag = ')
|
|
3942
|
+
const latchState = code.slice(
|
|
3943
|
+
latchStart,
|
|
3944
|
+
code.indexOf('let __wasiThreadCrashed = false\n', latchStart) +
|
|
3945
|
+
'let __wasiThreadCrashed = false\n'.length,
|
|
3946
|
+
)
|
|
3947
|
+
const loader = new Function(`
|
|
3948
|
+
${latchState}
|
|
3949
|
+
function __getWasiThreadManager() {
|
|
3950
|
+
return {}
|
|
3951
|
+
}
|
|
3952
|
+
${generatedFunction(code, '__readWasiThreadCrashReport')}
|
|
3953
|
+
${generatedFunction(code, '__recordWasiThreadCrashError')}
|
|
3954
|
+
${generatedFunction(code, '__getWasiThreadCrashError')}
|
|
3955
|
+
${generatedFunction(code, '__fillWasiThreadCrashError')}
|
|
3956
|
+
return {
|
|
3957
|
+
workerData: {
|
|
3958
|
+
crashFlag: __wasiThreadCrashFlag,
|
|
3959
|
+
crashReport: __wasiThreadCrashReport,
|
|
3960
|
+
},
|
|
3961
|
+
crashError: __getWasiThreadCrashError,
|
|
3962
|
+
workerError: __recordWasiThreadCrashError,
|
|
3963
|
+
}
|
|
3964
|
+
`)() as {
|
|
3965
|
+
workerData: { crashFlag: Int32Array; crashReport: SharedArrayBuffer }
|
|
3966
|
+
crashError: () => any
|
|
3967
|
+
workerError: (error: unknown, threadId: number) => void
|
|
3968
|
+
}
|
|
3969
|
+
const hook = wasiWorkerCrashHook()
|
|
3970
|
+
const reportStates: number[] = []
|
|
3971
|
+
const crashWorker = (error: unknown, threadId: number) => {
|
|
3972
|
+
const handler = {
|
|
3973
|
+
beforeReportError() {
|
|
3974
|
+
reportStates.push(
|
|
3975
|
+
Atomics.load(new Int32Array(loader.workerData.crashReport, 0, 1), 0),
|
|
3976
|
+
)
|
|
3977
|
+
},
|
|
3978
|
+
}
|
|
3979
|
+
new Function('workerData', 'handler', 'threadId', hook)(
|
|
3980
|
+
loader.workerData,
|
|
3981
|
+
handler,
|
|
3982
|
+
threadId,
|
|
3983
|
+
)
|
|
3984
|
+
;(handler.beforeReportError as (...args: unknown[]) => void)(error, 'start')
|
|
3985
|
+
}
|
|
3986
|
+
return { ...loader, crashWorker, reportStates }
|
|
3987
|
+
}
|
|
3988
|
+
|
|
3989
|
+
// The 'error' event is dropped when the loader terminates the worker first, so
|
|
3990
|
+
// the crash error lost its cause in most crash runs. The worker now writes its
|
|
3991
|
+
// error into shared memory before it raises the flag, and the loader rebuilds
|
|
3992
|
+
// it from there without waiting for any event.
|
|
3993
|
+
test('WASI worker hands its error to the loader through the shared crash report', (t) => {
|
|
3994
|
+
const pair = createWasiCrashReportPair()
|
|
3995
|
+
const trap = new WebAssembly.RuntimeError('forced trap')
|
|
3996
|
+
pair.crashWorker(trap, 5)
|
|
3997
|
+
// Complete before emnapi's hook runs, and so before the flag is seen.
|
|
3998
|
+
t.deepEqual(pair.reportStates, [2])
|
|
3999
|
+
t.is(Atomics.load(pair.workerData.crashFlag, 0), 1)
|
|
4000
|
+
// A second crash does not overwrite the first report.
|
|
4001
|
+
pair.crashWorker(new Error('second worker'), 6)
|
|
4002
|
+
const crashError = pair.crashError()
|
|
4003
|
+
t.is(crashError.message, CRASH_DISPOSAL_MESSAGE)
|
|
4004
|
+
t.true(crashError.cause instanceof Error)
|
|
4005
|
+
t.is(crashError.cause.name, 'RuntimeError')
|
|
4006
|
+
t.is(crashError.cause.message, 'forced trap')
|
|
4007
|
+
t.is(crashError.cause.stack, trap.stack)
|
|
4008
|
+
t.is(crashError.workerThreadId, 5)
|
|
4009
|
+
// One shared error: a later event does not replace the cause.
|
|
4010
|
+
pair.workerError(new Error('late event'), 7)
|
|
4011
|
+
t.is(pair.crashError(), crashError)
|
|
4012
|
+
t.is(crashError.cause.message, 'forced trap')
|
|
4013
|
+
|
|
4014
|
+
// The listener's error, when it came first, is the original object.
|
|
4015
|
+
const withEvent = createWasiCrashReportPair()
|
|
4016
|
+
withEvent.crashWorker(trap, 5)
|
|
4017
|
+
const eventError = new Error('from the error event')
|
|
4018
|
+
withEvent.workerError(eventError, 5)
|
|
4019
|
+
t.is(withEvent.crashError().cause, eventError)
|
|
4020
|
+
|
|
4021
|
+
// A value that is not an error, and a message too large for the report.
|
|
4022
|
+
const thrownString = createWasiCrashReportPair()
|
|
4023
|
+
thrownString.crashWorker('boom', 3)
|
|
4024
|
+
t.is(thrownString.crashError().cause.message, 'boom')
|
|
4025
|
+
const huge = createWasiCrashReportPair()
|
|
4026
|
+
huge.crashWorker(new Error('x'.repeat(10_000)), 4)
|
|
4027
|
+
const hugeCause = huge.crashError().cause
|
|
4028
|
+
t.true(hugeCause.message.length > 0)
|
|
4029
|
+
t.true(hugeCause.message.length < 10_000)
|
|
4030
|
+
t.is(huge.crashError().workerThreadId, 4)
|
|
4031
|
+
})
|