@napi-rs/cli 3.10.4 → 3.10.5

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.
@@ -1123,6 +1123,241 @@ for (const { name, code } of eagerWasiLoaderCases) {
1123
1123
  })
1124
1124
  }
1125
1125
 
1126
+ // `napi_prepare_wasm_env_cleanup` waits: it returns only once the addon's async
1127
+ // runtime has quiesced, and on a threaded artifact the work it waits for can be
1128
+ // waiting for a JavaScript turn from the very thread the export runs on — which
1129
+ // never comes, because that thread is inside the export. The addon exposes the
1130
+ // same teardown as `…_begin` / `napi_wasm_runtime_work_pending` / `…_finish` so
1131
+ // a loader can put real turns in the middle. Every shape that can yield has to
1132
+ // use it, and every shape has to keep working against an addon that has no such
1133
+ // exports.
1134
+ const TWO_PHASE_BARRIER_BY_FLAVOR = {
1135
+ eager: {
1136
+ detect:
1137
+ /if \(typeof begin !== 'function' \|\|\s*typeof finish !== 'function'\) \{/,
1138
+ poll: '}, __WASM_RUNTIME_WORK_POLL_INTERVAL_MS)',
1139
+ finish: '.then(finishCleanup, finishCleanup)',
1140
+ fallback: '__prepareWasmEnvCleanup()',
1141
+ report: '__reportUnreachedWasmEnvSettlements()',
1142
+ // The closer a caller that cannot yield uses to end a parked handshake,
1143
+ // and the point at which the poll publishes it.
1144
+ parked: '__finishParkedWasmEnvCleanup',
1145
+ publishParked: '__finishParkedWasmEnvCleanup = finishCleanup',
1146
+ clearParked: '__finishParkedWasmEnvCleanup = undefined',
1147
+ },
1148
+ deferred: {
1149
+ detect:
1150
+ /if \(typeof __begin !== 'function' \|\|\s*typeof __finish !== 'function'\) \{/,
1151
+ poll: '}, __WASM_RUNTIME_WORK_POLL_INTERVAL_MS)',
1152
+ finish: `return __pollWasmRuntimeWork(__workPending).then(
1153
+ __finishEnvCleanup,
1154
+ __finishEnvCleanup,
1155
+ )`,
1156
+ fallback: '__prepareEnvCleanup()',
1157
+ report: '__reportUnreachedSettlements()',
1158
+ parked: '__finishParkedEnvCleanup',
1159
+ publishParked: '__finishParkedEnvCleanup = __finishEnvCleanup',
1160
+ clearParked: '__finishParkedEnvCleanup = undefined',
1161
+ },
1162
+ } as const
1163
+
1164
+ for (const { name, code } of wasiLoaderCases) {
1165
+ const barrier =
1166
+ TWO_PHASE_BARRIER_BY_FLAVOR[
1167
+ code.includes(EAGER_ROLLBACK_SIGNATURE) ? 'eager' : 'deferred'
1168
+ ]
1169
+ test(`WASI loader polls the two-phase wasm env cleanup: ${name}`, (t) => {
1170
+ t.true(
1171
+ code.includes('napi_prepare_wasm_env_cleanup_begin'),
1172
+ 'loader must start the teardown without joining',
1173
+ )
1174
+ t.true(
1175
+ code.includes('napi_wasm_runtime_work_pending'),
1176
+ 'loader must poll the runtime between the two halves; without it there is nothing to wait on',
1177
+ )
1178
+ t.true(
1179
+ code.includes('napi_prepare_wasm_env_cleanup_finish'),
1180
+ 'loader must still join: finish is the call that owes quiescence',
1181
+ )
1182
+ // Optional, exactly like every other export in this teardown: an addon
1183
+ // built against a napi crate that predates the split keeps the single
1184
+ // blocking call.
1185
+ t.regex(code, barrier.detect, 'the split must be feature-detected')
1186
+ t.true(
1187
+ code.includes(barrier.fallback),
1188
+ 'a loader that finds no split must fall back to the single call',
1189
+ )
1190
+ // Unbounded, and deliberately so. The only way to end the poll early is to
1191
+ // call `…_finish`, which joins on the JavaScript thread — the very thread
1192
+ // the work it joins may be waiting for a turn from — so a budget would not
1193
+ // end that wait, it would only move it somewhere the thread can no longer
1194
+ // be reached. Same reason the async-work drain has no deadline. A host that
1195
+ // breaks the "no blocking closure may wait on a JavaScript turn" rule keeps
1196
+ // its disposal promise pending instead of wedging the thread.
1197
+ t.false(
1198
+ code.includes('__WASM_RUNTIME_DRAIN_TURNS'),
1199
+ 'the poll must not carry a turn budget',
1200
+ )
1201
+ t.is(
1202
+ code.split('for (;;) {').length - 1,
1203
+ 1,
1204
+ 'the poll must be an unbounded loop',
1205
+ )
1206
+ t.true(
1207
+ code.includes(barrier.poll),
1208
+ 'the poll must yield with a real referenced timer, not a macrotask spin',
1209
+ )
1210
+ // `__scheduleTimer` falls back to the macrotask scheduler when `setTimeout`
1211
+ // is missing or throws — but not when it is present, returns a handle and
1212
+ // never fires (fake timers; a host whose timers belong to an IO context
1213
+ // that is gone). With no budget left to bail the poll out, that host would
1214
+ // park it forever. Arm both until timers have actually arrived, then pace
1215
+ // on the timer alone rather than spinning the zero-delay queue.
1216
+ //
1217
+ // Whether they arrive is a property of the poll, never of the module: a
1218
+ // host can lose its timers between two disposals, and in the deferred
1219
+ // shape every instance shares this module — one healthy instance must not
1220
+ // disarm the fallback for the next one.
1221
+ t.false(
1222
+ code.includes('let __wasmRuntimePollTimerArrived'),
1223
+ 'the pacing state must not outlive the poll that learned it',
1224
+ )
1225
+ t.is(
1226
+ code.split('function __createWasmRuntimePollPace()').length - 1,
1227
+ 1,
1228
+ 'one definition of the pacing state',
1229
+ )
1230
+ t.is(
1231
+ code.split('__createWasmRuntimePollPace()').length - 1,
1232
+ 2,
1233
+ 'and exactly one caller: the poll loop, which owns it for its own run',
1234
+ )
1235
+ const yieldStart = code.indexOf('function __yieldWasmRuntimePollTurn(')
1236
+ t.true(yieldStart > 0, 'the poll must yield through one shared turn helper')
1237
+ const yieldTurn = code.slice(yieldStart, code.indexOf('\n}\n', yieldStart))
1238
+ t.true(
1239
+ yieldTurn.includes('__scheduleTimer(') &&
1240
+ yieldTurn.includes('__scheduleMacrotask('),
1241
+ 'an undecided turn must arm both primitives, so an inert setTimeout cannot park the poll',
1242
+ )
1243
+ t.true(
1244
+ yieldTurn.includes('pace.arrivals++'),
1245
+ 'the timer callback must count that timers work on this host',
1246
+ )
1247
+ t.true(
1248
+ yieldTurn.includes(
1249
+ 'pace.arrivals < __WASM_RUNTIME_WORK_POLL_TRUSTED_ARRIVALS',
1250
+ ),
1251
+ 'and one arrival must not be enough: it can be a timer armed before the host stopped running them',
1252
+ )
1253
+ // A poll that has settled onto the timer alone has nothing left to fall
1254
+ // back on if the timers stop mid-poll — the turn that armed the dead timer
1255
+ // is the turn that parks, and a parked poll schedules nothing that could
1256
+ // notice. Every turn keeps a longer timer outstanding for that.
1257
+ t.true(
1258
+ yieldTurn.includes('__armWasmRuntimePollStallBackup('),
1259
+ 'every turn must keep a stall backup outstanding, armed while the timers still work',
1260
+ )
1261
+ const backupStart = code.indexOf(
1262
+ 'function __armWasmRuntimePollStallBackup(',
1263
+ )
1264
+ t.true(backupStart > 0, 'the backup must be one shared helper')
1265
+ const stallBackup = code.slice(
1266
+ backupStart,
1267
+ code.indexOf('\n}\n', backupStart),
1268
+ )
1269
+ t.true(
1270
+ stallBackup.includes('pace.arrivals = 0'),
1271
+ 'a turn the backup has to end proves the timers stopped: the poll goes back to arming both',
1272
+ )
1273
+ t.true(
1274
+ stallBackup.includes('pace.settleTurn'),
1275
+ 'and the backup must end whichever turn is parked, not the one that armed it',
1276
+ )
1277
+ // By due time, never by how long the parked turn has been waiting. A host
1278
+ // runs its timers in due order, so a backup that runs while a turn due a
1279
+ // window earlier is still parked proves that turn's timer was dropped —
1280
+ // and a healthy turn, whose timer runs first and clears `settleTurn`, is
1281
+ // never touched. Measuring the wait instead has a phase hole: arms spaced
1282
+ // further apart than the window leave the turn that parks between them
1283
+ // with no backup young enough to rescue it.
1284
+ t.regex(
1285
+ stallBackup,
1286
+ /pace\.turnTimerDueAt > (?:__)?dueAt - __WASM_RUNTIME_WORK_POLL_STALL_MS/,
1287
+ 'the backup must judge by due time, so there is no phase to fall through',
1288
+ )
1289
+ t.false(
1290
+ stallBackup.includes('Date.now() -'),
1291
+ 'and it must not measure how long the parked turn has waited',
1292
+ )
1293
+ t.true(
1294
+ yieldTurn.includes('pace.settleTurn = undefined'),
1295
+ 'a turn that ends must stop being the parked one, or a later backup reads a due time already answered',
1296
+ )
1297
+ t.regex(
1298
+ yieldTurn,
1299
+ /if \(__settled\??\) \{\s*return\s*\}|if \(settled\) \{\s*return\s*\}/,
1300
+ 'whichever primitive loses the race must resolve nothing: one poll per turn',
1301
+ )
1302
+ t.true(
1303
+ code.includes(barrier.finish),
1304
+ 'finish must run whether the poll ended, timed out or could not run at all',
1305
+ )
1306
+ // The one caller that cannot yield is the raw `Context.destroy()` the
1307
+ // wrapper intercepts, and the queue it leaves behind is discarded by the
1308
+ // destroy that follows. Say so, once, without throwing.
1309
+ t.true(
1310
+ code.includes(barrier.report),
1311
+ 'the single-call barrier must report settlements nothing can reach any more',
1312
+ )
1313
+ // The window between the halves spans real event-loop turns, so a caller
1314
+ // that cannot yield — the CJS 'exit' teardown, the managed beforeExit one —
1315
+ // can land in the middle of one. It cannot wait for the poll; it has to
1316
+ // close the handshake itself, because `…_finish` is the call that joins and
1317
+ // lowers the barrier. Without this the context is destroyed with the
1318
+ // barrier still raised, the runtime never joined and the destroy recorded
1319
+ // as done.
1320
+ t.is(
1321
+ code.split(barrier.publishParked).length - 1,
1322
+ 1,
1323
+ 'the poll must publish a closer before it yields',
1324
+ )
1325
+ t.is(
1326
+ code.split(barrier.clearParked).length - 1,
1327
+ 1,
1328
+ 'finishing the handshake must retract that closer exactly once',
1329
+ )
1330
+ const parkedIndex = code.indexOf(`const ${barrier.parked}`)
1331
+ t.true(
1332
+ parkedIndex > 0 || code.includes(`let ${barrier.parked}`),
1333
+ 'the closer must live outside the barrier, where a non-yielding caller can reach it',
1334
+ )
1335
+ // And the single call is where it is reached: every teardown path runs the
1336
+ // barrier through it before destroying, so closing a parked handshake there
1337
+ // covers all of them at once.
1338
+ const isEager = barrier.parked === '__finishParkedWasmEnvCleanup'
1339
+ const prepareStart = code.indexOf(
1340
+ isEager
1341
+ ? `function ${barrier.fallback.replace('()', '')}() {`
1342
+ : `const ${barrier.fallback.replace('()', '')} = () => {`,
1343
+ )
1344
+ t.true(prepareStart > 0, 'the single-call barrier must be one function')
1345
+ const prepareBody = code.slice(
1346
+ prepareStart,
1347
+ code.indexOf(isEager ? '\n}\n' : '\n }\n', prepareStart),
1348
+ )
1349
+ const closerIndex = prepareBody.indexOf(barrier.parked)
1350
+ t.true(
1351
+ closerIndex > 0,
1352
+ 'the single call must close a parked handshake instead of skipping the barrier',
1353
+ )
1354
+ t.true(
1355
+ closerIndex < prepareBody.indexOf('napi_prepare_wasm_env_cleanup'),
1356
+ 'and it must close it before looking the single-call export up: a parked handshake is already begun',
1357
+ )
1358
+ })
1359
+ }
1360
+
1126
1361
  // The loaders order their own teardown barrier-then-destroy, but the emnapi
1127
1362
  // context is a live object: an embedder or test harness holding it, or emnapi's
1128
1363
  // own `beforeExit` auto-destroy on a host where `suppressDestroy()` is absent,
@@ -1140,14 +1375,22 @@ const CONTEXT_DESTROY_WRAP_SIGNATURE =
1140
1375
  const preparingBarrierGuards = {
1141
1376
  shared: {
1142
1377
  probe: '__isPreparingWasmEnvCleanup',
1378
+ // Both barrier entry points read it: the single call and the two-phase
1379
+ // form, which keeps it raised across the turns it yields. The single call
1380
+ // splits the check in two, because a handshake parked between the halves is
1381
+ // one it closes rather than refuses — see the two-phase test above — so the
1382
+ // shape below is the reentrancy half alone.
1383
+ entryGuard: ` if (__emnapiWasmEnvCleanupPreparing) {
1384
+ return
1385
+ }`,
1386
+ twoPhaseEntryGuard: ` if (__emnapiWasmEnvCleanupPrepared || __emnapiWasmEnvCleanupPreparing) {
1387
+ return
1388
+ }`,
1143
1389
  snippets: [
1144
1390
  'let __emnapiWasmEnvCleanupPreparing = false',
1145
1391
  `function __isPreparingWasmEnvCleanup() {
1146
1392
  return __emnapiWasmEnvCleanupPreparing
1147
1393
  }`,
1148
- ` if (__emnapiWasmEnvCleanupPrepared || __emnapiWasmEnvCleanupPreparing) {
1149
- return
1150
- }`,
1151
1394
  ` __emnapiWasmEnvCleanupPreparing = true
1152
1395
  try {
1153
1396
  prepare()
@@ -1158,12 +1401,15 @@ const preparingBarrierGuards = {
1158
1401
  },
1159
1402
  deferred: {
1160
1403
  probe: '__isPreparingEnvCleanup',
1404
+ entryGuard: ` if (__wasmEnvCleanupPreparing) {
1405
+ return
1406
+ }`,
1407
+ twoPhaseEntryGuard: ` if (__wasmEnvCleanupPrepared || __wasmEnvCleanupPreparing) {
1408
+ return
1409
+ }`,
1161
1410
  snippets: [
1162
1411
  'let __wasmEnvCleanupPreparing = false',
1163
1412
  'const __isPreparingEnvCleanup = () => __wasmEnvCleanupPreparing',
1164
- ` if (__wasmEnvCleanupPrepared || __wasmEnvCleanupPreparing) {
1165
- return
1166
- }`,
1167
1413
  ` __wasmEnvCleanupPreparing = true
1168
1414
  try {
1169
1415
  __prepareWasmEnvCleanup()
@@ -1267,6 +1513,20 @@ for (const { name, code, prepare, guard } of wrappedContextCreationCases) {
1267
1513
  `barrier must carry its in-flight guard exactly once: ${snippet}`,
1268
1514
  )
1269
1515
  }
1516
+ // Both barrier entry points — the single call and the two-phase form —
1517
+ // refuse to re-enter a barrier already in flight. The two-phase form still
1518
+ // tests the pair in one condition; the single call tests the reentrancy
1519
+ // half on its own, after it has dealt with a parked handshake.
1520
+ t.is(
1521
+ code.split(guard.entryGuard).length - 1,
1522
+ 1,
1523
+ 'the single call must refuse to re-enter a barrier already in flight',
1524
+ )
1525
+ t.is(
1526
+ code.split(guard.twoPhaseEntryGuard).length - 1,
1527
+ 1,
1528
+ 'the two-phase barrier must refuse to re-enter a barrier already in flight',
1529
+ )
1270
1530
  t.is(
1271
1531
  code.split(NESTED_DESTROY_NO_OP).length - 1,
1272
1532
  1,
package/src/api/build.ts CHANGED
@@ -42,7 +42,9 @@ import {
42
42
  removeNodeStreamWebTypeImports,
43
43
  rewriteUnboundNodeGlobalTypeQueries,
44
44
  rewriteTypeImportReferences,
45
+ checkSelfsign,
45
46
  scanExportedName,
47
+ signFileAtomic,
46
48
  type Target,
47
49
  targetToEnvVar,
48
50
  tryInstallCargoBinary,
@@ -254,7 +256,7 @@ export function checkAsyncRuntimeHostContract({
254
256
  }
255
257
  if (!asyncRuntime && missingHostExports.length === 0) {
256
258
  return {
257
- warning: `${packageName} exports the napi-async-runtime host contract but napi.wasm.asyncRuntime is not enabled. The generated WASI loaders will not install the CurrentThread task and timer hosts, so async exports will never make progress unless the host is installed by hand.`,
259
+ warning: `${packageName} exports the napi-async-runtime host contract but napi.wasm.asyncRuntime is not enabled. The generated WASI loaders will not install the CurrentThread task and timer hosts, so a binding running the CurrentThread flavor (the default on every wasm target) will make no progress unless the hosts are installed by hand. A wasm32-wasip1-threads binding that configures MultiThread does not need them.`,
258
260
  }
259
261
  }
260
262
  return {}
@@ -1138,6 +1140,7 @@ export async function buildProject(rawOptions: BuildOptions) {
1138
1140
 
1139
1141
  const options: ParsedBuildOptions = {
1140
1142
  dtsCache: true,
1143
+ ohosSign: true,
1141
1144
  ...rawOptions,
1142
1145
  format: resolveBuildFormat(rawOptions),
1143
1146
  cwd: rawOptions.cwd ?? process.cwd(),
@@ -2466,6 +2469,40 @@ class Builder {
2466
2469
  } else {
2467
2470
  await copyFileAtomic(src, dest)
2468
2471
  artifactReplaced = true
2472
+ // OpenHarmony devices refuse to load unsigned `.so` files, so inject
2473
+ // the fs-verity `.codesign` self-signature unless `--no-ohos-sign`.
2474
+ if (
2475
+ this.target.platform === 'openharmony' &&
2476
+ this.options.ohosSign !== false
2477
+ ) {
2478
+ debug('Self-signing OpenHarmony artifact:')
2479
+ debug(' %i', dest)
2480
+ try {
2481
+ // Force mode: the OHOS SDK linker can already self-sign artifacts
2482
+ // when its opt-in code-signing is enabled — strip that signature
2483
+ // and re-sign rather than failing on the existing `.codesign`
2484
+ // section.
2485
+ signFileAtomic(dest, true)
2486
+ // Re-verify the signature we just wrote so a corrupt or
2487
+ // mis-injected `.codesign` section is surfaced immediately.
2488
+ const { ok, reason } = checkSelfsign(readFileSync(dest))
2489
+ if (!ok) {
2490
+ debug.warn(
2491
+ `OpenHarmony self-sign verification failed: ${dest}: ${reason}`,
2492
+ )
2493
+ }
2494
+ } catch (e) {
2495
+ // Self-signing is best-effort: HarmonyOS only ships on aarch64,
2496
+ // and the algorithm (like the official binary-sign-tool) is only
2497
+ // exercised against aarch64 artifacts — warn instead of failing
2498
+ // the whole build when e.g. an x86_64 artifact cannot be signed.
2499
+ // signElf rejects before writing anything, so `dest` still holds
2500
+ // the copied artifact.
2501
+ debug.warn(
2502
+ `Skip OpenHarmony self-signing: ${dest}: ${(e as Error).message}`,
2503
+ )
2504
+ }
2505
+ }
2469
2506
  }
2470
2507
  this.outputs.push({
2471
2508
  kind: dest.endsWith('.node') ? 'node' : isWasm ? 'wasm' : 'exe',