@napi-rs/cli 3.9.0 → 3.10.0

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.
Files changed (39) hide show
  1. package/README.md +17 -4
  2. package/dist/cli.js +10359 -8729
  3. package/dist/index.cjs +10381 -8751
  4. package/dist/index.d.cts +83 -16
  5. package/dist/index.d.ts +83 -16
  6. package/dist/index.js +10359 -8729
  7. package/docs/wasi.md +318 -4
  8. package/package.json +5 -6
  9. package/src/api/__tests__/__snapshots__/templates.spec.ts.md +4003 -53
  10. package/src/api/__tests__/__snapshots__/templates.spec.ts.snap +0 -0
  11. package/src/api/__tests__/build-regressions.spec.ts +210 -3
  12. package/src/api/__tests__/build.spec.ts +2333 -3
  13. package/src/api/__tests__/create-npm-dirs.spec.ts +105 -3
  14. package/src/api/__tests__/pre-publish.spec.ts +153 -1
  15. package/src/api/__tests__/templates.spec.ts +1309 -1
  16. package/src/api/build.ts +756 -30
  17. package/src/api/create-npm-dirs.ts +23 -10
  18. package/src/api/new.ts +13 -18
  19. package/src/api/pre-publish.ts +34 -11
  20. package/src/api/rename.ts +10 -21
  21. package/src/api/templates/binding-target.ts +176 -0
  22. package/src/api/templates/index.ts +1 -0
  23. package/src/api/templates/js-binding.ts +54 -10
  24. package/src/api/templates/load-wasi-template.ts +661 -58
  25. package/src/api/templates/wasi-worker-template.ts +36 -31
  26. package/src/commands/build.ts +1 -1
  27. package/src/utils/__tests__/__snapshots__/typegen.spec.ts.md +18 -28
  28. package/src/utils/__tests__/__snapshots__/typegen.spec.ts.snap +0 -0
  29. package/src/utils/__tests__/misc.spec.ts +4 -0
  30. package/src/utils/__tests__/reconciliation.spec.ts +676 -0
  31. package/src/utils/__tests__/serialize.spec.ts +55 -0
  32. package/src/utils/__tests__/target.spec.ts +221 -0
  33. package/src/utils/__tests__/typegen.spec.ts +115 -0
  34. package/src/utils/config.ts +50 -0
  35. package/src/utils/index.ts +1 -0
  36. package/src/utils/misc.ts +351 -79
  37. package/src/utils/serialize.ts +47 -0
  38. package/src/utils/target.ts +150 -1
  39. package/src/utils/typegen.ts +608 -42
package/src/utils/misc.ts CHANGED
@@ -44,6 +44,7 @@ import { setTimeout as delay } from 'node:timers/promises'
44
44
 
45
45
  import pkgJson from '../../package.json' with { type: 'json' }
46
46
  import { debug } from './log.js'
47
+ import { serializeJson } from './serialize.js'
47
48
 
48
49
  export const readFileAsync = readFile
49
50
  export const writeFileAsync = writeFile
@@ -63,13 +64,25 @@ const reconciliationLockKind = 'napi-rs-filesystem-reconciliation-lock'
63
64
  const reconciliationReclaimKind = 'napi-rs-filesystem-reconciliation-reclaim'
64
65
  const reconciliationStateVersion = 1
65
66
  const reconciliationLockAcquisitionTimeout = 120_000
67
+ // Bounds how long acquisition rides out fast probe failures before degrading
68
+ // to lock-free operation. Slow-but-working probes are unaffected: a read that
69
+ // returns a complete identity always wins, regardless of this budget.
70
+ const reconciliationIdentityWaitTimeout = 10_000
66
71
  const reconciliationLockCleanupTimeout = 5_000
67
72
  const reconciliationLockCleanupRetryInterval = 250
68
73
  const reconciliationMetadataMaximumSize = 64 * 1024
69
74
  const processIncarnationCommandTimeout = 2_000
75
+ // One-shot host identity probes run once per process and must outlive slow
76
+ // first-start tools (e.g. powershell cold start on fresh CI VMs); per-PID
77
+ // incarnation probes run in poll loops and keep the shorter timeout.
78
+ const processExecutionIdentityCommandTimeout = 15_000
70
79
  const incompleteProcessExecutionIdentityCacheDuration =
71
80
  processIncarnationCommandTimeout
72
81
  const processIncarnationObservationCacheDuration = 1_000
82
+ // A failed current-process incarnation probe (e.g. powershell hitting its
83
+ // kill timeout on a loaded host) is retried at most this often, so a burst
84
+ // of sequential lock acquisitions pays one probe instead of one each.
85
+ const processIncarnationProbeRetryCooldown = 1_000
73
86
  const fileSystemTransactionJournalName = '.napi-rs-filesystem-transaction.swp'
74
87
  const fileSystemTransactionCandidateMarker = '.candidate.'
75
88
  const fileSystemTransactionRetiredMarker = '.retired.'
@@ -132,7 +145,8 @@ interface ReconciliationReclaimState {
132
145
  }
133
146
 
134
147
  type ReconciliationMetadataOwner =
135
- ReconciliationLockOwner | ReconciliationReclaimOwner
148
+ | ReconciliationLockOwner
149
+ | ReconciliationReclaimOwner
136
150
 
137
151
  interface ReconciliationCandidateState {
138
152
  owner: ReconciliationMetadataOwner
@@ -147,13 +161,20 @@ interface ProcessIncarnationObservation {
147
161
  incarnation: string | null
148
162
  }
149
163
 
150
- interface ProcessExecutionIdentity {
164
+ export interface ProcessExecutionIdentity {
151
165
  boot: string | null
152
166
  bootSession: string | null
153
167
  machine: string | null
154
168
  namespace: string | null
155
169
  }
156
170
 
171
+ export interface FileSystemReconciliationOptions {
172
+ getProcessExecutionIdentity?: () => Promise<ProcessExecutionIdentity>
173
+ getProcessIncarnation?: () => Promise<string | null>
174
+ identityWaitTimeout?: number
175
+ lockAcquisitionTimeout?: number
176
+ }
177
+
157
178
  interface ProcessOwnerState {
158
179
  stale: boolean
159
180
  unverifiableReason?: string
@@ -183,8 +204,6 @@ const processIncarnationObservations = new Map<
183
204
  number,
184
205
  ProcessIncarnationObservation
185
206
  >()
186
- let currentProcessIncarnation: string | undefined
187
- let currentProcessIncarnationProbe: Promise<string | null> | undefined
188
207
  let linuxBootId: string | undefined
189
208
 
190
209
  interface TransactionParentIdentity {
@@ -345,6 +364,7 @@ export async function copyFileAtomic(
345
364
  export async function withFileSystemReconciliation<T>(
346
365
  path: string,
347
366
  operation: () => Promise<T>,
367
+ options?: FileSystemReconciliationOptions,
348
368
  ): Promise<T> {
349
369
  const localKey = resolve(path)
350
370
  const previous = reconciliationTails.get(localKey) ?? Promise.resolve()
@@ -356,21 +376,54 @@ export async function withFileSystemReconciliation<T>(
356
376
  reconciliationTails.set(localKey, tail)
357
377
 
358
378
  const releaseCrossProcessLocks: Array<() => Promise<void>> = []
379
+ let lockAcquisitionDegraded = false
359
380
  let operationFailed = false
360
381
  let operationError: unknown
361
382
  let result!: T
362
383
  try {
363
384
  await previous.catch(() => {})
364
385
  const identities = await resolveReconciliationLockIdentities(path)
386
+ const injectedIdentityGetter = options?.getProcessExecutionIdentity
387
+ const identityWaitTimeout =
388
+ options?.identityWaitTimeout ?? reconciliationIdentityWaitTimeout
389
+ // Injected getters bypass the degraded-decision memo entirely; only the
390
+ // default singleton path memoizes so one build pays the wait once.
391
+ const resolveExecutionIdentity =
392
+ (): Promise<ProcessExecutionIdentityResolution> =>
393
+ injectedIdentityGetter === undefined
394
+ ? resolveDefaultProcessExecutionIdentityForLocking(
395
+ identityWaitTimeout,
396
+ )
397
+ : resolveProcessExecutionIdentityForLocking(
398
+ injectedIdentityGetter,
399
+ createReconciliationLockDeadline(identityWaitTimeout),
400
+ )
401
+ const getProcessExecutionIdentity =
402
+ injectedIdentityGetter ?? getCurrentProcessExecutionIdentity
403
+ const getProcessIncarnation =
404
+ options?.getProcessIncarnation ?? getCurrentProcessIncarnation
365
405
  const acquisitionDeadline = createReconciliationLockDeadline(
366
- reconciliationLockAcquisitionTimeout,
406
+ options?.lockAcquisitionTimeout ?? reconciliationLockAcquisitionTimeout,
367
407
  )
368
408
  for (const identity of identities) {
369
- releaseCrossProcessLocks.push(
370
- await acquireReconciliationLock(identity, acquisitionDeadline),
409
+ const acquisition = await acquireReconciliationLock(
410
+ identity,
411
+ acquisitionDeadline,
412
+ resolveExecutionIdentity,
413
+ getProcessExecutionIdentity,
414
+ getProcessIncarnation,
371
415
  )
416
+ releaseCrossProcessLocks.push(acquisition.release)
417
+ lockAcquisitionDegraded ||= acquisition.degraded
418
+ }
419
+ if (!lockAcquisitionDegraded) {
420
+ // Recovery assumes cross-process exclusivity: without a verifiable lock
421
+ // it could roll back a live transaction, so degraded (lock-free) hosts
422
+ // keep pre-3.8.0 behavior and skip it. A genuinely crashed journal then
423
+ // fails the next transaction publish loudly and is recovered by the
424
+ // next run whose identity probes succeed.
425
+ await recoverFileSystemTransaction(identities[0].anchorPath)
372
426
  }
373
- await recoverFileSystemTransaction(identities[0].anchorPath)
374
427
  const currentCapability = fileSystemReconciliationCapability.getStore()
375
428
  const roots = new Set(currentCapability?.roots)
376
429
  roots.add(fileSystemReconciliationCapabilityRoot(identities[0].anchorPath))
@@ -561,13 +614,18 @@ export async function withPackageFileSystemReconciliation<T>(
561
614
  'boundary' | 'cwd' | 'packageRoot'
562
615
  >,
563
616
  operation: () => Promise<T>,
617
+ options?: FileSystemReconciliationOptions,
564
618
  ): Promise<T> {
565
619
  const roots = getPackageReconciliationRoots(paths)
566
620
  const acquire = (index: number): Promise<T> => {
567
621
  if (index === roots.length) {
568
622
  return operation()
569
623
  }
570
- return withFileSystemReconciliation(roots[index], () => acquire(index + 1))
624
+ return withFileSystemReconciliation(
625
+ roots[index],
626
+ () => acquire(index + 1),
627
+ options,
628
+ )
571
629
  }
572
630
 
573
631
  // Build takes the package lock before widening to its transaction root.
@@ -1567,16 +1625,25 @@ async function resolveReconciliationLockIdentities(
1567
1625
  if (!anchorStats.isDirectory()) {
1568
1626
  throw reconciliationAnchorError(anchorPath, 'is not a directory', 'ENOTDIR')
1569
1627
  }
1570
- const guardKeys = new Set<string>()
1628
+ const guardKeys = new Map<string, string | undefined>()
1571
1629
  if (await directoryIsWritable(dirname(anchorPath))) {
1572
- guardKeys.add(anchorPath)
1630
+ guardKeys.set(anchorPath, undefined)
1573
1631
  }
1574
1632
  let currentPath = requestedPath
1575
1633
  while (true) {
1576
1634
  const currentStats = await lstatIfExists(currentPath)
1577
1635
  if (currentStats?.isSymbolicLink()) {
1578
- if (await directoryIsWritable(dirname(currentPath))) {
1579
- guardKeys.add(currentPath)
1636
+ // Far-ancestor symlink guards scatter locks outside the project (up to
1637
+ // a writable filesystem root, #3444); topology swaps are still caught
1638
+ // fail-safe by the anchor assertions, so a guard is kept only where its
1639
+ // lock stays within the anchor's parent. The lock root is resolved once
1640
+ // here at walk time so the checked root is the root used.
1641
+ const lockRoot = await realpath(dirname(currentPath))
1642
+ if (
1643
+ managedPackagePathIsWithin(dirname(anchorPath), lockRoot) &&
1644
+ (await directoryIsWritable(dirname(currentPath)))
1645
+ ) {
1646
+ guardKeys.set(currentPath, lockRoot)
1580
1647
  }
1581
1648
  }
1582
1649
  const parent = dirname(currentPath)
@@ -1587,8 +1654,8 @@ async function resolveReconciliationLockIdentities(
1587
1654
  }
1588
1655
 
1589
1656
  const pathIdentities = await Promise.all(
1590
- [...guardKeys].map(async (key) => {
1591
- const lockRootPath = await realpath(dirname(key))
1657
+ [...guardKeys].map(async ([key, carriedLockRoot]) => {
1658
+ const lockRootPath = carriedLockRoot ?? (await realpath(dirname(key)))
1592
1659
  const lockRootStats = await lstat(lockRootPath, { bigint: true })
1593
1660
  if (!lockRootStats.isDirectory()) {
1594
1661
  throw reconciliationPathCollisionError(
@@ -1784,11 +1851,14 @@ function reconciliationRetiredPath(path: string) {
1784
1851
  async function acquireReconciliationLock(
1785
1852
  identity: ReconciliationLockIdentity,
1786
1853
  acquisitionDeadline: ReconciliationLockDeadline,
1854
+ resolveExecutionIdentity: () => Promise<ProcessExecutionIdentityResolution>,
1855
+ getProcessExecutionIdentity: () => Promise<ProcessExecutionIdentity>,
1856
+ getProcessIncarnation: () => Promise<string | null>,
1787
1857
  ) {
1788
1858
  const { key } = identity
1789
- const [incarnation, executionIdentity] = await Promise.all([
1790
- getCurrentProcessIncarnation(),
1791
- getCurrentProcessExecutionIdentity(),
1859
+ const [incarnation, identityResolution] = await Promise.all([
1860
+ getProcessIncarnation(),
1861
+ resolveExecutionIdentity(),
1792
1862
  ])
1793
1863
  const lockPath = reconciliationLockPath(identity)
1794
1864
  await assertReconciliationAnchorUnchanged(identity)
@@ -1805,6 +1875,53 @@ async function acquireReconciliationLock(
1805
1875
  isReconciliationReclaimOwner,
1806
1876
  )
1807
1877
 
1878
+ let executionIdentity = identityResolution.identity
1879
+ if (!identityResolution.complete) {
1880
+ // Degraded (lock-free) wait: on hosts that cannot provide a complete
1881
+ // execution identity (FreeBSD, containers without /etc/machine-id,
1882
+ // policy-blocked Windows tools), skipping the owner write preserves
1883
+ // pre-reconciliation (3.7.4) behavior for uncontended builds; an owner we
1884
+ // cannot write verifiably could never be reclaimed. An existing lock is
1885
+ // still respected fail-closed: never bypassed, and an error only if it
1886
+ // outlives the acquisition deadline.
1887
+ while (true) {
1888
+ await waitForReconciliationReclaim(identity, acquisitionDeadline)
1889
+ const state = await inspectReconciliationLock(identity, lockPath)
1890
+ if (state === undefined) {
1891
+ debug.warn(
1892
+ `Skipping cross-process filesystem reconciliation lock for ${key}: the system did not provide a complete machine, boot-session, and process-namespace identity (missing: ${missingProcessExecutionIdentityComponents(executionIdentity)})`,
1893
+ )
1894
+ return { release: async () => {}, degraded: true }
1895
+ }
1896
+ // Re-probe with the raw getter every iteration: a transient probe
1897
+ // failure heals here, and the existing lock can then be evaluated and
1898
+ // reclaimed through the normal path instead of failing at the deadline.
1899
+ const healedIdentity = await getProcessExecutionIdentity()
1900
+ if (isCompleteProcessExecutionIdentity(healedIdentity)) {
1901
+ degradedProcessExecutionIdentityMemo = undefined
1902
+ executionIdentity = healedIdentity
1903
+ break
1904
+ }
1905
+ if (performance.now() >= acquisitionDeadline.expiresAt) {
1906
+ throw reconciliationExecutionIdentityUnavailableError(
1907
+ key,
1908
+ healedIdentity,
1909
+ )
1910
+ }
1911
+ try {
1912
+ await delayReconciliationLockRetry(acquisitionDeadline, key)
1913
+ } catch (error) {
1914
+ if ((error as NodeJS.ErrnoException).code === 'ETIMEDOUT') {
1915
+ throw reconciliationExecutionIdentityUnavailableError(
1916
+ key,
1917
+ healedIdentity,
1918
+ )
1919
+ }
1920
+ throw error
1921
+ }
1922
+ }
1923
+ }
1924
+
1808
1925
  while (true) {
1809
1926
  assertReconciliationLockAcquisitionTimeRemaining(acquisitionDeadline, key)
1810
1927
  await assertReconciliationAnchorUnchanged(identity)
@@ -1854,7 +1971,14 @@ async function acquireReconciliationLock(
1854
1971
  if (
1855
1972
  state?.owner &&
1856
1973
  state.stale &&
1857
- (await tryReclaimStaleReconciliationLock(identity, lockPath, state))
1974
+ (await tryReclaimStaleReconciliationLock(
1975
+ identity,
1976
+ lockPath,
1977
+ state,
1978
+ getProcessExecutionIdentity,
1979
+ getProcessIncarnation,
1980
+ acquisitionDeadline,
1981
+ ))
1858
1982
  ) {
1859
1983
  continue
1860
1984
  }
@@ -1876,7 +2000,15 @@ async function acquireReconciliationLock(
1876
2000
  `Lost filesystem reconciliation lock ownership before initialization: ${key}`,
1877
2001
  )
1878
2002
  }
1879
- return maintainReconciliationLock(identity, lockPath, lockStats, token)
2003
+ return {
2004
+ release: maintainReconciliationLock(
2005
+ identity,
2006
+ lockPath,
2007
+ lockStats,
2008
+ token,
2009
+ ),
2010
+ degraded: false,
2011
+ }
1880
2012
  } catch (error) {
1881
2013
  await cleanupFailedReconciliationLock(
1882
2014
  identity,
@@ -2385,13 +2517,26 @@ async function tryReclaimStaleReconciliationLock(
2385
2517
  identity: ReconciliationLockIdentity,
2386
2518
  lockPath: string,
2387
2519
  expectedState: ReconciliationLockState,
2520
+ getProcessExecutionIdentity: () => Promise<ProcessExecutionIdentity>,
2521
+ getProcessIncarnation: () => Promise<string | null>,
2522
+ acquisitionDeadline: ReconciliationLockDeadline,
2388
2523
  ) {
2389
2524
  const reclaimPath = reconciliationReclaimPath(identity)
2390
2525
  const token = randomUUID()
2391
- const [incarnation, executionIdentity] = await Promise.all([
2392
- getCurrentProcessIncarnation(),
2393
- getCurrentProcessExecutionIdentity(),
2526
+ const [incarnation, identityResolution] = await Promise.all([
2527
+ getProcessIncarnation(),
2528
+ resolveProcessExecutionIdentityForLocking(
2529
+ getProcessExecutionIdentity,
2530
+ acquisitionDeadline,
2531
+ ),
2394
2532
  ])
2533
+ // Never publish a reclaim owner with incomplete identity; an unverifiable
2534
+ // reclaim owner could never be reclaimed itself. Reachable in practice only
2535
+ // when the current side already proved complete (state.stale requires it).
2536
+ if (!identityResolution.complete) {
2537
+ return false
2538
+ }
2539
+ const executionIdentity = identityResolution.identity
2395
2540
  const reclaimOwner: ReconciliationReclaimOwner = {
2396
2541
  candidate: reconciliationCandidateName(reclaimPath, token),
2397
2542
  createdAt: Date.now(),
@@ -3131,6 +3276,32 @@ function reconciliationHardLinkRequiredError(path: string, cause: unknown) {
3131
3276
  return error
3132
3277
  }
3133
3278
 
3279
+ function missingProcessExecutionIdentityComponents(
3280
+ identity: ProcessExecutionIdentity,
3281
+ ) {
3282
+ return (
3283
+ [
3284
+ ['machine', identity.machine],
3285
+ ['boot', identity.boot],
3286
+ ['namespace', identity.namespace],
3287
+ ] as const
3288
+ )
3289
+ .filter(([, value]) => value === null)
3290
+ .map(([name]) => name)
3291
+ .join(', ')
3292
+ }
3293
+
3294
+ function reconciliationExecutionIdentityUnavailableError(
3295
+ key: string,
3296
+ identity: ProcessExecutionIdentity,
3297
+ ) {
3298
+ const error = new Error(
3299
+ `Filesystem reconciliation lock was not created for ${key}: the system did not provide a complete machine, boot-session, and process-namespace identity (missing: ${missingProcessExecutionIdentityComponents(identity)}), and an existing reconciliation lock cannot be evaluated without it. On Windows this means reg.exe or powershell.exe was blocked or too slow; retry the build or unblock those tools`,
3300
+ ) as NodeJS.ErrnoException
3301
+ error.code = 'EAGAIN'
3302
+ return error
3303
+ }
3304
+
3134
3305
  function reconciliationOwnerCannotBeVerifiedError(
3135
3306
  path: string,
3136
3307
  owner: ReconciliationMetadataOwner,
@@ -3335,34 +3506,59 @@ function processExists(pid: number) {
3335
3506
  }
3336
3507
  }
3337
3508
 
3338
- function getCurrentProcessIncarnation() {
3339
- if (currentProcessIncarnation !== undefined) {
3340
- return Promise.resolve(currentProcessIncarnation)
3341
- }
3342
- if (currentProcessIncarnationProbe !== undefined) {
3343
- return currentProcessIncarnationProbe
3509
+ export function createProcessIncarnationGetter(
3510
+ readIncarnation: () => Promise<string | null>,
3511
+ failureRetryCooldown: number,
3512
+ now: () => number = () => performance.now(),
3513
+ ) {
3514
+ // The current process incarnation is fixed for the process lifetime; only
3515
+ // the probe reading it can fail. A success is cached forever and an
3516
+ // in-flight probe is shared. A failure is reused for the cooldown window
3517
+ // so a burst of sequential lock acquisitions on a probe-failing host (e.g.
3518
+ // powershell hitting its kill timeout under load) spawns one probe instead
3519
+ // of one per acquisition.
3520
+ let incarnation: string | undefined
3521
+ let activeProbe: Promise<string | null> | undefined
3522
+ let retryAt = 0
3523
+ return function getProcessIncarnation() {
3524
+ if (incarnation !== undefined) {
3525
+ return Promise.resolve(incarnation)
3526
+ }
3527
+ if (activeProbe !== undefined) {
3528
+ return activeProbe
3529
+ }
3530
+ if (now() < retryAt) {
3531
+ return Promise.resolve(null)
3532
+ }
3533
+ const probe = readIncarnation()
3534
+ activeProbe = probe
3535
+ void probe.then(
3536
+ (observed) => {
3537
+ if (observed !== null) {
3538
+ incarnation = observed
3539
+ } else {
3540
+ retryAt = now() + failureRetryCooldown
3541
+ }
3542
+ if (activeProbe === probe) {
3543
+ activeProbe = undefined
3544
+ }
3545
+ },
3546
+ () => {
3547
+ retryAt = now() + failureRetryCooldown
3548
+ if (activeProbe === probe) {
3549
+ activeProbe = undefined
3550
+ }
3551
+ },
3552
+ )
3553
+ return probe
3344
3554
  }
3345
-
3346
- const probe = readProcessIncarnation(process.pid)
3347
- currentProcessIncarnationProbe = probe
3348
- void probe.then(
3349
- (incarnation) => {
3350
- if (incarnation !== null) {
3351
- currentProcessIncarnation = incarnation
3352
- }
3353
- if (currentProcessIncarnationProbe === probe) {
3354
- currentProcessIncarnationProbe = undefined
3355
- }
3356
- },
3357
- () => {
3358
- if (currentProcessIncarnationProbe === probe) {
3359
- currentProcessIncarnationProbe = undefined
3360
- }
3361
- },
3362
- )
3363
- return probe
3364
3555
  }
3365
3556
 
3557
+ const getCurrentProcessIncarnation = createProcessIncarnationGetter(
3558
+ () => readProcessIncarnation(process.pid),
3559
+ processIncarnationProbeRetryCooldown,
3560
+ )
3561
+
3366
3562
  export function createProcessExecutionIdentityGetter(
3367
3563
  readIdentity: () => Promise<ProcessExecutionIdentity>,
3368
3564
  incompleteCacheDuration: number,
@@ -3440,11 +3636,74 @@ function isCompleteProcessExecutionIdentity(
3440
3636
  )
3441
3637
  }
3442
3638
 
3639
+ // An owner written with an incomplete identity can never be verified or
3640
+ // reclaimed (processOwnerState fails closed), so publishing one would wedge
3641
+ // the lock until manual cleanup. Re-probe within the wait budget instead, then
3642
+ // report the outcome: callers degrade to lock-free operation rather than fail.
3643
+ // Slow-but-working probes win on the first read regardless of the budget; the
3644
+ // budget only bounds riding out fast probe failures.
3645
+ export async function resolveProcessExecutionIdentityForLocking(
3646
+ getIdentity: () => Promise<ProcessExecutionIdentity>,
3647
+ waitBudget: ReconciliationLockDeadline,
3648
+ ): Promise<
3649
+ | { complete: true; identity: ProcessExecutionIdentity }
3650
+ | { complete: false; identity: ProcessExecutionIdentity }
3651
+ > {
3652
+ while (true) {
3653
+ const identity = await getIdentity()
3654
+ if (isCompleteProcessExecutionIdentity(identity)) {
3655
+ return { complete: true, identity }
3656
+ }
3657
+ if (performance.now() >= waitBudget.expiresAt) {
3658
+ return { complete: false, identity }
3659
+ }
3660
+ await delay(
3661
+ Math.max(0, Math.min(20, waitBudget.expiresAt - performance.now())),
3662
+ )
3663
+ }
3664
+ }
3665
+
3666
+ type ProcessExecutionIdentityResolution = Awaited<
3667
+ ReturnType<typeof resolveProcessExecutionIdentityForLocking>
3668
+ >
3669
+
3443
3670
  const getCurrentProcessExecutionIdentity = createProcessExecutionIdentityGetter(
3444
3671
  readProcessExecutionIdentity,
3445
3672
  incompleteProcessExecutionIdentityCacheDuration,
3446
3673
  )
3447
3674
 
3675
+ const degradedProcessExecutionIdentityMemoDuration = 30_000
3676
+ // One build pays the probe-failure wait once: a degraded outcome is memoized
3677
+ // briefly so later acquisitions in this process skip the wait loop. 30s
3678
+ // outlives a single build but not separate invocations.
3679
+ let degradedProcessExecutionIdentityMemo: { decidedAt: number } | undefined
3680
+
3681
+ async function resolveDefaultProcessExecutionIdentityForLocking(
3682
+ identityWaitTimeout: number,
3683
+ ): Promise<ProcessExecutionIdentityResolution> {
3684
+ const memo = degradedProcessExecutionIdentityMemo
3685
+ if (
3686
+ memo !== undefined &&
3687
+ performance.now() - memo.decidedAt <
3688
+ degradedProcessExecutionIdentityMemoDuration
3689
+ ) {
3690
+ const identity = await getCurrentProcessExecutionIdentity()
3691
+ if (isCompleteProcessExecutionIdentity(identity)) {
3692
+ degradedProcessExecutionIdentityMemo = undefined
3693
+ return { complete: true, identity }
3694
+ }
3695
+ return { complete: false, identity }
3696
+ }
3697
+ const resolution = await resolveProcessExecutionIdentityForLocking(
3698
+ getCurrentProcessExecutionIdentity,
3699
+ createReconciliationLockDeadline(identityWaitTimeout),
3700
+ )
3701
+ degradedProcessExecutionIdentityMemo = resolution.complete
3702
+ ? undefined
3703
+ : { decidedAt: performance.now() }
3704
+ return resolution
3705
+ }
3706
+
3448
3707
  async function readProcessExecutionIdentity(): Promise<ProcessExecutionIdentity> {
3449
3708
  if (process.platform === 'linux') {
3450
3709
  const [bootId, machineId, productUuid, pidNamespace] = await Promise.all([
@@ -3481,19 +3740,29 @@ async function readProcessExecutionIdentity(): Promise<ProcessExecutionIdentity>
3481
3740
  )
3482
3741
  : 'powershell.exe'
3483
3742
  const [machineOutput, boot] = await Promise.all([
3484
- executeProcessIncarnationCommand(reg, [
3485
- 'query',
3486
- String.raw`HKLM\SOFTWARE\Microsoft\Cryptography`,
3487
- '/v',
3488
- 'MachineGuid',
3489
- ]),
3490
- executeProcessIncarnationCommand(powershell, [
3491
- '-NoLogo',
3492
- '-NoProfile',
3493
- '-NonInteractive',
3494
- '-Command',
3495
- '(Get-CimInstance Win32_OperatingSystem).LastBootUpTime.ToUniversalTime().Ticks',
3496
- ]),
3743
+ executeProcessIncarnationCommand(
3744
+ reg,
3745
+ [
3746
+ 'query',
3747
+ String.raw`HKLM\SOFTWARE\Microsoft\Cryptography`,
3748
+ '/v',
3749
+ 'MachineGuid',
3750
+ ],
3751
+ process.env,
3752
+ processExecutionIdentityCommandTimeout,
3753
+ ),
3754
+ executeProcessIncarnationCommand(
3755
+ powershell,
3756
+ [
3757
+ '-NoLogo',
3758
+ '-NoProfile',
3759
+ '-NonInteractive',
3760
+ '-Command',
3761
+ '(Get-CimInstance Win32_OperatingSystem).LastBootUpTime.ToUniversalTime().Ticks',
3762
+ ],
3763
+ process.env,
3764
+ processExecutionIdentityCommandTimeout,
3765
+ ),
3497
3766
  ])
3498
3767
  const machineGuid = machineOutput?.match(
3499
3768
  /\bMachineGuid\s+REG_SZ\s+([0-9a-f-]+)\s*$/i,
@@ -3510,19 +3779,24 @@ async function readProcessExecutionIdentity(): Promise<ProcessExecutionIdentity>
3510
3779
  if (process.platform === 'darwin') {
3511
3780
  const [machineOutput, bootSessionOutput, bootTimeOutput] =
3512
3781
  await Promise.all([
3513
- executeProcessIncarnationCommand('/usr/sbin/ioreg', [
3514
- '-rd1',
3515
- '-c',
3516
- 'IOPlatformExpertDevice',
3517
- ]),
3518
- executeProcessIncarnationCommand('/usr/sbin/sysctl', [
3519
- '-n',
3520
- 'kern.bootsessionuuid',
3521
- ]),
3522
- executeProcessIncarnationCommand('/usr/sbin/sysctl', [
3523
- '-n',
3524
- 'kern.boottime',
3525
- ]),
3782
+ executeProcessIncarnationCommand(
3783
+ '/usr/sbin/ioreg',
3784
+ ['-rd1', '-c', 'IOPlatformExpertDevice'],
3785
+ process.env,
3786
+ processExecutionIdentityCommandTimeout,
3787
+ ),
3788
+ executeProcessIncarnationCommand(
3789
+ '/usr/sbin/sysctl',
3790
+ ['-n', 'kern.bootsessionuuid'],
3791
+ process.env,
3792
+ processExecutionIdentityCommandTimeout,
3793
+ ),
3794
+ executeProcessIncarnationCommand(
3795
+ '/usr/sbin/sysctl',
3796
+ ['-n', 'kern.boottime'],
3797
+ process.env,
3798
+ processExecutionIdentityCommandTimeout,
3799
+ ),
3526
3800
  ])
3527
3801
  const platformUuid = machineOutput?.match(
3528
3802
  /"IOPlatformUUID"\s*=\s*"([0-9a-f-]+)"/i,
@@ -3685,6 +3959,7 @@ function executeProcessIncarnationCommand(
3685
3959
  command: string,
3686
3960
  args: string[],
3687
3961
  env: NodeJS.ProcessEnv = process.env,
3962
+ timeout: number = processIncarnationCommandTimeout,
3688
3963
  ): Promise<string | null> {
3689
3964
  return new Promise((resolveCommand) => {
3690
3965
  execFile(
@@ -3693,7 +3968,7 @@ function executeProcessIncarnationCommand(
3693
3968
  {
3694
3969
  encoding: 'utf8',
3695
3970
  env,
3696
- timeout: processIncarnationCommandTimeout,
3971
+ timeout,
3697
3972
  windowsHide: true,
3698
3973
  },
3699
3974
  (error, stdout) => {
@@ -6153,10 +6428,7 @@ export async function updatePackageJson(
6153
6428
  return
6154
6429
  }
6155
6430
  const old = JSON.parse(await readFileAsync(path, 'utf8'))
6156
- await writeFileAsync(
6157
- path,
6158
- JSON.stringify(mergePackageJson(old, partial), null, 2),
6159
- )
6431
+ await writeFileAsync(path, serializeJson(mergePackageJson(old, partial)))
6160
6432
  }
6161
6433
 
6162
6434
  export const CLI_VERSION = pkgJson.version
@@ -0,0 +1,47 @@
1
+ import { stringify as stringifyToml } from '@std/toml'
2
+ import { dump as yamlDump } from 'js-yaml'
3
+
4
+ /**
5
+ * Canonical JSON for files the CLI writes (package.json, napi config).
6
+ *
7
+ * Always 2-space indent and a POSIX trailing newline. Callers must not
8
+ * add another `\n` or omit one — every JSON writer uses this helper.
9
+ */
10
+ export function serializeJson(value: unknown): string {
11
+ return `${JSON.stringify(value, null, 2)}\n`
12
+ }
13
+
14
+ /**
15
+ * Canonical TOML for files the CLI rewrites (Cargo.toml).
16
+ *
17
+ * `@std/toml` stringify inserts a leading blank line and drops comments.
18
+ * We cannot preserve comments through parse→stringify. We only strip the
19
+ * leading blank and keep exactly one trailing newline — no regex rewrite
20
+ * of array contents, which would split commas inside quoted strings.
21
+ */
22
+ export function serializeToml(value: Record<string, unknown>): string {
23
+ let serialized = stringifyToml(value)
24
+ if (serialized.startsWith('\n')) {
25
+ serialized = serialized.slice(1)
26
+ }
27
+ if (!serialized.endsWith('\n')) {
28
+ serialized += '\n'
29
+ }
30
+ return serialized
31
+ }
32
+
33
+ /**
34
+ * Canonical YAML for files the CLI rewrites (GitHub Actions workflows).
35
+ *
36
+ * Matches `napi new` / `napi rename` dump options and always ends with a
37
+ * trailing newline. Blank lines from the original document are not
38
+ * preserved — js-yaml dump cannot round-trip those.
39
+ */
40
+ export function serializeYaml(value: unknown): string {
41
+ const serialized = yamlDump(value, {
42
+ lineWidth: -1,
43
+ noRefs: true,
44
+ sortKeys: false,
45
+ })
46
+ return serialized.endsWith('\n') ? serialized : `${serialized}\n`
47
+ }