@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.
- package/README.md +17 -4
- package/dist/cli.js +10359 -8729
- package/dist/index.cjs +10381 -8751
- package/dist/index.d.cts +83 -16
- package/dist/index.d.ts +83 -16
- package/dist/index.js +10359 -8729
- package/docs/wasi.md +318 -4
- package/package.json +5 -6
- package/src/api/__tests__/__snapshots__/templates.spec.ts.md +4003 -53
- package/src/api/__tests__/__snapshots__/templates.spec.ts.snap +0 -0
- package/src/api/__tests__/build-regressions.spec.ts +210 -3
- package/src/api/__tests__/build.spec.ts +2333 -3
- package/src/api/__tests__/create-npm-dirs.spec.ts +105 -3
- package/src/api/__tests__/pre-publish.spec.ts +153 -1
- package/src/api/__tests__/templates.spec.ts +1309 -1
- package/src/api/build.ts +756 -30
- package/src/api/create-npm-dirs.ts +23 -10
- package/src/api/new.ts +13 -18
- package/src/api/pre-publish.ts +34 -11
- package/src/api/rename.ts +10 -21
- package/src/api/templates/binding-target.ts +176 -0
- package/src/api/templates/index.ts +1 -0
- package/src/api/templates/js-binding.ts +54 -10
- package/src/api/templates/load-wasi-template.ts +661 -58
- package/src/api/templates/wasi-worker-template.ts +36 -31
- package/src/commands/build.ts +1 -1
- package/src/utils/__tests__/__snapshots__/typegen.spec.ts.md +18 -28
- package/src/utils/__tests__/__snapshots__/typegen.spec.ts.snap +0 -0
- package/src/utils/__tests__/misc.spec.ts +4 -0
- package/src/utils/__tests__/reconciliation.spec.ts +676 -0
- package/src/utils/__tests__/serialize.spec.ts +55 -0
- package/src/utils/__tests__/target.spec.ts +221 -0
- package/src/utils/__tests__/typegen.spec.ts +115 -0
- package/src/utils/config.ts +50 -0
- package/src/utils/index.ts +1 -0
- package/src/utils/misc.ts +351 -79
- package/src/utils/serialize.ts +47 -0
- package/src/utils/target.ts +150 -1
- 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
|
-
|
|
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
|
-
|
|
370
|
-
|
|
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(
|
|
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
|
|
1628
|
+
const guardKeys = new Map<string, string | undefined>()
|
|
1571
1629
|
if (await directoryIsWritable(dirname(anchorPath))) {
|
|
1572
|
-
guardKeys.
|
|
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
|
-
|
|
1579
|
-
|
|
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,
|
|
1790
|
-
|
|
1791
|
-
|
|
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(
|
|
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
|
|
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,
|
|
2392
|
-
|
|
2393
|
-
|
|
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
|
|
3339
|
-
|
|
3340
|
-
|
|
3341
|
-
|
|
3342
|
-
|
|
3343
|
-
|
|
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(
|
|
3485
|
-
|
|
3486
|
-
|
|
3487
|
-
|
|
3488
|
-
|
|
3489
|
-
|
|
3490
|
-
|
|
3491
|
-
|
|
3492
|
-
|
|
3493
|
-
|
|
3494
|
-
|
|
3495
|
-
|
|
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(
|
|
3514
|
-
'
|
|
3515
|
-
'-c',
|
|
3516
|
-
|
|
3517
|
-
|
|
3518
|
-
|
|
3519
|
-
|
|
3520
|
-
'
|
|
3521
|
-
|
|
3522
|
-
|
|
3523
|
-
|
|
3524
|
-
|
|
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
|
|
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
|
+
}
|