diff --git a/README.md b/README.md index c85bbbe..ba18234 100644 --- a/README.md +++ b/README.md @@ -225,7 +225,11 @@ If `XDG_DATA_HOME` is not set, the default is: Set `OPENCODE_GOAL_STATE_PATH` to use a custom file. -The state file is written atomically with owner-only permissions when the host filesystem supports it. Existing active goals recover from disk with their full objective, budget, history, and checkpoint metadata. +The state file is written atomically through a same-directory temp file: the final path is only ever replaced by a fully-flushed file, so after a crash the state is the previous or the new valid version, never a torn one. The file is created with owner-only permissions where the host filesystem supports them, and the temp name is a random UUID opened exclusively so concurrent writers cannot collide. + +Ordinary fsync improves crash consistency but is not `F_FULLFSYNC`, so sudden power loss on macOS/APFS is not an absolute durability guarantee; where the platform cannot fsync the parent directory, a crash may leave the old or the new state file (both valid), never a partially-written one. Existing active goals recover from disk with their full objective, budget, history, and checkpoint metadata. + +If the rename succeeds but syncing the parent directory reports a genuine I/O error, the mutation reports a write failure even though the new valid state may already be present. This avoids claiming durability that the filesystem did not confirm. ## Credits diff --git a/dist/server.js b/dist/server.js index 6d23dba..85ab9d2 100644 --- a/dist/server.js +++ b/dist/server.js @@ -3,11 +3,120 @@ import { z } from "zod"; // src/state.ts -import { chmod, mkdir, readFile, rename, writeFile } from "fs/promises"; +import { mkdir, readFile } from "fs/promises"; import { homedir } from "os"; -import { dirname, join } from "path"; +import { dirname as dirname2, join } from "path"; import { Data, Effect, Schema } from "effect"; +// src/atomic-write.ts +import { randomUUID } from "crypto"; +import { chmod, open, rename, unlink } from "fs/promises"; +import { dirname } from "path"; +function isUnsupportedSyncDirError(error, platform) { + const code = error?.code; + return code === "EINVAL" || code === "ENOTSUP" || code === "EOPNOTSUPP" || code === "EISDIR" || platform === "win32" && (code === "EPERM" || code === "EACCES" || code === "EBADF"); +} +function isTransientRenameError(error, platform) { + if (platform !== "win32") + return false; + const code = error?.code; + return code === "EPERM" || code === "EACCES" || code === "EBUSY"; +} +async function bestEffort(action) { + try { + await action(); + } catch {} +} +var defaultSleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)); +var defaultDirOpenOps = { + async open(dir, flags) { + const handle = await open(dir, flags); + return { + sync: () => handle.sync(), + close: () => handle.close() + }; + } +}; +async function syncDirectory(dir, ops = defaultDirOpenOps, platform = process.platform) { + let fsHandle = null; + try { + const handle = await ops.open(dir, "r"); + fsHandle = handle; + await handle.sync(); + } catch (error) { + if (!isUnsupportedSyncDirError(error, platform)) + throw error; + } finally { + const cleanupHandle = fsHandle; + if (cleanupHandle) + await bestEffort(() => cleanupHandle.close()); + } +} +var defaultAtomicWriteOps = { + platform: process.platform, + async open(path, flags, mode) { + const handle = await open(path, flags, mode); + return { + write: (data) => handle.writeFile(data), + sync: () => handle.sync(), + close: () => handle.close() + }; + }, + rename, + chmod, + unlink, + syncDir: (dir, platform) => syncDirectory(dir, defaultDirOpenOps, platform), + sleep: defaultSleep +}; +var RENAME_ATTEMPTS = 3; +var RENAME_RETRY_DELAY_MS = 20; +async function renameWithRetry(ops, from, to) { + let attempt = 0; + for (;; ) { + try { + await ops.rename(from, to); + return; + } catch (error) { + attempt += 1; + if (!isTransientRenameError(error, ops.platform) || attempt >= RENAME_ATTEMPTS) + throw error; + const delay = RENAME_RETRY_DELAY_MS * attempt; + await ops.sleep(delay); + } + } +} +async function atomicWriteFile(file, data, ops = defaultAtomicWriteOps) { + const tmp = `${file}.${randomUUID()}.tmp`; + let handle = null; + let created = false; + let renamed = false; + try { + handle = await ops.open(tmp, "wx", 384); + created = true; + await handle.write(data); + await handle.sync(); + await handle.close(); + handle = null; + await renameWithRetry(ops, tmp, file); + renamed = true; + await bestEffort(() => ops.chmod(file, 384)); + try { + await ops.syncDir(dirname(file), ops.platform); + } catch (error) { + if (!isUnsupportedSyncDirError(error, ops.platform)) + throw error; + } + } catch (error) { + const cleanupHandle = handle; + if (cleanupHandle) + await bestEffort(() => cleanupHandle.close()); + if (created && !renamed) + await bestEffort(() => ops.unlink(tmp)); + throw error; + } +} + +// src/state.ts class StateReadError extends Data.TaggedError("StateReadError") { } @@ -117,14 +226,9 @@ function writeStateEffect(state) { return Effect.tryPromise({ try: async () => { const file = statePath(); - await mkdir(dirname(file), { recursive: true, mode: 448 }); - const tmp = `${file}.${process.pid}.${Date.now()}.tmp`; - await writeFile(tmp, JSON.stringify(state, null, 2) + ` -`, { mode: 384 }); - await rename(tmp, file); - await chmod(file, 384).catch(() => { - return; - }); + await mkdir(dirname2(file), { recursive: true, mode: 448 }); + await atomicWriteFile(file, JSON.stringify(state, null, 2) + ` +`); }, catch: (cause) => new StateWriteError({ cause }) }); diff --git a/src/atomic-write.ts b/src/atomic-write.ts new file mode 100644 index 0000000..1f1c223 --- /dev/null +++ b/src/atomic-write.ts @@ -0,0 +1,278 @@ +import { randomUUID } from "node:crypto" +import { chmod, open, rename, unlink } from "node:fs/promises" +import { dirname } from "node:path" + +/** + * Minimal file-handle surface required for the atomic write sequence. Kept + * intentionally small so tests can inject recording or failing implementations + * without touching the real filesystem. + */ +export type FileHandleOps = { + write(data: string): Promise + sync(): Promise + close(): Promise +} + +/** + * Handle surface required from a directory opener. `syncDirectory` opens the + * parent directory read-only for one purpose: fsync'ing it so the rename of a + * state file into place is durable on filesystems that support that operation. + */ +export type DirHandleOps = { + sync(): Promise + close(): Promise +} + +/** + * Injectable directory opener for `syncDirectory`. The production + * implementation opens the directory with flag `"r"`; tests inject a recording + * opener to prove open(dir, "r") -> sync -> close without touching the real + * filesystem. + */ +export type DirOpenOps = { + open(dir: string, flags: string | number): Promise +} + +/** + * Filesystem operations used by `atomicWriteFile`. Tests inject their own + * implementations to assert call ordering and failure handling. + */ +export type AtomicWriteOps = { + platform: NodeJS.Platform + open(path: string, flags: string | number, mode: number): Promise + rename(from: string, to: string): Promise + chmod(path: string, mode: number): Promise + unlink(path: string): Promise + /** + * Platform-aware parent-directory sync. Implementations should swallow errors + * that mean "directory fsync is not supported here" (see + * `isUnsupportedSyncDirError`) and propagate genuine durability failures + * such as EIO / ENOSPC / ENOENT. `atomicWriteFile` applies the same + * classification around this call, so an implementation that throws an + * unsupported-class error still degrades gracefully; genuine errors fail + * the write honestly. + */ + syncDir(dir: string, platform: NodeJS.Platform): Promise + /** Delay between bounded Windows rename retries. */ + sleep(ms: number): Promise +} + +/** + * Errors from a directory fsync that mean the platform or filesystem simply + * does not support the operation. These are not durability failures: the file + * content itself is already fsync'd before the rename, so the old-or-new + * guarantee still holds without the directory sync. + * + * - `EINVAL` / `ENOTSUP` / `EOPNOTSUPP`: filesystems that reject fsync on a + * directory descriptor (macOS/APFS & friends report EINVAL). + * - `EISDIR`: platforms that refuse directory opens for fsync purposes. + * - `EPERM` / `EACCES` / `EBADF`: Windows cannot flush directory handles; + * `FlushFileBuffers` surfaces as ERROR_ACCESS_DENIED (EACCES) or + * ERROR_INVALID_HANDLE (EBADF) via libuv, and other layering reports EPERM. + * + * Genuine failures are NOT listed and must propagate: EIO, ENOSPC, ENOENT, + * and anything else that is not a known "unsupported" signal. + */ +export function isUnsupportedSyncDirError(error: unknown, platform: NodeJS.Platform): boolean { + const code = (error as NodeJS.ErrnoException | null)?.code + return ( + code === "EINVAL" || + code === "ENOTSUP" || + code === "EOPNOTSUPP" || + code === "EISDIR" || + (platform === "win32" && (code === "EPERM" || code === "EACCES" || code === "EBADF")) + ) +} + +/** + * Errors that can transiently fail a same-directory rename, most commonly on + * Windows when the target file is briefly locked by another process (antivirus + * scanners, an OpenCode session holding the state file open). These are worth a + * small bounded retry before giving up. Anything else (EIO, ENOENT, ENOSPC...) + * is a genuine failure and must not be retried. + */ +export function isTransientRenameError(error: unknown, platform: NodeJS.Platform): boolean { + if (platform !== "win32") return false + const code = (error as NodeJS.ErrnoException | null)?.code + return code === "EPERM" || code === "EACCES" || code === "EBUSY" +} + +async function bestEffort(action: () => void | Promise): Promise { + try { + await action() + } catch { + // Cleanup and permission hardening must not mask the primary operation. + } +} + +/** Production wait between rename retries: short, bounded backoff. */ +export const defaultSleep = (ms: number): Promise => new Promise((resolve) => setTimeout(resolve, ms)) + +/** Production directory opener: opens the directory read-only. */ +export const defaultDirOpenOps: DirOpenOps = { + async open(dir, flags) { + const handle = await open(dir, flags) + return { + sync: () => handle.sync(), + close: () => handle.close(), + } + }, +} + +/** + * fsync the parent directory so the rename itself is durable where the + * platform supports that operation (POSIX with a journaling filesystem). + * Opening a directory and fsync'ing it is unsupported on Windows and on some + * filesystems; those errors are classified and swallowed. Genuine failures + * (EIO, ENOSPC, ENOENT, ...) propagate: the durability of the rename could not + * be established, and callers must hear about it. + * + * Note on macOS/APFS: `fsync` here is the ordinary POSIX fsync, not + * `F_FULLFSYNC` (which Node.js does not expose). It improves crash + * consistency (process and OS crashes) but is not an absolute guarantee under + * sudden power loss. File *content* durability is handled separately by + * fsync'ing the temp file before the rename; this helper only covers the + * durability of the rename itself. + */ +export async function syncDirectory( + dir: string, + ops: DirOpenOps = defaultDirOpenOps, + platform: NodeJS.Platform = process.platform, +): Promise { + let fsHandle: DirHandleOps | null = null + try { + const handle = await ops.open(dir, "r") + fsHandle = handle + await handle.sync() + } catch (error) { + if (!isUnsupportedSyncDirError(error, platform)) throw error + // Directory fsync unsupported on this platform/filesystem: the write still + // succeeds. After a crash the state file is the old or the new valid + // version (the temp file was fully flushed before the rename), never a + // torn one — the only thing lost by skipping this step is the durability + // of the rename itself. + } finally { + const cleanupHandle = fsHandle + if (cleanupHandle) await bestEffort(() => cleanupHandle.close()) + } +} + +/** Production implementation backed by node:fs/promises. */ +export const defaultAtomicWriteOps: AtomicWriteOps = { + platform: process.platform, + async open(path, flags, mode) { + const handle = await open(path, flags, mode) + return { + write: (data) => handle.writeFile(data), + sync: () => handle.sync(), + close: () => handle.close(), + } + }, + rename, + chmod, + unlink, + syncDir: (dir, platform) => syncDirectory(dir, defaultDirOpenOps, platform), + sleep: defaultSleep, +} + +/** Total rename attempts for transient Windows replacement errors. */ +export const RENAME_ATTEMPTS = 3 + +/** Base backoff between rename retries (multiplied by the attempt number). */ +export const RENAME_RETRY_DELAY_MS = 20 + +async function renameWithRetry(ops: AtomicWriteOps, from: string, to: string): Promise { + let attempt = 0 + for (;;) { + try { + await ops.rename(from, to) + return + } catch (error) { + attempt += 1 + // A non-transient error fails immediately; transient errors get a small, + // bounded number of attempts (renaming over a briefly-locked target). + if (!isTransientRenameError(error, ops.platform) || attempt >= RENAME_ATTEMPTS) throw error + const delay = RENAME_RETRY_DELAY_MS * attempt + await ops.sleep(delay) + } + } +} + +/** + * Durably write `data` to `file` through a same-directory temp file so the + * final path is only ever replaced atomically: + * + * open(tmp, "wx", 0o600) -> write -> sync -> close -> rename(tmp, file) + * -> chmod(file, 0o600) -> syncDir(dirname(file)) + * + * The temp name is a random UUID and the temp file is opened with the + * exclusive `"wx"` flag, so concurrent writers can never collide on the same + * temp path and a stale temp file can never be overwritten. + * + * The temp file is fsync'd before the rename, so the final path never exposes + * an empty or partially-flushed file after a crash: the state file is always + * the old or the new valid version. Ordinary fsync improves crash consistency + * (process and OS crashes); on macOS/APFS it is not a substitute for + * `F_FULLFSYNC`, which Node.js cannot issue, so sudden power loss has no + * absolute durability guarantee there. + * + * On POSIX the parent directory is fsync'd after the rename so the rename + * itself is durable too. Failures propagate unless the platform reports that + * directory fsync is unsupported; when unsupported (Windows, some + * filesystems), a crash may leave the old or the new file, but never a torn + * one. + * + * Rename is retried a bounded number of times on transient Windows + * replacement errors (`EPERM` / `EACCES` / `EBUSY`) because another process + * may briefly hold the target state file open. + * + * If any step before the rename eventually fails, the temp file is unlinked + * best-effort. The original error is preserved even when close/unlink cleanup + * also fails. Once the rename has succeeded, the final file is never unlinked: + * a later chmod or directory-sync failure is either tolerated (unsupported + * directory fsync) or propagated honestly (EIO / ENOSPC / ...) with the new + * content already in place. + */ +export async function atomicWriteFile( + file: string, + data: string, + ops: AtomicWriteOps = defaultAtomicWriteOps, +): Promise { + const tmp = `${file}.${randomUUID()}.tmp` + let handle: FileHandleOps | null = null + let created = false + let renamed = false + try { + // "wx" = create exclusively: fail with EEXIST if the temp path already + // exists, so an accidental collision can never clobber another writer's + // file. The UUID name makes collisions effectively impossible anyway. + handle = await ops.open(tmp, "wx", 0o600) + created = true + await handle.write(data) + await handle.sync() + await handle.close() + handle = null + await renameWithRetry(ops, tmp, file) + renamed = true + // The temp file was created 0o600; chmod guards against umask and any + // permissions drift between open and rename. Best-effort by design. + await bestEffort(() => ops.chmod(file, 0o600)) + // Directory sync: only known-unsupported errors are tolerated. + // Genuine durability failures (EIO, ENOSPC, ENOENT, ...) propagate — the + // rename already happened, so the new content is in place, and the caller + // deserves an honest report instead of a silent swallow. + try { + await ops.syncDir(dirname(file), ops.platform) + } catch (error) { + if (!isUnsupportedSyncDirError(error, ops.platform)) throw error + } + } catch (error) { + // Best-effort cleanup that must never mask the primary error. Only the + // temp path is ever removed; after a successful rename the final file is + // left alone (a crash there may leave old or new content, never torn). + const cleanupHandle = handle + if (cleanupHandle) await bestEffort(() => cleanupHandle.close()) + if (created && !renamed) await bestEffort(() => ops.unlink(tmp)) + throw error + } +} diff --git a/src/state.ts b/src/state.ts index e198225..6b6d59c 100644 --- a/src/state.ts +++ b/src/state.ts @@ -1,8 +1,9 @@ import { readFileSync } from "node:fs" -import { chmod, mkdir, readFile, rename, writeFile } from "node:fs/promises" +import { mkdir, readFile } from "node:fs/promises" import { homedir } from "node:os" import { dirname, join } from "node:path" import { Data, Effect, Schema } from "effect" +import { atomicWriteFile } from "./atomic-write" export type GoalStatus = "active" | "paused" | "budgetLimited" | "usageLimited" | "complete" | "unmet" export type MutableGoalStatus = "active" | "paused" @@ -284,10 +285,17 @@ function writeStateEffect(state: State) { try: async () => { const file = statePath() await mkdir(dirname(file), { recursive: true, mode: 0o700 }) - const tmp = `${file}.${process.pid}.${Date.now()}.tmp` - await writeFile(tmp, JSON.stringify(state, null, 2) + "\n", { mode: 0o600 }) - await rename(tmp, file) - await chmod(file, 0o600).catch(() => undefined) + // atomicWriteFile writes to a same-directory temp file, fsyncs it, then + // renames it into place: the final path is only ever replaced by a + // fully-flushed file, so after a process or OS crash the state is the + // old or the new valid version, never a torn or empty file. Ordinary + // fsync improves crash consistency but is not `F_FULLFSYNC`, so sudden + // power loss on macOS/APFS has no absolute durability guarantee. Where + // the platform supports it, the parent directory is also fsync'd after + // the rename so the rename itself survives a crash; where it does not + // (Windows / some filesystems) the write still succeeds and a crash + // leaves either the old or the new valid state, never a torn file. + await atomicWriteFile(file, JSON.stringify(state, null, 2) + "\n") }, catch: (cause) => new StateWriteError({ cause }), }) diff --git a/test/atomic-write.test.ts b/test/atomic-write.test.ts new file mode 100644 index 0000000..7008db5 --- /dev/null +++ b/test/atomic-write.test.ts @@ -0,0 +1,573 @@ +import { afterEach, beforeEach, expect, test } from "bun:test" +import { mkdtemp, readdir, readFile, rm, stat, writeFile } from "node:fs/promises" +import { join } from "node:path" +import { tmpdir } from "node:os" +import { + RENAME_ATTEMPTS, + RENAME_RETRY_DELAY_MS, + atomicWriteFile, + defaultAtomicWriteOps, + defaultDirOpenOps, + defaultSleep, + syncDirectory, +} from "../src/atomic-write" +import type { AtomicWriteOps, DirHandleOps, DirOpenOps, FileHandleOps } from "../src/atomic-write" + +let dir = "" +let file = "" + +beforeEach(async () => { + dir = await mkdtemp(join(tmpdir(), "opencode-goal-atomic-")) + file = join(dir, "goals.json") +}) + +afterEach(async () => { + await rm(dir, { recursive: true, force: true }) +}) + +/** + * Windows cannot express POSIX mode bits: Node reports writable files as + * 0o666 (0o444 when the read-only attribute is set). Keep the exact 0600 + * assertion on POSIX and assert "not read-only" on Windows. + */ +function expectOwnerOnlyMode(mode: number) { + if (process.platform === "win32") { + expect(mode & 0o222).not.toBe(0) + } else { + expect(mode).toBe(0o600) + } +} + +function errno(code: string, message: string) { + const error = new Error(message) as NodeJS.ErrnoException + error.code = code + return error +} + +type Event = { op: string; args: unknown[] } + +/** + * Wraps the production ops with a recorder, delegating to the real filesystem + * unless a specific op/handle method is overridden. Every invocation (including + * overridden ones) is recorded in order. + */ +function makeRecordingOps(overrides: { ops?: Partial; handle?: Partial } = {}) { + const events: Event[] = [] + const record = (op: string, args: unknown[]) => events.push({ op, args }) + + const ops: AtomicWriteOps = { + platform: overrides.ops?.platform ?? process.platform, + async open(path, flags, mode) { + record("open", [path, flags, mode]) + if (overrides.ops?.open) return overrides.ops.open(path, flags, mode) + const inner = await defaultAtomicWriteOps.open(path, flags, mode) + return { + write: (data) => { + record("write", [data]) + return overrides.handle?.write ? overrides.handle.write(data) : inner.write(data) + }, + sync: () => { + record("sync", []) + return overrides.handle?.sync ? overrides.handle.sync() : inner.sync() + }, + close: () => { + record("close", []) + return overrides.handle?.close ? overrides.handle.close() : inner.close() + }, + } + }, + rename(from, to) { + record("rename", [from, to]) + return overrides.ops?.rename ? overrides.ops.rename(from, to) : defaultAtomicWriteOps.rename(from, to) + }, + chmod(path, mode) { + record("chmod", [path, mode]) + return overrides.ops?.chmod ? overrides.ops.chmod(path, mode) : defaultAtomicWriteOps.chmod(path, mode) + }, + unlink(path) { + record("unlink", [path]) + return overrides.ops?.unlink ? overrides.ops.unlink(path) : defaultAtomicWriteOps.unlink(path) + }, + syncDir(dir, platform) { + record("syncDir", [dir, platform]) + return overrides.ops?.syncDir + ? overrides.ops.syncDir(dir, platform) + : defaultAtomicWriteOps.syncDir(dir, platform) + }, + sleep(ms) { + record("sleep", [ms]) + return overrides.ops?.sleep ? overrides.ops.sleep(ms) : Promise.resolve() + }, + } + + const opsCalled = () => events.map((event) => event.op) + const argsFor = (op: string) => { + const event = events.find((candidate) => candidate.op === op) + if (!event) throw new Error(`expected an ${op} call, got ${opsCalled().join(", ")}`) + return event.args + } + const allArgsFor = (op: string) => events.filter((candidate) => candidate.op === op).map((candidate) => candidate.args) + return { ops, events, opsCalled, argsFor, allArgsFor } +} + +async function leftoverTmpFiles() { + const entries = await readdir(dir) + return entries.filter((name) => name.endsWith(".tmp")) +} + +const DATA = '{"version":1}\n' + +test("writes through the full atomic sequence in order and persists content and mode", async () => { + const { ops, opsCalled, argsFor } = makeRecordingOps() + + await atomicWriteFile(file, DATA, ops) + + expect(opsCalled()).toEqual(["open", "write", "sync", "close", "rename", "chmod", "syncDir"]) + + // The temp file lives next to the final file (same directory) so the rename + // is atomic, its name is a random UUID, and it is opened exclusively with + // owner-only mode. + const openArgs = argsFor("open") + const tmpPath = openArgs[0] as string + expect(tmpPath.startsWith(`${file}.`)).toBe(true) + expect(tmpPath.endsWith(".tmp")).toBe(true) + const tmpName = tmpPath.slice(file.length + 1, -".tmp".length) + expect(tmpName).toMatch(/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/) + expect(openArgs[1]).toBe("wx") + expect(openArgs[2]).toBe(0o600) + + // rename moves the temp file onto the final path; chmod and the parent + // directory sync both run after the rename. + expect(argsFor("rename")).toEqual([tmpPath, file]) + expect(argsFor("chmod")[1]).toBe(0o600) + expect(argsFor("syncDir")[0]).toBe(dir) + + expect(await readFile(file, "utf8")).toBe(DATA) + expectOwnerOnlyMode((await stat(file)).mode & 0o777) + expect(await leftoverTmpFiles()).toEqual([]) +}) + +test("uses a fresh, exclusive temp path for every write", async () => { + const first = makeRecordingOps() + await atomicWriteFile(file, DATA, first.ops) + const second = makeRecordingOps() + await atomicWriteFile(file, DATA, second.ops) + + const firstTmp = first.argsFor("open")[0] as string + const secondTmp = second.argsFor("open")[0] as string + expect(firstTmp).not.toBe(secondTmp) + expect(first.argsFor("open")[1]).toBe("wx") + expect(second.argsFor("open")[1]).toBe("wx") + expect(await readFile(file, "utf8")).toBe(DATA) +}) + +test("unlinks the temp file when sync fails and preserves the sync error", async () => { + await writeFile(file, "old state", "utf8") + const { ops, opsCalled } = makeRecordingOps({ + handle: { + sync: async () => { + throw new Error("sync boom") + }, + }, + }) + + await expect(atomicWriteFile(file, "new state", ops)).rejects.toThrow("sync boom") + + // The handle is still best-effort closed before the temp file is unlinked. + expect(opsCalled()).toEqual(["open", "write", "sync", "close", "unlink"]) + expect(await readFile(file, "utf8")).toBe("old state") + expect(await leftoverTmpFiles()).toEqual([]) +}) + +test("preserves the primary error when close and unlink cleanup throw synchronously", async () => { + await writeFile(file, "old state", "utf8") + const primary = new Error("sync boom") + const { ops, opsCalled } = makeRecordingOps({ + handle: { + sync: async () => { + throw primary + }, + close: () => { + throw new Error("close boom") + }, + }, + ops: { + unlink: () => { + throw new Error("unlink boom") + }, + }, + }) + + await expect(atomicWriteFile(file, "new state", ops)).rejects.toThrow("sync boom") + expect(opsCalled()).toEqual(["open", "write", "sync", "close", "unlink"]) + expect(await readFile(file, "utf8")).toBe("old state") +}) + +test("unlinks the temp file when rename fails and leaves the final file untouched", async () => { + await writeFile(file, "old state", "utf8") + const { ops, opsCalled } = makeRecordingOps({ + ops: { + rename: async () => { + throw errno("EIO", "rename boom") + }, + }, + }) + + await expect(atomicWriteFile(file, "new state", ops)).rejects.toThrow("rename boom") + + expect(opsCalled()).toEqual(["open", "write", "sync", "close", "rename", "unlink"]) + expect(await readFile(file, "utf8")).toBe("old state") + expect(await leftoverTmpFiles()).toEqual([]) +}) + +test("an open failure never unlinks or touches the final file", async () => { + await writeFile(file, "old state", "utf8") + const { ops, opsCalled } = makeRecordingOps({ + ops: { + open: async () => { + throw new Error("open boom") + }, + }, + }) + + await expect(atomicWriteFile(file, "new state", ops)).rejects.toThrow("open boom") + + // No temp file was ever created, so there is nothing to clean up. + expect(opsCalled()).toEqual(["open"]) + expect(await readFile(file, "utf8")).toBe("old state") + expect(await leftoverTmpFiles()).toEqual([]) +}) + +test("unlinks the temp file when the write fails", async () => { + await writeFile(file, "old state", "utf8") + const { ops, opsCalled } = makeRecordingOps({ + handle: { + write: async () => { + throw new Error("write boom") + }, + }, + }) + + await expect(atomicWriteFile(file, "new state", ops)).rejects.toThrow("write boom") + + expect(opsCalled()).toEqual(["open", "write", "close", "unlink"]) + expect(await readFile(file, "utf8")).toBe("old state") + expect(await leftoverTmpFiles()).toEqual([]) +}) + +test("a close failure before rename fails the write and cleans up the temp file", async () => { + await writeFile(file, "old state", "utf8") + const { ops, opsCalled } = makeRecordingOps({ + handle: { + close: async () => { + throw new Error("close boom") + }, + }, + }) + + await expect(atomicWriteFile(file, "new state", ops)).rejects.toThrow("close boom") + + // The failing success-path close plus the best-effort cleanup close both run; + // the temp file is still removed and the close error stays the primary one. + expect(opsCalled()).toEqual(["open", "write", "sync", "close", "close", "unlink"]) + expect(await readFile(file, "utf8")).toBe("old state") + expect(await leftoverTmpFiles()).toEqual([]) +}) + +test("an unsupported directory fsync is skipped without failing the write", async () => { + const { ops, opsCalled } = makeRecordingOps({ + ops: { + syncDir: async () => { + throw errno("EINVAL", "directory fsync not supported") + }, + }, + }) + + // Windows / filesystems without directory fsync must degrade gracefully. + await atomicWriteFile(file, DATA, ops) + + expect(opsCalled()).toEqual(["open", "write", "sync", "close", "rename", "chmod", "syncDir"]) + expect(await readFile(file, "utf8")).toBe(DATA) + expect(await leftoverTmpFiles()).toEqual([]) +}) + +test("a genuine directory-sync failure fails the write but keeps the renamed file", async () => { + const { ops, opsCalled } = makeRecordingOps({ + ops: { + syncDir: async () => { + throw errno("EIO", "disk error while flushing directory") + }, + }, + }) + + await expect(atomicWriteFile(file, DATA, ops)).rejects.toThrow("disk error while flushing directory") + + // The rename had already succeeded, so the final file holds the new content; + // durability of the rename could not be established and that is reported + // honestly. The final file is never unlinked. + expect(opsCalled()).toEqual(["open", "write", "sync", "close", "rename", "chmod", "syncDir"]) + expect(await readFile(file, "utf8")).toBe(DATA) + expect(await leftoverTmpFiles()).toEqual([]) +}) + +test("a synchronous chmod throw after rename does not fail the write and never unlinks", async () => { + const { ops, opsCalled } = makeRecordingOps({ + ops: { + chmod: () => { + throw new Error("chmod boom") + }, + }, + }) + + await atomicWriteFile(file, DATA, ops) + + expect(opsCalled()).toEqual(["open", "write", "sync", "close", "rename", "chmod", "syncDir"]) + expect(await readFile(file, "utf8")).toBe(DATA) + expect(await leftoverTmpFiles()).toEqual([]) +}) + +test("the default directory sync completes for an existing directory", async () => { + await expect(defaultAtomicWriteOps.syncDir(dir, process.platform)).resolves.toBeUndefined() +}) + +test("the default directory sync propagates genuine errors such as ENOENT", async () => { + // A missing directory is a real problem, not "unsupported": it must surface. + await expect(defaultAtomicWriteOps.syncDir(join(dir, "does-not-exist"), process.platform)).rejects.toMatchObject({ + code: "ENOENT", + }) +}) + +test("the real directory sync helper opens the directory read-only, syncs, then closes", async () => { + const events: string[] = [] + const handle: DirHandleOps = { + sync: async () => { + events.push("sync") + }, + close: async () => { + events.push("close") + }, + } + const dirOps: DirOpenOps = { + open: async (path, flags) => { + events.push(`open:${path}:${String(flags)}`) + return handle + }, + } + + await syncDirectory(dir, dirOps) + + expect(events).toEqual([`open:${dir}:r`, "sync", "close"]) +}) + +test("the real directory sync helper swallows unsupported fsync errors and propagates genuine ones", async () => { + for (const code of ["EINVAL", "ENOTSUP", "EOPNOTSUPP", "EISDIR"] as const) { + const unsupported: DirOpenOps = { + open: () => { + throw errno(code, `${code}: fsync not supported on directories here`) + }, + } + await expect(syncDirectory(dir, unsupported, "darwin")).resolves.toBeUndefined() + await expect(syncDirectory(dir, unsupported, "win32")).resolves.toBeUndefined() + } + + const genuine: DirOpenOps = { + open: async () => { + throw errno("EIO", "disk gone") + }, + } + await expect(syncDirectory(dir, genuine)).rejects.toThrow("disk gone") +}) + +test("the directory sync helper ignores a synchronous close cleanup failure", async () => { + const dirOps: DirOpenOps = { + open: async () => ({ + sync: async () => undefined, + close: () => { + throw new Error("close boom") + }, + }), + } + + await expect(syncDirectory(dir, dirOps)).resolves.toBeUndefined() +}) + +for (const code of ["EPERM", "EACCES", "EBADF"] as const) { + test(`directory sync propagates ${code} on POSIX and tolerates it on Windows`, async () => { + const unsupportedOnWindows: DirOpenOps = { + open: () => { + throw errno(code, `${code}: directory handle cannot be flushed`) + }, + } + + await expect(syncDirectory(dir, unsupportedOnWindows, "darwin")).rejects.toThrow(code) + await expect(syncDirectory(dir, unsupportedOnWindows, "win32")).resolves.toBeUndefined() + + const posix = makeRecordingOps({ + ops: { + platform: "darwin", + syncDir: () => { + throw errno(code, `${code}: directory sync denied`) + }, + }, + }) + await expect(atomicWriteFile(file, DATA, posix.ops)).rejects.toThrow(code) + + const windows = makeRecordingOps({ + ops: { + platform: "win32", + syncDir: () => { + throw errno(code, `${code}: directory sync unsupported`) + }, + }, + }) + await expect(atomicWriteFile(file, DATA, windows.ops)).resolves.toBeUndefined() + expect(await readFile(file, "utf8")).toBe(DATA) + expect(await leftoverTmpFiles()).toEqual([]) + }) +} + +test("the default rename backoff waits asynchronously", async () => { + let settled = false + const sleeping = defaultSleep(10).then(() => { + settled = true + }) + + await Promise.resolve() + expect(settled).toBe(false) + await sleeping + expect(settled).toBe(true) +}) + +test("the real directory open passes the read-only flag through to node:fs", async () => { + const events: string[] = [] + const dirOps: DirOpenOps = { + async open(path, flags) { + // Wrap the production opener so the real open(dir, "r") occurs. + const handle = await defaultDirOpenOps.open(path, flags) + events.push(`open:${path}:${String(flags)}`) + return { + sync: () => { + events.push("sync") + return handle.sync() + }, + close: () => { + events.push("close") + return handle.close() + }, + } + }, + } + + await syncDirectory(dir, dirOps) + + expect(events).toEqual([`open:${dir}:r`, "sync", "close"]) +}) + +for (const code of ["EPERM", "EACCES", "EBUSY"] as const) { + test(`rename retries a transient Windows ${code} failure and succeeds`, async () => { + let failures = 1 + const { ops, opsCalled, allArgsFor, argsFor } = makeRecordingOps({ + ops: { + platform: "win32", + rename: async (from, to) => { + if (failures > 0) { + failures -= 1 + throw errno(code, `${code}: target temporarily locked`) + } + return defaultAtomicWriteOps.rename(from, to) + }, + }, + }) + + await atomicWriteFile(file, DATA, ops) + + expect(opsCalled()).toEqual([ + "open", + "write", + "sync", + "close", + "rename", + "sleep", + "rename", + "chmod", + "syncDir", + ]) + // One short backoff between the two rename attempts. + expect(argsFor("sleep")[0]).toBe(RENAME_RETRY_DELAY_MS) + expect(allArgsFor("rename")).toHaveLength(2) + expect(await readFile(file, "utf8")).toBe(DATA) + expect(await leftoverTmpFiles()).toEqual([]) + }) + + test(`rename does not retry ${code} on POSIX`, async () => { + await writeFile(file, "old state", "utf8") + const { ops, opsCalled, allArgsFor } = makeRecordingOps({ + ops: { + platform: "darwin", + rename: () => { + throw errno(code, `${code}: rename denied`) + }, + }, + }) + + await expect(atomicWriteFile(file, "new state", ops)).rejects.toThrow(`${code}: rename denied`) + + expect(allArgsFor("rename")).toHaveLength(1) + expect(opsCalled()).toEqual(["open", "write", "sync", "close", "rename", "unlink"]) + expect(await readFile(file, "utf8")).toBe("old state") + }) +} + +test("rename retry exhaustion fails, preserves the old file, and cleans the temp file", async () => { + await writeFile(file, "old state", "utf8") + const { ops, opsCalled, allArgsFor } = makeRecordingOps({ + ops: { + platform: "win32", + rename: async () => { + throw errno("EPERM", "EPERM: target locked") + }, + }, + }) + + await expect(atomicWriteFile(file, "new state", ops)).rejects.toThrow("EPERM: target locked") + + expect(allArgsFor("rename")).toHaveLength(RENAME_ATTEMPTS) + expect(opsCalled()).toEqual([ + "open", + "write", + "sync", + "close", + "rename", + "sleep", + "rename", + "sleep", + "rename", + "unlink", + ]) + // Backoff grows per attempt: 20ms then 40ms. + expect(allArgsFor("sleep").map((args) => args[0])).toEqual([ + RENAME_RETRY_DELAY_MS, + RENAME_RETRY_DELAY_MS * 2, + ]) + // The old file survived and the temp file was cleaned up. + expect(await readFile(file, "utf8")).toBe("old state") + expect(await leftoverTmpFiles()).toEqual([]) +}) + +test("a non-transient rename error is never retried", async () => { + await writeFile(file, "old state", "utf8") + const { ops, opsCalled, allArgsFor } = makeRecordingOps({ + ops: { + rename: async () => { + throw errno("EIO", "EIO: disk error") + }, + }, + }) + + await expect(atomicWriteFile(file, "new state", ops)).rejects.toThrow("EIO: disk error") + + expect(allArgsFor("rename")).toHaveLength(1) + expect(opsCalled()).toEqual(["open", "write", "sync", "close", "rename", "unlink"]) + expect(await readFile(file, "utf8")).toBe("old state") + expect(await leftoverTmpFiles()).toEqual([]) +}) diff --git a/test/state.test.ts b/test/state.test.ts index cdc8463..f4d74d1 100644 --- a/test/state.test.ts +++ b/test/state.test.ts @@ -273,7 +273,14 @@ test("writes state with owner-only file permissions", async () => { const mode = (await stat(process.env.OPENCODE_GOAL_STATE_PATH!)).mode & 0o777 - expect(mode).toBe(0o600) + if (process.platform === "win32") { + // Windows cannot express POSIX mode bits: Node reports writable files as + // 0o666 (0o444 when read-only). Assert the file is not read-only there, + // while POSIX keeps the exact 0600 assertion. + expect(mode & 0o222).not.toBe(0) + } else { + expect(mode).toBe(0o600) + } }) test("does not overwrite corrupt persisted state", async () => {