@napi-rs/cli
Version:
Cli tools for napi-rs
1,689 lines (1,611 loc) • 183 kB
text/typescript
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)