Files
deepseek-harness/packages/util/atomic-write/src/index.ts
T
Turtle 1bd3df926d fix(plugin-manager): wait for a pnpm run its exited operation left behind
Review found three ways the takeover could let two writers overlap.

- A claimant that read an exited PID could remove the lock of a new
  holder that reused that PID before the claim. The claimant now probes
  the PID again under the claim, after re-reading the record.
- The browser Worker's process shim reports its own PID as absent, so a
  second writer in the Worker took over the first one's live lock. A
  record naming the contender's own process is never taken over.
- Takeover proves only that the holder exited, while a pnpm run it
  started can still be writing the profile. Each profile operation now
  records its pnpm run in .plugin-manager/run.json and removes the record
  when the run ends; an operation that finds a record waits for the run
  within the existing tree bound and otherwise refuses, naming the
  process and the record, without running pnpm.

A failed removal of the exited holder's lock now waits instead of
failing the acquisition, the probe range is stated as int32, and the
tests inject exited PIDs instead of reusing real ones, which Windows
reissues at once.
2026-09-24 19:55:41 +08:00

269 lines
12 KiB
TypeScript

/**
* Zero-dependency atomic file replacement and writer coordination.
* `writeFileAtomic` writes a random-suffix sibling with exclusive create and
* the caller's permission bits, then renames it over the target, so readers
* observe either the old or the new complete content and a replaced file ends
* up with exactly the stated mode. `withFileLock` serializes cross-process
* writers of one file through a `wx`-created `<file>.lock` sibling, so a
* read-modify-write cycle can never resurrect a state another writer just
* replaced; readers stay lock-free because the rename commit is atomic. A lock
* whose recorded holder process no longer exists is taken over.
* @module @deepseek-ai/dsh-atomic-write
*/
import { createHash, randomBytes } from 'node:crypto'
import { lstat, mkdir, readFile, rename, rm, writeFile } from 'node:fs/promises'
import { dirname } from 'node:path'
const WINDOWS_TRANSIENT_RENAME_ERRORS: ReadonlySet<string> = new Set(['EACCES', 'EBUSY', 'EPERM'])
const WINDOWS_RENAME_RETRY_INITIAL_MS = 20
const WINDOWS_RENAME_RETRY_MAX_MS = 200
const WINDOWS_RENAME_RETRY_LIMIT = 8
/** Whether Windows reported temporary interference with an atomic replacement. */
function isTransientWindowsRenameError(error: unknown): boolean {
if (process.platform !== 'win32') return false
return WINDOWS_TRANSIENT_RENAME_ERRORS.has((error as NodeJS.ErrnoException | null)?.code ?? '')
}
/** Replace the target after bounded retries for transient Windows interference. */
async function renameAtomicTemp(temp: string, filename: string): Promise<void> {
let delay = WINDOWS_RENAME_RETRY_INITIAL_MS
for (let retries = 0;; retries += 1) {
try {
await rename(temp, filename)
return
} catch (error) {
if (!isTransientWindowsRenameError(error)) throw error
if (retries >= WINDOWS_RENAME_RETRY_LIMIT) throw error
}
await new Promise(resolve => setTimeout(resolve, delay))
delay = Math.min(delay * 2, WINDOWS_RENAME_RETRY_MAX_MS)
}
}
/**
* Filesystem options for {@link writeFileAtomic}; `mode` is required so the
* permission decision stays visible at every call site.
*/
export interface WriteFileAtomicOptions {
/**
* Permission bits stamped on the fresh temp inode and carried through the
* rename (subject to the process umask, like every fresh inode).
*/
mode: number
/**
* Permission bits for parent directories this call creates (subject to the
* umask; existing directories keep their mode). Omission uses the mkdir
* default — pass `0o700` when the tree holds user-private data.
*/
dirMode?: number
}
/**
* Replace `filename` with `content` in one atomic step, creating parent
* directories. The content is first written to a random-suffix sibling opened
* with exclusive create (`wx`): the open refuses to follow a symlink planted
* at the temp path, and the fresh inode carries `options.mode` through the
* rename, so replacing a wider-permission file narrows it without a chmod
* race. The rename also replaces a symlinked target itself instead of writing
* through to its referent, and the same-directory sibling keeps the rename on
* one filesystem. Windows replacement retries transient `EACCES`, `EBUSY`,
* and `EPERM` failures for a bounded interval while the complete temp file
* remains the rename source. On any remaining failure the temp file is
* removed and the failure rethrown. Crash durability (fsync) is out of scope.
* @param filename - final path receiving the content.
* @param content - complete next file content.
* @param options - permission bits for the replacement inode.
*/
export async function writeFileAtomic(filename: string, content: string, options: WriteFileAtomicOptions): Promise<void> {
await mkdir(dirname(filename), {
recursive: true,
...options.dirMode === undefined ? {} : { mode: options.dirMode },
})
// TODO(settings-atomic-durability): Use a replacement that fsyncs the file
// and parent directory and preserves owner-only permissions on Windows.
const temp = `${filename}.${randomBytes(6).toString('hex')}.tmp`
try {
await writeFile(temp, content, { mode: options.mode, flag: 'wx' })
await renameAtomicTemp(temp, filename)
} catch (error) {
await rm(temp, { force: true })
throw error
}
}
/** Whether an exclusive create found an existing lock. */
async function isLockContention(error: unknown, lockPath: string): Promise<boolean> {
const code = (error as NodeJS.ErrnoException | null)?.code
if (code === 'EEXIST') return true
if (code !== 'EPERM') return false
try {
await lstat(lockPath)
return true
} catch {
// Keep the original EPERM authoritative when lock existence is unproven.
return false
}
}
/** Whether the holder a `<pid>\n` record names is proven gone: a signal probe finds no such process. */
function holderExited(record: string): boolean {
// Any other content is being written or was cut short, which proves nothing about its holder.
if (!/^\d+\n$/.test(record)) return false
const pid = Number(record.trim())
// PID 0 addresses the caller's process group, and the probe accepts only positive int32 PIDs.
if (pid === 0 || pid > 0x7fffffff) return false
// This process is running; a runtime whose probe cannot see itself must not take over its own lock.
if (pid === process.pid) return false
try {
process.kill(pid, 0)
return false
} catch (error) {
// EPERM means the process exists under another user.
return (error as NodeJS.ErrnoException).code === 'ESRCH'
}
}
/** The lock file's content, or undefined when it cannot be read. */
async function readLockRecord(lockPath: string): Promise<string | undefined> {
try {
return await readFile(lockPath, 'utf8')
} catch (error) {
// A lock that vanished, or that Windows is still deleting, proves nothing about a holder.
void error
return undefined
}
}
/**
* Remove the lock when its recorded holder exited. Contenders that read the
* same record serialize on a claim file named after it. Under the claim, the
* claimant re-reads the lock and probes its PID again, and removes it only
* when it still holds that record and that PID is still gone: the record's
* holder can no longer release it, and no other contender can remove it
* without the claim, so a removal never deletes a lock another contender
* acquired after the dead holder's, including one whose holder reused the PID.
* @returns Whether this call removed the dead holder's lock.
*/
async function takeOverExitedLock(lockPath: string): Promise<boolean> {
const record = await readLockRecord(lockPath)
if (record === undefined || !holderExited(record)) return false
const claim = `${lockPath}.takeover-${createHash('sha256').update(record).digest('hex').slice(0, 16)}`
try {
await writeFile(claim, `${process.pid}\n`, { mode: 0o600, flag: 'wx' })
} catch (error) {
// Another contender owns the claim for this record, or Windows still deletes the claim it released.
const code = (error as NodeJS.ErrnoException).code
if (code === 'EEXIST' || code === 'EPERM') return false
throw error
}
try {
if (await readLockRecord(lockPath) !== record || !holderExited(record)) return false
try {
await rm(lockPath, { force: true })
} catch (error) {
// Windows can refuse the removal while other software briefly holds the file; the next attempt retries.
void error
return false
}
return true
} finally {
await rm(claim, { force: true }).catch((error: unknown) => {
// A claim left behind names a record that is no longer the lock, so it blocks no later takeover.
void error
})
}
}
/**
* Retry cadence for a contended lock. These stay robustness invariants of the
* cross-process write protocol rather than deployment tunables: they govern how
* often a contender asks, which no caller has a reason to vary.
*/
const LOCK_RETRY_INITIAL_MS = 20
const LOCK_RETRY_MAX_MS = 200
/**
* How long a contender waits when the caller states no limit — sized for the
* render-and-rename cycle every call site had when this package was written.
* Expiry fails the contender rather than guessing whether the existing lock
* still has an owner. How long is *worth* waiting is a property of the
* operation the lock holder runs, which is why {@link FileLockOptions.waitMs}
* exists; the value here is the floor for an operation that does file work
* alone.
*/
const DEFAULT_LOCK_WAIT_MS = 2_000
/** Options for one {@link withFileLock} acquisition. */
export interface FileLockOptions {
/**
* Maximum time to wait for the lock, in milliseconds. State one when the
* holder's operation legitimately runs longer than file work — a credential
* mutation that refreshes a token performs a network round trip while
* holding the lock, and leaving the default in place would fail every other
* writer of the same file for the duration. Waiting is productive: a
* contender that acquires the lock afterwards re-reads the committed state.
*/
waitMs?: number
}
/**
* Hold the cross-process writer lock for `filename` around one operation. The
* lock is a `wx`-created sibling (`<filename>.lock`); paired with the
* rename-based commit of {@link writeFileAtomic}, readers stay lock-free and
* only writers contend. `EEXIST` is contention directly; an `EPERM` is
* contention only when a fresh `lstat` confirms the lock path exists, covering
* Windows exclusive-create behavior. Windows retries one unconfirmed EPERM
* because the holder can release before the probe; a repeated unconfirmed
* permission error is rethrown. The lock records its holder's PID. A contender
* removes the lock and retries at once when no process with that PID exists
* (`ESRCH`); any other lock, including one whose holder exists under another
* user (`EPERM`) or whose record is incomplete, is waited for. Contention backs
* off exponentially and times out after the deadline. A holder whose PID a
* live process reused keeps its lock until an operator removes it. Takeover
* proves only that the recorded process exited: an operation that starts other
* writers must stop them with it or leave its successor a way to find them.
* PIDs are compared on the contender's host, so writers on other hosts or in
* other PID namespaces sharing the file are unsupported and could both hold
* the lock. The parent directory must exist.
* @param filename - the file whose writers this lock serializes.
* @param operation - the read-render-commit cycle to run while holding the lock.
* @param options - acquisition options; omitted waits {@link DEFAULT_LOCK_WAIT_MS}.
* @returns the operation's result; the lock releases on both outcomes.
*/
export async function withFileLock<T>(
filename: string,
operation: () => Promise<T>,
options?: FileLockOptions,
): Promise<T> {
const lockPath = `${filename}.lock`
const deadline = Date.now() + (options?.waitMs ?? DEFAULT_LOCK_WAIT_MS)
let delay = LOCK_RETRY_INITIAL_MS
let retriedUnconfirmedPermissionError = false
for (;;) {
try {
await writeFile(lockPath, `${process.pid}\n`, { mode: 0o600, flag: 'wx' })
break
} catch (error) {
if (!await isLockContention(error, lockPath)) {
// Windows can release the competing lock between exclusive create and lstat.
if (process.platform !== 'win32'
|| (error as NodeJS.ErrnoException | null)?.code !== 'EPERM'
|| retriedUnconfirmedPermissionError) throw error
retriedUnconfirmedPermissionError = true
} else if (await takeOverExitedLock(lockPath)) continue
}
if (Date.now() >= deadline) {
throw new Error(`atomic-write: timed out waiting for the writer lock at ${lockPath}`)
}
await new Promise(resolve => setTimeout(resolve, delay))
delay = Math.min(delay * 2, LOCK_RETRY_MAX_MS)
}
try {
return await operation()
} finally {
await rm(lockPath, { force: true })
}
}