Files
deepseek-harness/packages/util/atomic-write/src/index.ts
Yichen Jiang caf2db0929 Merge branch 'worktree-config-settings-seam' into worktree-llm-dynamic-config
# Conflicts:
#	packages/settings/settings-local/src/index.ts
2026-07-30 17:39:07 +08:00

158 lines
6.3 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.
* @module @deepseek-ai/dsh-atomic-write
*/
import { randomBytes } from 'node:crypto'
import { mkdir, rename, rm, stat, writeFile } from 'node:fs/promises'
import { dirname } from 'node:path'
/**
* 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. On any 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 rename(temp, filename)
} catch (error) {
await rm(temp, { force: true })
throw error
}
}
/** Whether an exclusive create failed because the path already exists. */
function isEEXIST(error: unknown): boolean {
return (error as NodeJS.ErrnoException | null)?.code === 'EEXIST'
}
/** Whether a filesystem error means absence. */
function isENOENT(error: unknown): boolean {
return (error as NodeJS.ErrnoException | null)?.code === 'ENOENT'
}
/**
* Writer-lock protocol constants. These are robustness invariants of the
* cross-process write protocol, not deployment tunables: a holder rewrites one
* small file in milliseconds, so contention resolves well inside the retry
* deadline, and a lock older than the stale age can only belong to a crashed
* holder.
*/
const LOCK_RETRY_INITIAL_MS = 20
const LOCK_RETRY_MAX_MS = 200
const LOCK_TIMEOUT_MS = 2_000
const LOCK_STALE_MS = 5_000
/** Options for {@link withFileLock}. */
export interface WithFileLockOptions {
/**
* Called once each time a stale (crashed-holder) lock is broken, so the
* caller can log the takeover in its own voice.
*/
onStaleBreak?: (lockPath: string) => void
}
/** Age of the lock file, or `undefined` when it vanished after a failed create. */
async function lockAgeMs(lockPath: string): Promise<number | undefined> {
try {
return Date.now() - (await stat(lockPath)).mtimeMs
} catch (error) {
if (!isENOENT(error)) throw error
return undefined
}
}
/**
* 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. Contention backs off exponentially; a lock older than
* the stale age is a crashed holder and is broken (see
* {@link WithFileLockOptions.onStaleBreak}); a live holder past the deadline
* fails the operation with a timed-out error. 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 - stale-takeover notification hook.
* @returns the operation's result; the lock releases on both outcomes.
*/
export async function withFileLock<T>(
filename: string,
operation: () => Promise<T>,
options?: WithFileLockOptions,
): Promise<T> {
const lockPath = `${filename}.lock`
const deadline = Date.now() + LOCK_TIMEOUT_MS
let delay = LOCK_RETRY_INITIAL_MS
for (;;) {
try {
await writeFile(lockPath, `${process.pid}\n`, { mode: 0o600, flag: 'wx' })
break
} catch (error) {
if (!isEEXIST(error)) throw error
}
const ageMs = await lockAgeMs(lockPath)
// The holder released between the failed create and the stat: the lock is
// free right now, so retry without burning backoff or deadline.
if (ageMs === undefined) continue
if (ageMs > LOCK_STALE_MS) {
// TODO(settings-lock-ownership): Replace age-only takeover with ownership-safe
// acquisition and release so a slow writer cannot remove a successor's lock.
options?.onStaleBreak?.(lockPath)
await rm(lockPath, { force: true })
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 })
}
}