UNPKG

@napi-rs/cli

Version:

Cli tools for napi-rs

1,689 lines (1,611 loc) 183 kB
import { execFile } from 'node:child_process' import { AsyncLocalStorage } from 'node:async_hooks' import { readFile, writeFile, unlink, copyFile, mkdir, stat, readdir, access, chmod, rename, rm, realpath, lstat, link, open, readlink, type FileHandle, } from 'node:fs/promises' import { constants, existsSync, readFileSync, realpathSync, type BigIntStats, type Stats, } from 'node:fs' import { createHash, randomUUID } from 'node:crypto' import { basename, dirname, extname, isAbsolute, join, relative, resolve, sep, } from 'node:path' import { performance } from 'node:perf_hooks' import { setTimeout as scheduleTimeout } from 'node:timers' import { setTimeout as delay } from 'node:timers/promises' import pkgJson from '../../package.json' with { type: 'json' } import { debug } from './log.js' export const readFileAsync = readFile export const writeFileAsync = writeFile export const unlinkAsync = unlink export const copyFileAsync = copyFile export const mkdirAsync = mkdir export const statAsync = stat export const readdirAsync = readdir const reconciliationTails = new Map<string, Promise<void>>() const reconciliationLockName = '.napi-rs-filesystem-reconciliation' const reconciliationReclaimMarker = '.reclaim.' const reconciliationCandidateMarker = '.candidate.' const reconciliationRetiredMarker = '.retired.' const reconciliationMetadataExtension = '.swp' const reconciliationLockKind = 'napi-rs-filesystem-reconciliation-lock' const reconciliationReclaimKind = 'napi-rs-filesystem-reconciliation-reclaim' const reconciliationStateVersion = 1 const reconciliationLockAcquisitionTimeout = 120_000 const reconciliationLockCleanupTimeout = 5_000 const reconciliationLockCleanupRetryInterval = 250 const reconciliationMetadataMaximumSize = 64 * 1024 const processIncarnationCommandTimeout = 2_000 const incompleteProcessExecutionIdentityCacheDuration = processIncarnationCommandTimeout const processIncarnationObservationCacheDuration = 1_000 const fileSystemTransactionJournalName = '.napi-rs-filesystem-transaction.swp' const fileSystemTransactionCandidateMarker = '.candidate.' const fileSystemTransactionRetiredMarker = '.retired.' const fileSystemTransactionOwnerName = 'owner.json' const fileSystemTransactionStateName = 'state.json' const fileSystemTransactionKind = 'napi-rs-filesystem-transaction' const legacyFileSystemTransactionStateVersion = 1 const previousFileSystemTransactionStateVersion = 2 const fileSystemTransactionStateVersion = 3 const fileSystemTransactionStateMaximumSize = 16 * 1024 * 1024 const fileSystemTransactionMaximumEntries = 100_000 const fileSystemTransactionCleanupTimeout = 5_000 const fileSystemTransactionCleanupInitialRetryDelay = 10 const fileSystemTransactionCleanupMaximumRetryDelay = 250 interface ReconciliationLockOwner { candidate: string createdAt: number boot?: string | null bootSession?: string | null incarnation?: string | null key: string kind: typeof reconciliationLockKind machine?: string | null namespace?: string | null pid: number token: string version: typeof reconciliationStateVersion } interface ReconciliationReclaimOwner { candidate: string createdAt: number boot?: string | null bootSession?: string | null incarnation?: string | null key: string kind: typeof reconciliationReclaimKind machine?: string | null namespace?: string | null pid: number token: string version: typeof reconciliationStateVersion } interface ReconciliationLockState { lockStats: BigIntStats ownerContent?: string owner?: ReconciliationLockOwner stale: boolean unverifiableReason?: string } interface ReconciliationReclaimState { owner: ReconciliationReclaimOwner ownerContent: string reclaimStats: BigIntStats stale: boolean unverifiableReason?: string } type ReconciliationMetadataOwner = ReconciliationLockOwner | ReconciliationReclaimOwner interface ReconciliationCandidateState { owner: ReconciliationMetadataOwner ownerContent: string stats: BigIntStats stale: boolean unverifiableReason?: string } interface ProcessIncarnationObservation { expiresAt: number incarnation: string | null } interface ProcessExecutionIdentity { boot: string | null bootSession: string | null machine: string | null namespace: string | null } interface ProcessOwnerState { stale: boolean unverifiableReason?: string } interface ReconciliationLockDeadline { expiresAt: number timeout: number } interface ReconciliationLockIdentity { anchorPath: string // 64-bit filesystem identifiers are captured from bigint stats so anchors // whose dev/ino exceed Number.MAX_SAFE_INTEGER (Windows NTFS file IDs and // volume serials) never collide with a distinct filesystem object. dev: bigint ino: bigint key: string lockRootDev: bigint lockRootIno: bigint lockRootPath: string reportTopologyChange: boolean requestedPath: string } const processIncarnationObservations = new Map< number, ProcessIncarnationObservation >() let currentProcessIncarnation: string | undefined let currentProcessIncarnationProbe: Promise<string | null> | undefined let linuxBootId: string | undefined interface TransactionParentIdentity { canonicalParent: string // 64-bit filesystem identifiers are captured from a bigint stat() and stored // as decimal strings so values above Number.MAX_SAFE_INTEGER (common for // Windows NTFS file references and volume serials) round-trip losslessly. dev: string identityPath: string ino: string } interface FileSystemTransactionJournalParent { canonicalParent: string dev: string identityPath: string ino: string } interface FileSystemTransactionJournalFileState { dev?: string hash: string ino?: string mode: number } interface FileSystemTransactionFileIdentity { dev: string ino: string } interface FileSystemTransactionJournalEntry { backup?: string final?: FileSystemTransactionJournalFileState original?: FileSystemTransactionJournalFileState parent: FileSystemTransactionJournalParent path: string prepared?: string retired?: string rollbackRetired?: string } interface FileSystemTransactionJournal { entries: FileSystemTransactionJournalEntry[] phase: 'committed' | 'prepared' | 'preparing' token: string version: | typeof legacyFileSystemTransactionStateVersion | typeof previousFileSystemTransactionStateVersion | typeof fileSystemTransactionStateVersion } interface FileSystemTransactionJournalOwner { kind: typeof fileSystemTransactionKind token: string version: | typeof legacyFileSystemTransactionStateVersion | typeof previousFileSystemTransactionStateVersion | typeof fileSystemTransactionStateVersion } interface FileSystemReconciliationCapability { roots: ReadonlySet<string> } interface PreparedFileSystemTransactionWrite { destination: string final: FileSystemTransactionJournalFileState input: string prepared?: string removeBeforeWrite?: string } interface OpenFileSystemTransactionIdentity { handle: FileHandle state: FileSystemTransactionJournalFileState } export interface FileSystemTransactionWrite { destination: string mode?: number removeBeforeWrite?: string source: string } const fileSystemReconciliationCapability = new AsyncLocalStorage<FileSystemReconciliationCapability>() export async function writeFileAtomic( path: string, data: Parameters<typeof writeFile>[1], options?: Parameters<typeof writeFile>[2], ) { await mkdir(dirname(path), { recursive: true }) while (true) { const temporaryPath = atomicTemporaryPath(path) const exclusiveOptions = typeof options === 'string' ? { encoding: options, flag: 'wx' as const } : { ...options, flag: 'wx' as const } try { await writeFile(temporaryPath, data, exclusiveOptions) } catch (error) { if ((error as NodeJS.ErrnoException).code === 'EEXIST') { continue } throw error } let committed = false try { await syncFile(temporaryPath) await rename(temporaryPath, path) await syncDirectory(dirname(path)) committed = true return } finally { if (!committed) { await unlinkFileIfExists(temporaryPath) } } } } export async function copyFileAtomic( source: string, destination: string, mode?: number, ) { await mkdir(dirname(destination), { recursive: true }) while (true) { const temporaryPath = atomicTemporaryPath(destination) try { await copyFile(source, temporaryPath, constants.COPYFILE_EXCL) } catch (error) { if ((error as NodeJS.ErrnoException).code === 'EEXIST') { continue } throw error } let committed = false try { if (mode !== undefined) { await chmod(temporaryPath, mode) } await syncFile(temporaryPath) await rename(temporaryPath, destination) await syncDirectory(dirname(destination)) committed = true return } finally { if (!committed) { await unlinkFileIfExists(temporaryPath) } } } } export async function withFileSystemReconciliation<T>( path: string, operation: () => Promise<T>, ): Promise<T> { const localKey = resolve(path) const previous = reconciliationTails.get(localKey) ?? Promise.resolve() let release!: () => void const current = new Promise<void>((resolveCurrent) => { release = resolveCurrent }) const tail = previous.catch(() => {}).then(() => current) reconciliationTails.set(localKey, tail) const releaseCrossProcessLocks: Array<() => Promise<void>> = [] let operationFailed = false let operationError: unknown let result!: T try { await previous.catch(() => {}) const identities = await resolveReconciliationLockIdentities(path) const acquisitionDeadline = createReconciliationLockDeadline( reconciliationLockAcquisitionTimeout, ) for (const identity of identities) { releaseCrossProcessLocks.push( await acquireReconciliationLock(identity, acquisitionDeadline), ) } await recoverFileSystemTransaction(identities[0].anchorPath) const currentCapability = fileSystemReconciliationCapability.getStore() const roots = new Set(currentCapability?.roots) roots.add(fileSystemReconciliationCapabilityRoot(identities[0].anchorPath)) result = await fileSystemReconciliationCapability.run({ roots }, operation) } catch (error) { operationFailed = true operationError = error } const releaseErrors: unknown[] = [] for (let index = releaseCrossProcessLocks.length - 1; index >= 0; index--) { try { await releaseCrossProcessLocks[index]() } catch (error) { releaseErrors.push(error) } } release() if (reconciliationTails.get(localKey) === tail) { reconciliationTails.delete(localKey) } if (operationFailed) { if (releaseErrors.length > 0) { throw new AggregateError( [operationError, ...releaseErrors], 'Filesystem reconciliation operation and lock release both failed', { cause: operationError }, ) } throw operationError } if (releaseErrors.length === 1) { throw releaseErrors[0] } if (releaseErrors.length > 1) { throw new AggregateError( releaseErrors, 'Multiple filesystem reconciliation locks could not be released', { cause: releaseErrors[0] }, ) } return result } function fileSystemReconciliationCapabilityRoot(path: string) { const resolvedPath = resolve(path) return process.platform === 'win32' ? resolvedPath.toLowerCase() : resolvedPath } export function getPackageReconciliationRoot( cwd: string, packageJsonPath = 'package.json', ) { return dirname(resolve(cwd, packageJsonPath)) } export function resolvePackageReconciliationPaths( cwd: string, packageJsonPath = 'package.json', managedPaths: string[] = [], ) { const canonicalCwd = canonicalizeManagedPackagePath(cwd) const requestedPackageJsonPath = resolve(cwd, packageJsonPath) const canonicalPackageJsonPath = canonicalizeManagedPackagePath( requestedPackageJsonPath, ) const canonicalPackageRoot = canonicalizeManagedPackagePath( dirname(requestedPackageJsonPath), ) const canonicalManagedPaths = managedPaths.map((path) => canonicalizeManagedPackagePath(resolve(cwd, path)), ) if ( !managedPackagePathIsWithin(canonicalPackageRoot, canonicalPackageJsonPath) ) { throw new Error( `Package manifest escapes its package root: ${requestedPackageJsonPath}`, ) } const packageRootAndCwdAreRelated = managedPackagePathIsWithin(canonicalPackageRoot, canonicalCwd) || managedPackagePathIsWithin(canonicalCwd, canonicalPackageRoot) if (!packageRootAndCwdAreRelated) { throw managedPackageBoundaryError( canonicalCwd, canonicalPackageRoot, canonicalManagedPaths, ) } const discoveryBoundary = managedPackagePathIsWithin( canonicalCwd, canonicalPackageRoot, ) ? canonicalCwd : canonicalPackageRoot let candidate = canonicalPackageRoot while (true) { if ( hasPackageWorkspaceBoundaryMarker(candidate) && canonicalManagedPaths.every((path) => managedPackagePathIsWithin(candidate, path), ) ) { return { boundary: candidate, cwd: canonicalCwd, managedPaths: canonicalManagedPaths, packageJsonPath: canonicalPackageJsonPath, packageRoot: canonicalPackageRoot, } } if (candidate === discoveryBoundary) { break } const parent = dirname(candidate) if ( parent === candidate || !managedPackagePathIsWithin(discoveryBoundary, parent) ) { break } candidate = parent } if ( dirname(canonicalPackageRoot) !== canonicalPackageRoot && canonicalManagedPaths.every((path) => managedPackagePathIsWithin(canonicalPackageRoot, path), ) ) { return { boundary: canonicalPackageRoot, cwd: canonicalCwd, managedPaths: canonicalManagedPaths, packageJsonPath: canonicalPackageJsonPath, packageRoot: canonicalPackageRoot, } } throw managedPackageBoundaryError( canonicalCwd, canonicalPackageRoot, canonicalManagedPaths, ) } export function getPackageReconciliationRoots({ boundary, cwd, packageRoot, }: Pick< ReturnType<typeof resolvePackageReconciliationPaths>, 'boundary' | 'cwd' | 'packageRoot' >) { const roots: string[] = [] const rootIdentities = new Set<string>() for (const root of [packageRoot, boundary, cwd]) { const resolvedRoot = resolve(root) const identity = fileSystemReconciliationCapabilityRoot(resolvedRoot) if (rootIdentities.has(identity)) { continue } if ( roots.some( (existingRoot) => !managedPackagePathIsWithin(existingRoot, resolvedRoot) && !managedPackagePathIsWithin(resolvedRoot, existingRoot), ) ) { throw new Error( `Package reconciliation roots must be nested: ${roots.join(', ')}, ${resolvedRoot}`, ) } rootIdentities.add(identity) roots.push(resolvedRoot) } return roots } export async function withPackageFileSystemReconciliation<T>( paths: Pick< ReturnType<typeof resolvePackageReconciliationPaths>, 'boundary' | 'cwd' | 'packageRoot' >, operation: () => Promise<T>, ): Promise<T> { const roots = getPackageReconciliationRoots(paths) const acquire = (index: number): Promise<T> => { if (index === roots.length) { return operation() } return withFileSystemReconciliation(roots[index], () => acquire(index + 1)) } // Build takes the package lock before widening to its transaction root. // Preserve that semantic order even when cwd is below the package root. return acquire(0) } function managedPackagePathIsWithin(root: string, path: string) { const relativePath = relative(resolve(root), resolve(path)) return ( relativePath === '' || (!isAbsolute(relativePath) && relativePath !== '..' && !relativePath.startsWith(`..${sep}`)) ) } function canonicalizeManagedPackagePath(path: string) { let current = resolve(path) const missingSegments: string[] = [] while (true) { try { return join(realpathSync.native(current), ...missingSegments) } catch (error) { if ((error as NodeJS.ErrnoException).code !== 'ENOENT') { throw error } const parent = dirname(current) if (parent === current) { return join(current, ...missingSegments) } missingSegments.unshift(basename(current)) current = parent } } } function hasPackageWorkspaceBoundaryMarker(directory: string) { if (existsSync(join(directory, 'pnpm-workspace.yaml'))) { return true } const manifestPath = join(directory, 'package.json') if (!existsSync(manifestPath)) { return false } try { const manifest = JSON.parse(readFileSync(manifestPath, 'utf8')) as { workspaces?: unknown } return ( Array.isArray(manifest.workspaces) || (typeof manifest.workspaces === 'object' && manifest.workspaces !== null && Array.isArray((manifest.workspaces as { packages?: unknown }).packages)) ) } catch { return false } } function managedPackageBoundaryError( cwd: string, packageRoot: string, managedPaths: string[], ) { return new Error( `Managed package paths must stay within the project or workspace boundary discovered from ${cwd}: ${[ packageRoot, ...managedPaths, ].join(', ')}`, ) } export async function commitFileSystemTransaction( root: string, writes: FileSystemTransactionWrite[], removals: string[], ) { const requestedTransactionRoot = resolve(root) for (const path of [ ...writes.flatMap(({ destination, removeBeforeWrite }) => removeBeforeWrite ? [destination, removeBeforeWrite] : [destination], ), ...removals, ]) { assertFileSystemTransactionPathIsNotReserved( requestedTransactionRoot, resolveTransactionPath(requestedTransactionRoot, path), ) } const capability = fileSystemReconciliationCapability.getStore() if (capability) { const transactionRoot = await canonicalizeReconciliationPath( requestedTransactionRoot, ) if ( capability.roots.has( fileSystemReconciliationCapabilityRoot(transactionRoot), ) ) { return commitFileSystemTransactionUnlocked( requestedTransactionRoot, transactionRoot, writes, removals, ) } } return withFileSystemReconciliation(requestedTransactionRoot, async () => { const transactionRoot = await canonicalizeReconciliationPath( requestedTransactionRoot, ) await commitFileSystemTransactionUnlocked( requestedTransactionRoot, transactionRoot, writes, removals, ) }) } async function commitFileSystemTransactionUnlocked( requestedTransactionRoot: string, transactionRoot: string, writes: FileSystemTransactionWrite[], removals: string[], ) { const transactionWrites = await Promise.all( writes.map(async (write) => { const destination = await resolveCanonicalTransactionPath( requestedTransactionRoot, transactionRoot, write.destination, ) await mkdir(dirname(destination), { recursive: true }) const stableDestination = await resolveCanonicalTransactionPath( requestedTransactionRoot, transactionRoot, write.destination, ) if ( !pathsHaveEquivalentPlatformSpelling(destination, stableDestination) ) { throw new Error( `Filesystem transaction destination parent changed while it was created: ${write.destination}`, ) } return { destination: stableDestination, mode: write.mode, removeBeforeWrite: write.removeBeforeWrite ? await resolveCanonicalTransactionPath( requestedTransactionRoot, transactionRoot, write.removeBeforeWrite, ) : undefined, source: write.source, } }), ) const destinationKeys = new Set<string>() for (const { destination } of transactionWrites) { const destinationKey = fileSystemReconciliationCapabilityRoot(destination) if (destinationKeys.has(destinationKey)) { throw new Error( `Duplicate canonical filesystem transaction destination: ${destination}`, ) } destinationKeys.add(destinationKey) } const writesByDestination = new Map( transactionWrites.map((write) => [write.destination, write]), ) const resolvedRemovals = await Promise.all( removals.map((path) => resolveCanonicalTransactionPath( requestedTransactionRoot, transactionRoot, path, ), ), ) const preWriteRemovals = new Set( transactionWrites.flatMap(({ removeBeforeWrite }) => removeBeforeWrite ? [removeBeforeWrite] : [], ), ) const affected = new Set([ ...writesByDestination.keys(), ...resolvedRemovals, ...preWriteRemovals, ]) const journalRoot = fileSystemTransactionJournalPath(transactionRoot) for (const path of affected) { assertFileSystemTransactionPathIsNotReserved(transactionRoot, path) } // Capture 64-bit identity so the source preflight below can detect an inode // replacement even when dev/ino exceed Number.MAX_SAFE_INTEGER on Windows. const affectedStats = new Map<string, BigIntStats | undefined>() for (const path of affected) { const stats = await lstatIfExists(path, { bigint: true }) if (stats && !stats.isFile()) { throw new Error( `Filesystem transaction path is not a regular file: ${path}`, ) } affectedStats.set(path, stats) } const replacementAliasParents = new Map<string, string>() const findReplacementAlias = (path: string): string => { const parent = replacementAliasParents.get(path) if (parent === undefined) { replacementAliasParents.set(path, path) return path } if (parent === path) { return path } const root = findReplacementAlias(parent) replacementAliasParents.set(path, root) return root } const joinReplacementAliases = (left: string, right: string) => { const leftRoot = findReplacementAlias(left) const rightRoot = findReplacementAlias(right) if (leftRoot !== rightRoot) { replacementAliasParents.set(rightRoot, leftRoot) } } for (const { destination, removeBeforeWrite } of transactionWrites) { if (!removeBeforeWrite) { continue } const replacementStats = affectedStats.get(removeBeforeWrite) if (!replacementStats) { throw new Error( `Filesystem transaction replacement source does not exist: ${removeBeforeWrite}`, ) } const destinationStats = affectedStats.get(destination) if (destinationStats) { if ( !(await pathsReferToSameDirectoryEntry( removeBeforeWrite, destination, replacementStats, destinationStats, )) ) { throw new Error( `Filesystem transaction replacement destination is occupied: ${destination}`, ) } joinReplacementAliases(removeBeforeWrite, destination) } } const replacementAliasBackups = new Map<string, string>() for (const { removeBeforeWrite } of transactionWrites) { if (!removeBeforeWrite || !replacementAliasParents.has(removeBeforeWrite)) { continue } const root = findReplacementAlias(removeBeforeWrite) if (!replacementAliasBackups.has(root)) { replacementAliasBackups.set(root, removeBeforeWrite) } } const replacementBackupPaths = new Set(replacementAliasBackups.values()) const parentIdentities = new Map<string, TransactionParentIdentity>() for (const path of affected) { const parent = dirname(path) if (!parentIdentities.has(parent)) { parentIdentities.set( parent, await captureTransactionParentIdentity(transactionRoot, parent), ) } } const transactionState = await createFileSystemTransactionCandidate(transactionRoot) const { path: candidateRoot, stats: candidateStats, token: transactionToken, } = transactionState const candidateBackupRoot = join(candidateRoot, 'backups') const candidateInputRoot = join(candidateRoot, 'inputs') const publishedBackupRoot = join(journalRoot, 'backups') const backups = new Map< string, { path: string state: FileSystemTransactionJournalFileState } >() let preserveJournalRoot = false let journal: FileSystemTransactionJournal | undefined let committed = false let published = false let transactionError: unknown try { await writeFileAtomic( join(candidateRoot, fileSystemTransactionOwnerName), JSON.stringify({ kind: fileSystemTransactionKind, token: transactionToken, version: fileSystemTransactionStateVersion, } satisfies FileSystemTransactionJournalOwner), { mode: 0o644 }, ) await syncDirectory(transactionRoot) await Promise.all([mkdir(candidateBackupRoot), mkdir(candidateInputRoot)]) const preparedWrites: PreparedFileSystemTransactionWrite[] = [] const snapshots = new Map< string, { final: FileSystemTransactionJournalFileState input: string } >() for (let index = 0; index < transactionWrites.length; index++) { const write = transactionWrites[index] let snapshot = snapshots.get(write.source) if (!snapshot) { const inputName = String(snapshots.size) const inputPath = join(candidateInputRoot, inputName) const sourceState = await snapshotFileSystemTransactionInput( write.source, inputPath, ) snapshot = { final: fileSystemTransactionFileContents(sourceState), input: join('inputs', inputName), } snapshots.set(write.source, snapshot) } preparedWrites.push({ destination: write.destination, final: { hash: snapshot.final.hash, mode: write.mode ?? snapshot.final.mode, }, input: snapshot.input, removeBeforeWrite: write.removeBeforeWrite, }) } for (let index = 0; index < preparedWrites.length; index++) { const write = preparedWrites[index] const prepared = fileSystemTransactionArtifactPath( write.destination, transactionToken, index, 'prepared', ) if ((await lstatIfExists(prepared)) !== undefined) { throw new Error( `Filesystem transaction artifact path already exists for ${write.destination}`, ) } write.prepared = prepared } const artifactPaths = new Map< string, { retired: string rollbackRetired: string } >() let artifactIndex = 0 for (const path of affected) { const retired = fileSystemTransactionArtifactPath( path, transactionToken, artifactIndex, 'retired', ) const rollbackRetired = fileSystemTransactionArtifactPath( path, transactionToken, artifactIndex, 'rollback', ) artifactIndex += 1 if ( (await lstatIfExists(retired)) !== undefined || (await lstatIfExists(rollbackRetired)) !== undefined ) { throw new Error( `Filesystem transaction artifact path already exists for ${path}`, ) } artifactPaths.set(path, { retired, rollbackRetired }) } const journalParentForPath = ( path: string, ): FileSystemTransactionJournalParent => { const parentIdentity = parentIdentities.get(dirname(path)) if (!parentIdentity) { throw new Error( `Filesystem transaction parent metadata was not captured for ${path}`, ) } return { canonicalParent: fileSystemTransactionRelativePath( transactionRoot, parentIdentity.canonicalParent, 'Transaction canonical parent', true, ), dev: parentIdentity.dev, identityPath: fileSystemTransactionRelativePath( transactionRoot, parentIdentity.identityPath, 'Transaction parent identity', true, ), ino: parentIdentity.ino, } } const preparingJournal: FileSystemTransactionJournal = { entries: preparedWrites.map((write) => ({ final: write.final, parent: journalParentForPath(write.destination), path: fileSystemTransactionRelativePath( transactionRoot, write.destination, 'Transaction path', ), prepared: fileSystemTransactionRelativePath( transactionRoot, write.prepared!, 'Transaction prepared replacement', ), })), phase: 'preparing', token: transactionToken, version: fileSystemTransactionStateVersion, } await writeFileSystemTransactionJournal(candidateRoot, preparingJournal) journal = preparingJournal // Release every prepared-file writer only after one atomic journal update // records all created inodes. A crash before that checkpoint is ambiguous. let preparedIdentityCount = 0 let preparedIdentityPublicationClosed = false let resolvePreparedIdentityPublication!: () => void let rejectPreparedIdentityPublication!: (reason?: unknown) => void const preparedIdentityPublication = new Promise<void>((resolve, reject) => { resolvePreparedIdentityPublication = resolve rejectPreparedIdentityPublication = reject }) void preparedIdentityPublication.catch(() => {}) const failPreparedIdentityPublication = (error: unknown) => { if (!preparedIdentityPublicationClosed) { preparedIdentityPublicationClosed = true rejectPreparedIdentityPublication(error) } } const recordPreparedIdentity = ( index: number, identity: FileSystemTransactionFileIdentity, ) => { if (!preparedIdentityPublicationClosed) { const entry = preparingJournal.entries[index] if (!entry?.final) { failPreparedIdentityPublication( new Error( `Filesystem transaction preparing metadata was not captured for ${preparedWrites[index]?.destination}`, ), ) } else { entry.final = { ...entry.final, ...identity } preparedIdentityCount += 1 if (preparedIdentityCount === preparedWrites.length) { preparedIdentityPublicationClosed = true void writeFileSystemTransactionJournal( candidateRoot, preparingJournal, ).then( resolvePreparedIdentityPublication, rejectPreparedIdentityPublication, ) } } } return preparedIdentityPublication } const preparations = preparedWrites.map(async (write, index) => { try { await assertTransactionParentUnchanged( transactionRoot, write.destination, parentIdentities, ) const state = await prepareFileSystemTransactionReplacement( join(candidateInputRoot, basename(write.input)), write.prepared!, write.final, () => assertTransactionParentUnchanged( transactionRoot, write.destination, parentIdentities, ), (identity) => recordPreparedIdentity(index, identity), ) write.final = state } catch (error) { failPreparedIdentityPublication(error) throw error } }) try { await Promise.all(preparations) } catch (error) { // Cleanup owns every prepared pathname only after all creators stop. await Promise.allSettled(preparations) throw error } const preparedWritesByDestination = new Map( preparedWrites.map((write) => [write.destination, write]), ) let backupIndex = 0 for (const path of affected) { if ( replacementAliasParents.has(path) && !replacementBackupPaths.has(path) ) { continue } const stats = affectedStats.get(path) if (!stats) { continue } await assertTransactionParentUnchanged( transactionRoot, path, parentIdentities, ) const backupName = String(backupIndex++) const backup = join(candidateBackupRoot, backupName) const mode = Number(stats.mode & 0o7777n) const state = await snapshotFileSystemTransactionInput( path, backup, mode, stats, ) backups.set(path, { path: join(publishedBackupRoot, backupName), state, }) } const backupForPath = (path: string) => { const direct = backups.get(path) if (direct) { return direct } if (!replacementAliasParents.has(path)) { return } const aliasBackupPath = replacementAliasBackups.get( findReplacementAlias(path), ) return aliasBackupPath ? backups.get(aliasBackupPath) : undefined } const finalWriteForPath = (path: string) => { const direct = preparedWritesByDestination.get(path) if (direct) { return direct } if (!replacementAliasParents.has(path)) { return } const aliasRoot = findReplacementAlias(path) return preparedWrites.find( (write) => replacementAliasParents.has(write.destination) && findReplacementAlias(write.destination) === aliasRoot, ) } const preparedJournal: FileSystemTransactionJournal = { entries: [...affected].map((path) => { const originalStats = affectedStats.get(path) const backup = backupForPath(path) const finalWrite = finalWriteForPath(path) const artifacts = artifactPaths.get(path) if (!artifacts) { throw new Error( `Filesystem transaction metadata was not captured for ${path}`, ) } if (finalWrite && !finalWrite.prepared) { throw new Error( `Filesystem transaction replacement was not prepared for ${path}`, ) } return { backup: backup ? fileSystemTransactionRelativePath( transactionRoot, backup.path, 'Transaction backup', ) : undefined, final: finalWrite?.final, original: originalStats && backup ? backup.state : undefined, parent: journalParentForPath(path), path: fileSystemTransactionRelativePath( transactionRoot, path, 'Transaction path', ), prepared: finalWrite ? fileSystemTransactionRelativePath( transactionRoot, finalWrite.prepared!, 'Transaction prepared replacement', ) : undefined, retired: fileSystemTransactionRelativePath( transactionRoot, artifacts.retired, 'Transaction retirement path', ), rollbackRetired: fileSystemTransactionRelativePath( transactionRoot, artifacts.rollbackRetired, 'Transaction rollback retirement path', ), } }), phase: 'prepared', token: transactionToken, version: fileSystemTransactionStateVersion, } await writeFileSystemTransactionJournal(candidateRoot, preparedJournal) journal = preparedJournal const journalEntriesByPath = new Map( preparedJournal.entries.map((entry) => [ resolveFileSystemTransactionRelativePath( transactionRoot, entry.path, 'Transaction path', ), entry, ]), ) await Promise.all([ syncDirectory(candidateBackupRoot), syncDirectory(candidateInputRoot), ]) await syncDirectory(candidateRoot) if (await lstatIfExists(journalRoot)) { throw new Error( `Filesystem transaction recovery state already exists: ${journalRoot}`, ) } await rename(candidateRoot, journalRoot) published = true await syncDirectory(transactionRoot) const publishedStats = await lstatIfExists(journalRoot, { bigint: true }) if (!fileSystemTransactionStateMatches(candidateStats, publishedStats)) { throw new Error( `Filesystem transaction recovery state changed during publication: ${journalRoot}`, ) } const publishedOwner = await readFileSystemTransactionOwner(transactionRoot) if (publishedOwner.token !== transactionToken) { throw new Error( `Filesystem transaction recovery state owner changed during publication: ${journalRoot}`, ) } for (const path of preWriteRemovals) { const entry = journalEntriesByPath.get(path) if (!entry) { throw new Error(`Filesystem transaction journal omitted path: ${path}`) } await removeFileSystemTransactionPath( transactionRoot, path, parentIdentities, entry.original, resolveFileSystemTransactionRelativePath( transactionRoot, entry.retired!, 'Transaction retirement path', ), 'before pre-write removal', ) } for (const write of preparedWrites) { const { destination } = write const entry = journalEntriesByPath.get(destination) if (!entry || !entry.final || !entry.prepared || !entry.retired) { throw new Error( `Filesystem transaction journal omitted write destination: ${destination}`, ) } const expectedDestination = write.removeBeforeWrite && replacementAliasParents.has(write.removeBeforeWrite) && replacementAliasParents.has(destination) && findReplacementAlias(write.removeBeforeWrite) === findReplacementAlias(destination) ? undefined : entry.original await commitFileSystemTransactionReplacement( transactionRoot, destination, parentIdentities, expectedDestination, resolveFileSystemTransactionRelativePath( transactionRoot, entry.prepared, 'Transaction prepared replacement', ), entry.final, resolveFileSystemTransactionRelativePath( transactionRoot, entry.retired, 'Transaction retirement path', ), ) } for (const path of affected) { if (!writesByDestination.has(path) && !preWriteRemovals.has(path)) { const entry = journalEntriesByPath.get(path) if (!entry) { throw new Error( `Filesystem transaction journal omitted path: ${path}`, ) } await removeFileSystemTransactionPath( transactionRoot, path, parentIdentities, entry.original, resolveFileSystemTransactionRelativePath( transactionRoot, entry.retired!, 'Transaction retirement path', ), 'before transaction removal', ) } } journal = { ...journal, phase: 'committed' } await writeFileSystemTransactionJournal(journalRoot, journal) committed = true } catch (error) { transactionError = error if (published && journal && !committed) { const rollbackErrors: Error[] = [] for (const rollbackError of await rollbackFileSystemTransaction( transactionRoot, journal, )) { rollbackErrors.push(rollbackError) } if (rollbackErrors.length > 0) { preserveJournalRoot = true transactionError = new AggregateError( [error, ...rollbackErrors], `Filesystem transaction failed and rollback was incomplete; recovery state is preserved at ${journalRoot}`, { cause: error }, ) } } } let cleanupError: unknown if (!preserveJournalRoot) { try { if (published) { if (journal) { await cleanupFileSystemTransactionArtifacts(transactionRoot, journal) } await removeFileSystemTransactionJournal( transactionRoot, transactionToken, candidateStats, ) } else { if (journal) { await cleanupUnpublishedFileSystemTransactionArtifacts( transactionRoot, journal, ) } await removeFileSystemTransactionCandidate( transactionRoot, candidateRoot, candidateStats, ) } } catch (error) { cleanupError = error } } if (transactionError !== undefined) { if (cleanupError !== undefined) { throw new AggregateError( [transactionError, cleanupError], `Filesystem transaction failed and its recovery state could not be removed from ${journalRoot}`, { cause: transactionError }, ) } throw transactionError } if (cleanupError !== undefined) { debug.warn( `Filesystem transaction committed but recovery-state cleanup failed at ${journalRoot}: ${errorMessage(cleanupError)}`, ) } } async function pathsReferToSameDirectoryEntry( left: string, right: string, leftStats: BigIntStats, rightStats: BigIntStats, ) { const resolvedLeft = resolve(left) const resolvedRight = resolve(right) if (resolvedLeft === resolvedRight) { return true } if ( !leftStats.isFile() || !rightStats.isFile() || !statIdentitiesMatch(leftStats, rightStats) ) { return false } if (process.platform !== 'darwin' && process.platform !== 'win32') { return false } const [leftParent, rightParent] = await Promise.all([ realpath(dirname(left)), realpath(dirname(right)), ]) if (!pathsHaveEquivalentPlatformSpelling(leftParent, rightParent)) { return false } const entries = await readdir(leftParent) const leftName = basename(left) const rightName = basename(right) return !( leftName !== rightName && entries.includes(leftName) && entries.includes(rightName) ) } async function lstatIfExists(path: string): Promise<Stats | undefined> async function lstatIfExists( path: string, options: { bigint: true }, ): Promise<BigIntStats | undefined> async function lstatIfExists(path: string, options?: { bigint: true }) { try { return options?.bigint ? await lstat(path, { bigint: true }) : await lstat(path) } catch (error) { if ((error as NodeJS.ErrnoException).code === 'ENOENT') { return } throw error } } /** * Exact 64-bit dev/ino identity equality between two bigint stat observations. * Every ownership or continuity decision in the reconciliation and transaction * subsystems must compare identity through this helper (or on the decimal * strings derived from a bigint stat), never on the lossy Number * `Stats.dev`/`Stats.ino` fields: Windows NTFS file IDs and volume serials * exceed Number.MAX_SAFE_INTEGER, so two distinct filesystem objects can * collapse onto the same JS double and defeat the check. */ export function statIdentitiesMatch( expected: Pick<BigIntStats, 'dev' | 'ino'>, current: Pick<BigIntStats, 'dev' | 'ino'> | undefined, ): boolean { return ( current !== undefined && expected.dev === current.dev && expected.ino === current.ino ) } async function readReconciliationMetadata(path: string, label: string) { let handle try { handle = await open(path, constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0)) } catch (error) { if ((error as NodeJS.ErrnoException).code === 'ELOOP') { throw reconciliationPathCollisionError( path, `is not a regular ${label} file`, ) } throw error } try { // Capture 64-bit dev/ino so every downstream ownership comparison on this // metadata is exact even past Number.MAX_SAFE_INTEGER. const stats = await handle.stat({ bigint: true }) if (!stats.isFile()) { throw reconciliationPathCollisionError( path, `is not a regular ${label} file`, ) } const content = Buffer.allocUnsafe(reconciliationMetadataMaximumSize + 1) let offset = 0 while (offset < content.length) { const { bytesRead } = await handle.read( content, offset, content.length - offset, offset, ) if (bytesRead === 0) { break } offset += bytesRead } if (offset > reconciliationMetadataMaximumSize) { throw reconciliationPathCollisionError( path, `exceeds the maximum ${label} size`, ) } return { content: content.subarray(0, offset).toString('utf8'), stats, } } finally { await handle.close() } } async function canonicalizeReconciliationPath(path: string) { let current = resolve(path) const missingSegments: string[] = [] while (true) { try { return join(await realpath(current), ...missingSegments) } catch (error) { if ((error as NodeJS.ErrnoException).code !== 'ENOENT') { throw error } const parent = dirname(current) if (parent === current) { return join(current, ...missingSegments) } missingSegments.unshift(basename(current)) current = parent } } } async function resolveReconciliationLockIdentities( path: string, ): Promise<ReconciliationLockIdentity[]> { const requestedPath = resolve(path) const anchorPath = await canonicalizeReconciliationPath(path) let anchorStats: BigIntStats try { anchorStats = await lstat(anchorPath, { bigint: true }) } catch (error) { if ((error as NodeJS.ErrnoException).code === 'ENOENT') { throw reconciliationAnchorError(anchorPath, 'does not exist', 'ENOENT') } throw error } if (!anchorStats.isDirectory()) { throw reconciliationAnchorError(anchorPath, 'is not a directory', 'ENOTDIR') } const guardKeys = new Set<string>() if (await directoryIsWritable(dirname(anchorPath))) { guardKeys.add(anchorPath) } let currentPath = requestedPath while (true) { const currentStats = await lstatIfExists(currentPath) if (currentStats?.isSymbolicLink()) { if (await directoryIsWritable(dirname(currentPath))) { guardKeys.add(currentPath) } } const parent = dirname(currentPath) if (parent === currentPath) { break } currentPath = parent } const pathIdentities = await Promise.all( [...guardKeys].map(async (key) => { const lockRootPath = await realpath(dirname(key)) const lockRootStats = await lstat(lockRootPath, { bigint: true }) if (!lockRootStats.isDirectory()) { throw reconciliationPathCollisionError( lockRootPath, 'is not a lock namespace directory', ) } return { anchorPath, dev: anchorStats.dev, ino: anchorStats.ino, key, lockRootDev: lockRootStats.dev, lockRootIno: lockRootStats.ino, lockRootPath, reportTopologyChange: key === anchorPath, requestedPath, } }), ) const anchorParentPath = await realpath(dirname(anchorPath)) const anchorParentStats = await lstat(anchorParentPath, { bigint: true }) const anchorParentIsShared = (anchorParentStats.mode & 0o1000n) === 0n && (await directoryIsWritable(anchorParentPath)) const objectLockRootPath = anchorParentIsShared ? anchorParentPath : anchorPath const objectLockRootStats = anchorParentIsShared ? anchorParentStats : anchorStats if (!anchorParentIsShared && !(await directoryIsWritable(anchorPath))) { throw reconciliationPathCollisionError( anchorPath, 'does not provide a writable lock namespace', ) } const objectIdentity: ReconciliationLockIdentity = { anchorPath, dev: anchorStats.dev, ino: anchorStats.ino, key: `inode:${anchorStats.ino}`, lockRootDev: objectLockRootStats.dev, lockRootIno: objectLockRootStats.ino, lockRootPath: objectLockRootPath, reportTopologyChange: !guardKeys.has(anchorPath), requestedPath, } const identities = [...pathIdentities, objectIdentity] return identities.sort((left, right) => { const leftPath = reconciliationLockPath(left) const rightPath = reconciliationLockPath(right) return leftPath < rightPath ? -1 : leftPath > rightPath ? 1 : 0 }) } async function directoryIsWritable(path: string) { try { await access(path, constants.W_OK) return true } catch { return false } } async function assertReconciliationRequestedPathUnchanged( identity: ReconciliationLockIdentity, ) { let currentPath: string try { currentPath = await canonicalizeReconciliationPath(identity.requestedPath) } catch { throw reconciliationAnchorError( identity.requestedPath, 'changed after lock identity was captured', 'ESTALE', ) } const currentStats = await lstatIfExists(currentPath, { bigint: true }) if ( !currentStats?.isDirectory() || !statIdentitiesMatch(identity, currentStats) ) { throw reconciliationAnchorError( identity.requestedPath, 'changed after lock identity was captured', 'ESTALE', ) } } async function assertReconciliationAnchorUnchanged( identity: ReconciliationLockIdentity, ) { let anchorStats: BigIntStats try { anchorStats = await lstat(identity.anchorPath, { bigint: true }) } catch (error)