Files
orca/src/main/durable-file-write.ts
T
Jinjing cbe8635f46 fix(worktrees): prevent deletion from blocking Orca (#11233)
* fix(worktrees): prevent deletion from blocking Orca

* test(worktrees): loosen async history-delete event-loop bound for CI

The main-thread safety check failed on a loaded runner when a single
timer gap hit ~48ms under the prior 30ms threshold. Keep the bound well
below a recursive sync-rm stall without treating CI jitter as a block.

* test(worktrees): measure history-delete critical path, not timer gaps

setInterval gaps during async rm of thousands of files still flake under
CI scheduling. deleteWorktreeHistoryDir is sync and must only rename, so
assert that critical-path wall time stays well below a recursive walk.

* fix(worktrees): prevent deletion from blocking Orca

Add timeout-based draining of watcher closes so SSH round-trip delays don't
indefinitely block the worktree removal path. Also: order durable temp-file
sweeps ahead of writes to reclaim orphans before accumulation, skip own-process
temps to avoid deleting live writes, swallow persistence errors so disk failures
don't cascade to query callers, and measure history-deletion progress by loop
turns rather than timer gaps to detect blocking on CI runners.

* fix(worktrees): prevent deletion from blocking Orca

Worktree deletion can now proceed even if filesystem watchers or history cleanup operations hang, preventing Orca from freezing. Changes:

- Fence install slots with tokens instead of counters so removals can abandon wedged installs without corrupting later removals
- Timeout-bound watcher unsubscribe operations with a shared drain budget
- Move JSON serialization of large usage caches from queue-time to write-time to avoid blocking main thread
- Async tombstone + schedule history tree deletion instead of blocking recursive rmSync during GC, preventing main-thread stalls ~10s after startup

* Extract usage cache writer into reusable durable snapshot class

Consolidates serialized durable-write and generation-veto logic from
three usage stores into UsageCacheSnapshotWriter. Eliminates duplication,
centralizes multi-MB JSON serialization on the main thread via write-queue
serialization, and vetoes superseded snapshots to avoid wasted rewrites.

* fix(worktrees): prevent deletion from blocking Orca

Worktree deletion used to recursively delete large session trees (hundreds
of MB) on the critical path, stalling the event loop. Instead, rename trees
into a `.pending-delete` tombstone queue and reclaim them asynchronously
off the removal's critical path.

Extracted host tree removal into a reusable helper (`removeHostTree`) that
centralizes Windows retry logic. Added usage-cache flush on quit to prevent
data loss when scans complete right before shutdown. Improved watcher
removal deadline management with reserved tail slices for the final
unsubscribe, and added retry logic for tombstone removals that fail once
under transient Windows locking.

* fix(history): retry failed session tree removals

Tombstoned session trees whose removal fails transiently (e.g., EBUSY
under Windows AV) are now re-queued in-process with bounded exponential
backoff instead of sitting until the next HistoryManager construction.
Prevents a single stuck tree from blocking the entire Orca process.
2026-07-29 18:21:26 -07:00

140 lines
4.8 KiB
TypeScript

// Why: rename() is atomic for readers but not durable. Without fsync on the file and its directory,
// a power loss after a successful rename can leave the old contents, or an empty inode — the same
// empty-file symptom as issue #1158, from a different cause. The .bak ring recovers it at up to an
// hour's loss; fsync stops it from happening.
import { closeSync, fsyncSync, openSync, renameSync, writeFileSync } from 'node:fs'
import { open, readdir, rename, rm } from 'node:fs/promises'
import { basename, dirname, join } from 'node:path'
/**
* fsync a directory so a rename within it is durable. Best-effort by design: Windows cannot open a
* directory for fsync, and some filesystems reject it. The file fsync above it is the load-bearing
* part; this closes the "rename recorded but not persisted" window where the platform allows it.
*/
async function syncDirectory(directory: string): Promise<void> {
let handle: Awaited<ReturnType<typeof open>> | null = null
try {
handle = await open(directory, 'r')
await handle.sync()
} catch {
// Expected on Windows and on filesystems without directory fsync.
} finally {
await handle?.close().catch(() => {})
}
}
function syncDirectorySync(directory: string): void {
let fd: number | null = null
try {
fd = openSync(directory, 'r')
fsyncSync(fd)
} catch {
// Same platform caveats as syncDirectory.
} finally {
if (fd !== null) {
try {
closeSync(fd)
} catch {
// Nothing actionable; the fsync already happened or the open failed.
}
}
}
}
/**
* Rename and then fsync the containing directory. For callers that already fsynced the temp file
* themselves and need the rename made durable.
*/
export async function renameDurable(tmpPath: string, finalPath: string): Promise<void> {
await rename(tmpPath, finalPath)
await syncDirectory(dirname(finalPath))
}
/** Write `payload` to `tmpPath`, fsync it, then rename onto `finalPath` and fsync the directory. */
export async function writeFileDurable(
tmpPath: string,
finalPath: string,
payload: string
): Promise<void> {
await writeFileDurableIfCurrent(tmpPath, finalPath, payload, () => true)
}
/**
* `writeFileDurable` with a commit veto: `isCurrent` is consulted after the fsync and before the
* rename so a writer that was superseded mid-write doesn't publish a stale snapshot. Returns whether
* the rename happened; the temp file is removed on every path that doesn't commit, so a multi-MB
* payload can't orphan itself.
*/
export async function writeFileDurableIfCurrent(
tmpPath: string,
finalPath: string,
payload: string,
isCurrent: () => boolean
): Promise<boolean> {
let renamed = false
try {
const handle = await open(tmpPath, 'w')
try {
await handle.writeFile(payload, 'utf-8')
// Why: fsync BEFORE rename. A rename that lands first can expose a zero-length file.
await handle.sync()
} finally {
await handle.close()
}
if (!isCurrent()) {
return false
}
await rename(tmpPath, finalPath)
renamed = true
await syncDirectory(dirname(finalPath))
return true
} finally {
if (!renamed) {
await rm(tmpPath, { force: true }).catch(() => {})
}
}
}
/** Temp path for a durable write. Shared shape so `removeStaleDurableWriteTempFiles` can reclaim orphans. */
export function durableWriteTempPath(finalPath: string): string {
return `${finalPath}.${process.pid}.${Date.now()}.${Math.random().toString(16).slice(2)}.tmp`
}
/**
* Sweep temp files orphaned by a death between write and rename — for multi-MB payloads they would
* otherwise accumulate forever. Racing another instance's in-flight save at worst loses that save,
* the trade already accepted for rename-based atomicity. This process's own temps are skipped: a
* `<file>.<our pid>.*.tmp` seen during a sweep is a live write, and deleting it fails its rename.
*/
export async function removeStaleDurableWriteTempFiles(finalPath: string): Promise<void> {
const directory = dirname(finalPath)
const prefix = `${basename(finalPath)}.`
const ownPrefix = `${prefix}${process.pid}.`
try {
const names = await readdir(directory)
await Promise.all(
names
.filter(
(name) => name.startsWith(prefix) && name.endsWith('.tmp') && !name.startsWith(ownPrefix)
)
.map((name) => rm(join(directory, name), { force: true }).catch(() => {}))
)
} catch {
// Directory missing or unreadable — nothing to sweep.
}
}
/** Synchronous counterpart for quit and crash paths that cannot await. */
export function writeFileDurableSync(tmpPath: string, finalPath: string, payload: string): void {
writeFileSync(tmpPath, payload, 'utf-8')
const fd = openSync(tmpPath, 'r+')
try {
fsyncSync(fd)
} finally {
closeSync(fd)
}
renameSync(tmpPath, finalPath)
syncDirectorySync(dirname(finalPath))
}