mirror of
https://github.com/stablyai/orca.git
synced 2026-10-27 00:02:48 +00:00
* fix(quit): stop durable state writes from parking the main thread will-quit ran stats.flush() and store.flush() synchronously, before preventDefault(). Both fsync and rename a multi-MB file on the profile directory. When that directory sits on a stalled network mount the syscall enters an uninterruptible wait: the app stops repainting and stops responding to Force Quit, because a process blocked in the kernel ignores SIGTERM and SIGKILL alike. The existing 20s teardown deadline could not bound this. Its timer runs on the very thread the syscall parked, so it never fires. The fix is to make the quit path awaitable rather than to try to bound it — a quit that is slow but responsive stays killable by the OS. - preventDefault() now runs first, so every teardown step is free to await - stats and state gain flushAsync() twins that use node:fs/promises - both join the existing teardown barrier, which can now actually bound them - the pass-2 will-quit re-entry returns early instead of re-running teardown - quitFlushStarted makes the quit flush the last write, so a teardown step touching the store cannot arm a debounce that races process exit Making the swap async cost the atomicity of check-generation-then-rename: a writer parked on await rename has already cleared the guard, so a later synchronous flush could be clobbered by stale state. Both async writers now claim their temp path, and the sync writers delete it, turning that swap into a swallowed ENOENT. Atomic temp+rename is unchanged, so a write cut short by the deadline leaves the previous file whole — bounded loss, never corruption. * fix(quit): harden async persistence finalization * fix(persistence): bound best-effort flushes
151 lines
5.1 KiB
TypeScript
151 lines
5.1 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, stat } 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. 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): void {
|
|
writeFileSync(tmpPath, payload, 'utf-8')
|
|
const fd = openSync(tmpPath, 'r+')
|
|
try {
|
|
fsyncSync(fd)
|
|
} finally {
|
|
closeSync(fd)
|
|
}
|
|
renameSync(tmpPath, finalPath)
|
|
syncDirectorySync(dirname(finalPath))
|
|
}
|