Files
orca/src/main/durable-file-write.ts
T
OrcaWinandm4air 5f308bfa9c revert: take the 26 Phase 3 (#16741 port) PRs back out of main (#24559)
* Revert "feat(orcad): source-side dormant export of a relay-hosted SSH target (#16741 T6-8) (#24519)"

This reverts commit 783101b304.

* Revert "feat(ssh): update, roll back, recover and stop a managed orcad server (#16741 T6-5 follow-up) (#24463)"

This reverts commit 38c2d1dcb9.

* Revert "feat(ssh): deploy and pair an empty managed orcad server over SSH (#16741 T6-5) (#24453)"

This reverts commit 8b76683b40.

* Revert "fix(ssh): orcad GC honors the activation journal; readiness requires proven daemon coverage (#16741 T6 follow-up) (#24451)"

This reverts commit d3f8c5063b.

* Revert "feat(ssh): remote orcad stop by request file and journaled decommission (#16741 T6-4) (#24449)"

This reverts commit 43d9b43d3f.

* Revert "feat(orcad): supervisable server: stop requests, managed stop receipts and a lifetime that keeps its lock on failed teardown (#16741 T6-3) (#24433)"

This reverts commit b093d3ab20.

* Revert "feat(ssh): crash-safe orcad activation, rollback and recovery (#16741 T6-2) (#24423)"

This reverts commit 1a9ac0e955.

* Revert "feat(runtime): SSH access links for paired servers in a downgrade-safe sidecar (#16741 T5-1+T5-2) (#24420)"

This reverts commit 99db2bfae4.

* Revert "feat(relay): capability-gated owner reset with a durable preparation journal (#16741 T3 R1) (#24418)"

This reverts commit 34a582bd39.

* Revert "feat(ssh): track connection-manager drains, test probes and provider continuations (#16741 T2 P3+P8a) (#24407)"

This reverts commit d53063d2b1.

* Revert "feat(daemon): idle retirement, session census and recovery-only provider (#16741 T2 P4b) (#24409)"

This reverts commit ff212dbbef.

* Revert "feat(ssh): add pty.resumeClient and split SSH PTY process listing (#16741 T2 P5+P6) (#24414)"

This reverts commit 92cb71765e.

* Revert "feat(relay): await owned watcher and agent children on shutdown (#16741 T2 P1) (#24400)"

This reverts commit 6b36e4f85b.

* Revert "feat(session): retry failed renderer session writes and verify local folder PTYs (#16741 T2 P9) (#24406)"

This reverts commit d23ecef301.

* Revert "feat(ssh): remote orcad primitives on the pinned Node runtime (#16741 T6-1) (#24419)"

This reverts commit dd87ae578d.

* Revert "fix(runtime): fence runtime-environment subscriptions and status probes by identity (#16741 T5-3) (#24421)"

This reverts commit ece9e4d2e3.

* Revert "feat(orcad): migration manifest and dormant-state contracts (#16741 T6-7) (#24422)"

This reverts commit 3fbdaba262.

* Revert "feat(ssh): wire SshConnection through the work and transport close ledgers (#16741 T2 P2) (#24401)"

This reverts commit 4e8edc8872.

* Revert "feat(profiles): carry markdown frontmatter visibility in project transfers (#16741 T2 P7) (#24405)"

This reverts commit 60c93263cc.

* Revert "fix(runtime): project the PTY incarnation onto mobile session tabs (#24413)"

This reverts commit 99e0303572.

* Revert "feat(daemon): tag daemon stream data with the PTY incarnation id (#16741 T2 P4a) (#24402)"

This reverts commit 817af768b0.

* Revert "feat(ssh): port the SSH connection work ledger and transport close ledger (#16741 T2) (#24210)"

This reverts commit c9918931c8.

* Revert "feat(relay): fence and drain file and git response streams on shutdown (#24185)"

This reverts commit dc08ffeba9.

* Revert "refactor(runtime-rpc): extract the Node WebSocket lifecycle; opt-in pinned port (#24186)"

This reverts commit a789233bbb.

* Revert "feat(relay): route relay handlers through work admission; producer publication drain (#24181)"

This reverts commit 0b812bd698.

* Revert "feat(relay): land the #16741 T1 seam (work drain, publication drain, release gate) (#24156)"

This reverts commit 3aa2d3af7c.

---------

Co-authored-by: m4air <m4air@Mac.localdomain>
2026-10-02 00:52:32 -07:00

232 lines
7.6 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, rmSync, writeFileSync } from 'node:fs'
import { copyFile, open, readdir, rm, stat } from 'node:fs/promises'
import { basename, dirname, join } from 'node:path'
import {
publishFileWithoutOverwrite,
renameFileWithWindowsRetry,
renameFileWithWindowsRetryAsync
} from './codex-accounts/fs-utils'
/**
* 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 an already-fsynced file and make the containing directory durable. */
export function renameDurableSync(tmpPath: string, finalPath: string): void {
renameFileWithWindowsRetry(tmpPath, finalPath)
syncDirectorySync(dirname(finalPath))
}
/** Publish an already-fsynced file without replacing a concurrently created destination. */
export function publishFileDurableSync(tmpPath: string, finalPath: string): boolean {
if (!publishFileWithoutOverwrite(tmpPath, finalPath)) {
return false
}
syncDirectorySync(dirname(finalPath))
rmSync(tmpPath)
return true
}
/**
* 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 renameFileWithWindowsRetryAsync(tmpPath, finalPath)
await syncDirectory(dirname(finalPath))
}
/**
* Write `payload` to `tmpPath` and fsync it, WITHOUT publishing it. For callers that must order
* other work between "the new content is durable" and "the new content is visible" — a backup
* rotation that has to happen while the old file is still in place, for instance.
*/
export async function writeTempFileDurable(
tmpPath: string,
payload: string,
mode?: number
): Promise<void> {
const handle = await open(tmpPath, 'w', mode)
try {
await handle.writeFile(payload, 'utf-8')
await handle.sync()
} finally {
await handle.close()
}
}
/**
* Copy `sourcePath` onto `finalPath` durably: a fresh inode, fsynced, then renamed into place. A
* plain copyFile can be interrupted and leave a torn destination — fatal when the destination is
* the backup someone will fall back to. Returns false when the source does not exist.
*/
export async function copyFileDurable(sourcePath: string, finalPath: string): Promise<boolean> {
const tmpPath = durableWriteTempPath(finalPath)
let renamed = false
try {
try {
// copyFile stays in the kernel — and clones the extents outright on APFS and btrfs — so
// this does not pull the whole file through the process on every commit.
await copyFile(sourcePath, tmpPath)
} catch (error) {
if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
return false
}
throw error
}
const handle = await open(tmpPath, 'r+')
try {
await handle.sync()
} finally {
await handle.close()
}
await renameFileWithWindowsRetryAsync(tmpPath, finalPath)
renamed = true
await syncDirectory(dirname(finalPath))
return true
} finally {
if (!renamed) {
await rm(tmpPath, { force: true }).catch(() => {})
}
}
}
/** 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 {
// Why: fsync BEFORE rename. A rename that lands first can expose a zero-length file.
await writeTempFileDurable(tmpPath, payload)
if (!(await renameFileWithWindowsRetryAsync(tmpPath, finalPath, isCurrent))) {
return false
}
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. Callers can require a minimum age to spare another live instance's
* write. This process's own temps are always skipped because deleting one would fail its rename.
*/
export async function removeStaleDurableWriteTempFiles(
finalPath: string,
options: { minimumAgeMs?: number } = {}
): 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(async (name) => {
const path = join(directory, name)
if (options.minimumAgeMs) {
const info = await stat(path).catch(() => null)
if (!info || Date.now() - info.mtimeMs < options.minimumAgeMs) {
return
}
}
await rm(path, { 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 | Uint8Array
): void {
let renamed = false
try {
// A Uint8Array payload is written verbatim; a string still defaults to UTF-8.
writeFileSync(tmpPath, payload)
const fd = openSync(tmpPath, 'r+')
try {
fsyncSync(fd)
} finally {
closeSync(fd)
}
renameDurableSync(tmpPath, finalPath)
renamed = true
} finally {
if (!renamed) {
rmSync(tmpPath, { force: true })
}
}
}