@napi-rs/cli 3.9.1 → 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.
@@ -95,6 +95,27 @@ export interface UserNapiConfig {
95
95
  * @default 4000 pages (250 MiB), or 1024 pages (64 MiB) for the deferred workerd loader
96
96
  */
97
97
  initialMemory?: number
98
+ /**
99
+ * Initial linear memory, in 64 KiB pages, for the **threadless**
100
+ * (`wasm32-wasip1`) loaders only: the Node CJS loader, the browser loader
101
+ * and the deferred `./workerd` loader.
102
+ *
103
+ * The threaded loaders allocate one `shared: true` memory that every
104
+ * wasi-threads worker maps, so every worker stack and every thread's
105
+ * allocations come out of it and growing it is a cross-thread event; it is
106
+ * sized for the whole pool up front. The threadless loaders drive a single
107
+ * thread through a plain growable `ArrayBuffer`: they only have to clear
108
+ * the module's own link-time floor (`-zstack-size` plus static data) and
109
+ * grow on demand, which is what lets the same build fit a host with a hard
110
+ * isolate cap such as workerd's 128 MiB.
111
+ *
112
+ * Falls back to `initialMemory` when unset, so existing projects are
113
+ * unaffected.
114
+ *
115
+ * @default `initialMemory` (4000 pages), or 1024 pages for the deferred
116
+ * `./workerd` loader
117
+ */
118
+ threadlessInitialMemory?: number
98
119
  /**
99
120
  * @default 65536 pages (4GiB)
100
121
  */
@@ -119,6 +140,35 @@ export interface UserNapiConfig {
119
140
  */
120
141
  optionalDependency?: boolean
121
142
 
143
+ /**
144
+ * Whether the generated WASI loaders bootstrap the `napi-async-runtime`
145
+ * CurrentThread JavaScript hosts.
146
+ *
147
+ * A binding built with the `napi-async-runtime` crate runs the
148
+ * `CurrentThread` flavor on every WebAssembly target (it is the only
149
+ * flavor there). Its futures make no progress until a task host is
150
+ * registered, and its timers never fire until a timer host is, so the
151
+ * loader must install both right after instantiation and unregister them
152
+ * before the environment is destroyed.
153
+ *
154
+ * Enabling this emits that bootstrap in the node, browser and deferred
155
+ * (workerd) loaders, and declares `@napi-rs/async-runtime` in the
156
+ * generated `<packageName>-wasm32-*` packages. The loader delegates the
157
+ * whole host contract — the seven exports, contract version 4, the
158
+ * reservation identity, the liveness probe and the rollback — to that
159
+ * package, so a binding that does not actually expose the contract fails
160
+ * loudly at load with `ERR_NAPI_ASYNC_RUNTIME_BINDING_MISMATCH`.
161
+ *
162
+ * The bootstrap runs after instantiation, so after `#[module_init]`: the
163
+ * host registration functions are exports of the binding itself. Code that
164
+ * runs during module registration must not create timers (`sleep_until`
165
+ * fails loud at creation without a timer host) and cannot expect task
166
+ * progress until the loader has returned.
167
+ *
168
+ * @default false
169
+ */
170
+ asyncRuntime?: boolean
171
+
122
172
  /**
123
173
  * Browser wasm binding configuration
124
174
  */
package/src/utils/misc.ts CHANGED
@@ -64,13 +64,25 @@ const reconciliationLockKind = 'napi-rs-filesystem-reconciliation-lock'
64
64
  const reconciliationReclaimKind = 'napi-rs-filesystem-reconciliation-reclaim'
65
65
  const reconciliationStateVersion = 1
66
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
67
71
  const reconciliationLockCleanupTimeout = 5_000
68
72
  const reconciliationLockCleanupRetryInterval = 250
69
73
  const reconciliationMetadataMaximumSize = 64 * 1024
70
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
71
79
  const incompleteProcessExecutionIdentityCacheDuration =
72
80
  processIncarnationCommandTimeout
73
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
74
86
  const fileSystemTransactionJournalName = '.napi-rs-filesystem-transaction.swp'
75
87
  const fileSystemTransactionCandidateMarker = '.candidate.'
76
88
  const fileSystemTransactionRetiredMarker = '.retired.'
@@ -149,13 +161,20 @@ interface ProcessIncarnationObservation {
149
161
  incarnation: string | null
150
162
  }
151
163
 
152
- interface ProcessExecutionIdentity {
164
+ export interface ProcessExecutionIdentity {
153
165
  boot: string | null
154
166
  bootSession: string | null
155
167
  machine: string | null
156
168
  namespace: string | null
157
169
  }
158
170
 
171
+ export interface FileSystemReconciliationOptions {
172
+ getProcessExecutionIdentity?: () => Promise<ProcessExecutionIdentity>
173
+ getProcessIncarnation?: () => Promise<string | null>
174
+ identityWaitTimeout?: number
175
+ lockAcquisitionTimeout?: number
176
+ }
177
+
159
178
  interface ProcessOwnerState {
160
179
  stale: boolean
161
180
  unverifiableReason?: string
@@ -185,8 +204,6 @@ const processIncarnationObservations = new Map<
185
204
  number,
186
205
  ProcessIncarnationObservation
187
206
  >()
188
- let currentProcessIncarnation: string | undefined
189
- let currentProcessIncarnationProbe: Promise<string | null> | undefined
190
207
  let linuxBootId: string | undefined
191
208
 
192
209
  interface TransactionParentIdentity {
@@ -347,6 +364,7 @@ export async function copyFileAtomic(
347
364
  export async function withFileSystemReconciliation<T>(
348
365
  path: string,
349
366
  operation: () => Promise<T>,
367
+ options?: FileSystemReconciliationOptions,
350
368
  ): Promise<T> {
351
369
  const localKey = resolve(path)
352
370
  const previous = reconciliationTails.get(localKey) ?? Promise.resolve()
@@ -358,21 +376,54 @@ export async function withFileSystemReconciliation<T>(
358
376
  reconciliationTails.set(localKey, tail)
359
377
 
360
378
  const releaseCrossProcessLocks: Array<() => Promise<void>> = []
379
+ let lockAcquisitionDegraded = false
361
380
  let operationFailed = false
362
381
  let operationError: unknown
363
382
  let result!: T
364
383
  try {
365
384
  await previous.catch(() => {})
366
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
367
405
  const acquisitionDeadline = createReconciliationLockDeadline(
368
- reconciliationLockAcquisitionTimeout,
406
+ options?.lockAcquisitionTimeout ?? reconciliationLockAcquisitionTimeout,
369
407
  )
370
408
  for (const identity of identities) {
371
- releaseCrossProcessLocks.push(
372
- await acquireReconciliationLock(identity, acquisitionDeadline),
409
+ const acquisition = await acquireReconciliationLock(
410
+ identity,
411
+ acquisitionDeadline,
412
+ resolveExecutionIdentity,
413
+ getProcessExecutionIdentity,
414
+ getProcessIncarnation,
373
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)
374
426
  }
375
- await recoverFileSystemTransaction(identities[0].anchorPath)
376
427
  const currentCapability = fileSystemReconciliationCapability.getStore()
377
428
  const roots = new Set(currentCapability?.roots)
378
429
  roots.add(fileSystemReconciliationCapabilityRoot(identities[0].anchorPath))
@@ -563,13 +614,18 @@ export async function withPackageFileSystemReconciliation<T>(
563
614
  'boundary' | 'cwd' | 'packageRoot'
564
615
  >,
565
616
  operation: () => Promise<T>,
617
+ options?: FileSystemReconciliationOptions,
566
618
  ): Promise<T> {
567
619
  const roots = getPackageReconciliationRoots(paths)
568
620
  const acquire = (index: number): Promise<T> => {
569
621
  if (index === roots.length) {
570
622
  return operation()
571
623
  }
572
- return withFileSystemReconciliation(roots[index], () => acquire(index + 1))
624
+ return withFileSystemReconciliation(
625
+ roots[index],
626
+ () => acquire(index + 1),
627
+ options,
628
+ )
573
629
  }
574
630
 
575
631
  // Build takes the package lock before widening to its transaction root.
@@ -1569,16 +1625,25 @@ async function resolveReconciliationLockIdentities(
1569
1625
  if (!anchorStats.isDirectory()) {
1570
1626
  throw reconciliationAnchorError(anchorPath, 'is not a directory', 'ENOTDIR')
1571
1627
  }
1572
- const guardKeys = new Set<string>()
1628
+ const guardKeys = new Map<string, string | undefined>()
1573
1629
  if (await directoryIsWritable(dirname(anchorPath))) {
1574
- guardKeys.add(anchorPath)
1630
+ guardKeys.set(anchorPath, undefined)
1575
1631
  }
1576
1632
  let currentPath = requestedPath
1577
1633
  while (true) {
1578
1634
  const currentStats = await lstatIfExists(currentPath)
1579
1635
  if (currentStats?.isSymbolicLink()) {
1580
- if (await directoryIsWritable(dirname(currentPath))) {
1581
- 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)
1582
1647
  }
1583
1648
  }
1584
1649
  const parent = dirname(currentPath)
@@ -1589,8 +1654,8 @@ async function resolveReconciliationLockIdentities(
1589
1654
  }
1590
1655
 
1591
1656
  const pathIdentities = await Promise.all(
1592
- [...guardKeys].map(async (key) => {
1593
- const lockRootPath = await realpath(dirname(key))
1657
+ [...guardKeys].map(async ([key, carriedLockRoot]) => {
1658
+ const lockRootPath = carriedLockRoot ?? (await realpath(dirname(key)))
1594
1659
  const lockRootStats = await lstat(lockRootPath, { bigint: true })
1595
1660
  if (!lockRootStats.isDirectory()) {
1596
1661
  throw reconciliationPathCollisionError(
@@ -1786,11 +1851,14 @@ function reconciliationRetiredPath(path: string) {
1786
1851
  async function acquireReconciliationLock(
1787
1852
  identity: ReconciliationLockIdentity,
1788
1853
  acquisitionDeadline: ReconciliationLockDeadline,
1854
+ resolveExecutionIdentity: () => Promise<ProcessExecutionIdentityResolution>,
1855
+ getProcessExecutionIdentity: () => Promise<ProcessExecutionIdentity>,
1856
+ getProcessIncarnation: () => Promise<string | null>,
1789
1857
  ) {
1790
1858
  const { key } = identity
1791
- const [incarnation, executionIdentity] = await Promise.all([
1792
- getCurrentProcessIncarnation(),
1793
- getCurrentProcessExecutionIdentity(),
1859
+ const [incarnation, identityResolution] = await Promise.all([
1860
+ getProcessIncarnation(),
1861
+ resolveExecutionIdentity(),
1794
1862
  ])
1795
1863
  const lockPath = reconciliationLockPath(identity)
1796
1864
  await assertReconciliationAnchorUnchanged(identity)
@@ -1807,6 +1875,53 @@ async function acquireReconciliationLock(
1807
1875
  isReconciliationReclaimOwner,
1808
1876
  )
1809
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
+
1810
1925
  while (true) {
1811
1926
  assertReconciliationLockAcquisitionTimeRemaining(acquisitionDeadline, key)
1812
1927
  await assertReconciliationAnchorUnchanged(identity)
@@ -1856,7 +1971,14 @@ async function acquireReconciliationLock(
1856
1971
  if (
1857
1972
  state?.owner &&
1858
1973
  state.stale &&
1859
- (await tryReclaimStaleReconciliationLock(identity, lockPath, state))
1974
+ (await tryReclaimStaleReconciliationLock(
1975
+ identity,
1976
+ lockPath,
1977
+ state,
1978
+ getProcessExecutionIdentity,
1979
+ getProcessIncarnation,
1980
+ acquisitionDeadline,
1981
+ ))
1860
1982
  ) {
1861
1983
  continue
1862
1984
  }
@@ -1878,7 +2000,15 @@ async function acquireReconciliationLock(
1878
2000
  `Lost filesystem reconciliation lock ownership before initialization: ${key}`,
1879
2001
  )
1880
2002
  }
1881
- return maintainReconciliationLock(identity, lockPath, lockStats, token)
2003
+ return {
2004
+ release: maintainReconciliationLock(
2005
+ identity,
2006
+ lockPath,
2007
+ lockStats,
2008
+ token,
2009
+ ),
2010
+ degraded: false,
2011
+ }
1882
2012
  } catch (error) {
1883
2013
  await cleanupFailedReconciliationLock(
1884
2014
  identity,
@@ -2387,13 +2517,26 @@ async function tryReclaimStaleReconciliationLock(
2387
2517
  identity: ReconciliationLockIdentity,
2388
2518
  lockPath: string,
2389
2519
  expectedState: ReconciliationLockState,
2520
+ getProcessExecutionIdentity: () => Promise<ProcessExecutionIdentity>,
2521
+ getProcessIncarnation: () => Promise<string | null>,
2522
+ acquisitionDeadline: ReconciliationLockDeadline,
2390
2523
  ) {
2391
2524
  const reclaimPath = reconciliationReclaimPath(identity)
2392
2525
  const token = randomUUID()
2393
- const [incarnation, executionIdentity] = await Promise.all([
2394
- getCurrentProcessIncarnation(),
2395
- getCurrentProcessExecutionIdentity(),
2526
+ const [incarnation, identityResolution] = await Promise.all([
2527
+ getProcessIncarnation(),
2528
+ resolveProcessExecutionIdentityForLocking(
2529
+ getProcessExecutionIdentity,
2530
+ acquisitionDeadline,
2531
+ ),
2396
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
2397
2540
  const reclaimOwner: ReconciliationReclaimOwner = {
2398
2541
  candidate: reconciliationCandidateName(reclaimPath, token),
2399
2542
  createdAt: Date.now(),
@@ -3133,6 +3276,32 @@ function reconciliationHardLinkRequiredError(path: string, cause: unknown) {
3133
3276
  return error
3134
3277
  }
3135
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
+
3136
3305
  function reconciliationOwnerCannotBeVerifiedError(
3137
3306
  path: string,
3138
3307
  owner: ReconciliationMetadataOwner,
@@ -3337,34 +3506,59 @@ function processExists(pid: number) {
3337
3506
  }
3338
3507
  }
3339
3508
 
3340
- function getCurrentProcessIncarnation() {
3341
- if (currentProcessIncarnation !== undefined) {
3342
- return Promise.resolve(currentProcessIncarnation)
3343
- }
3344
- if (currentProcessIncarnationProbe !== undefined) {
3345
- 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
3346
3554
  }
3347
-
3348
- const probe = readProcessIncarnation(process.pid)
3349
- currentProcessIncarnationProbe = probe
3350
- void probe.then(
3351
- (incarnation) => {
3352
- if (incarnation !== null) {
3353
- currentProcessIncarnation = incarnation
3354
- }
3355
- if (currentProcessIncarnationProbe === probe) {
3356
- currentProcessIncarnationProbe = undefined
3357
- }
3358
- },
3359
- () => {
3360
- if (currentProcessIncarnationProbe === probe) {
3361
- currentProcessIncarnationProbe = undefined
3362
- }
3363
- },
3364
- )
3365
- return probe
3366
3555
  }
3367
3556
 
3557
+ const getCurrentProcessIncarnation = createProcessIncarnationGetter(
3558
+ () => readProcessIncarnation(process.pid),
3559
+ processIncarnationProbeRetryCooldown,
3560
+ )
3561
+
3368
3562
  export function createProcessExecutionIdentityGetter(
3369
3563
  readIdentity: () => Promise<ProcessExecutionIdentity>,
3370
3564
  incompleteCacheDuration: number,
@@ -3442,11 +3636,74 @@ function isCompleteProcessExecutionIdentity(
3442
3636
  )
3443
3637
  }
3444
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
+
3445
3670
  const getCurrentProcessExecutionIdentity = createProcessExecutionIdentityGetter(
3446
3671
  readProcessExecutionIdentity,
3447
3672
  incompleteProcessExecutionIdentityCacheDuration,
3448
3673
  )
3449
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
+
3450
3707
  async function readProcessExecutionIdentity(): Promise<ProcessExecutionIdentity> {
3451
3708
  if (process.platform === 'linux') {
3452
3709
  const [bootId, machineId, productUuid, pidNamespace] = await Promise.all([
@@ -3483,19 +3740,29 @@ async function readProcessExecutionIdentity(): Promise<ProcessExecutionIdentity>
3483
3740
  )
3484
3741
  : 'powershell.exe'
3485
3742
  const [machineOutput, boot] = await Promise.all([
3486
- executeProcessIncarnationCommand(reg, [
3487
- 'query',
3488
- String.raw`HKLM\SOFTWARE\Microsoft\Cryptography`,
3489
- '/v',
3490
- 'MachineGuid',
3491
- ]),
3492
- executeProcessIncarnationCommand(powershell, [
3493
- '-NoLogo',
3494
- '-NoProfile',
3495
- '-NonInteractive',
3496
- '-Command',
3497
- '(Get-CimInstance Win32_OperatingSystem).LastBootUpTime.ToUniversalTime().Ticks',
3498
- ]),
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
+ ),
3499
3766
  ])
3500
3767
  const machineGuid = machineOutput?.match(
3501
3768
  /\bMachineGuid\s+REG_SZ\s+([0-9a-f-]+)\s*$/i,
@@ -3512,19 +3779,24 @@ async function readProcessExecutionIdentity(): Promise<ProcessExecutionIdentity>
3512
3779
  if (process.platform === 'darwin') {
3513
3780
  const [machineOutput, bootSessionOutput, bootTimeOutput] =
3514
3781
  await Promise.all([
3515
- executeProcessIncarnationCommand('/usr/sbin/ioreg', [
3516
- '-rd1',
3517
- '-c',
3518
- 'IOPlatformExpertDevice',
3519
- ]),
3520
- executeProcessIncarnationCommand('/usr/sbin/sysctl', [
3521
- '-n',
3522
- 'kern.bootsessionuuid',
3523
- ]),
3524
- executeProcessIncarnationCommand('/usr/sbin/sysctl', [
3525
- '-n',
3526
- 'kern.boottime',
3527
- ]),
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
+ ),
3528
3800
  ])
3529
3801
  const platformUuid = machineOutput?.match(
3530
3802
  /"IOPlatformUUID"\s*=\s*"([0-9a-f-]+)"/i,
@@ -3687,6 +3959,7 @@ function executeProcessIncarnationCommand(
3687
3959
  command: string,
3688
3960
  args: string[],
3689
3961
  env: NodeJS.ProcessEnv = process.env,
3962
+ timeout: number = processIncarnationCommandTimeout,
3690
3963
  ): Promise<string | null> {
3691
3964
  return new Promise((resolveCommand) => {
3692
3965
  execFile(
@@ -3695,7 +3968,7 @@ function executeProcessIncarnationCommand(
3695
3968
  {
3696
3969
  encoding: 'utf8',
3697
3970
  env,
3698
- timeout: processIncarnationCommandTimeout,
3971
+ timeout,
3699
3972
  windowsHide: true,
3700
3973
  },
3701
3974
  (error, stdout) => {