diff --git a/src/features/team-mode/team-state-store/locks.test.ts b/src/features/team-mode/team-state-store/locks.test.ts index a1d2ad0c8..0e458486c 100644 --- a/src/features/team-mode/team-state-store/locks.test.ts +++ b/src/features/team-mode/team-state-store/locks.test.ts @@ -1,6 +1,6 @@ import { expect, test } from "bun:test" -import type { PathLike } from "node:fs" -import { mkdtemp, readdir, readFile, rm, writeFile } from "node:fs/promises" +import type { Mode, OpenMode, PathLike } from "node:fs" +import { mkdtemp, open, readdir, readFile, rename, rm, writeFile } from "node:fs/promises" import { tmpdir } from "node:os" import { join } from "node:path" @@ -72,6 +72,29 @@ test("atomicWrite leaves no partial file when rename fails", async () => { await rm(rootDirectory, { recursive: true, force: true }) }) +test("atomicWrite syncs temp files through a writable handle", async () => { + // given + const rootDirectory = await createTempDirectory("locks-atomic-writable-") + const targetPath = join(rootDirectory, "target.txt") + const openFlags: string[] = [] + + const { atomicWrite } = await import("./locks") + + // when + await atomicWrite(targetPath, "new content", { + rename, + open: async (filePath: PathLike, flags?: OpenMode, mode?: Mode) => { + openFlags.push(String(flags)) + return await open(filePath, flags, mode) + }, + }) + + // then + expect(openFlags).toEqual(["wx"]) + expect(await readFile(targetPath, "utf8")).toBe("new content") + await rm(rootDirectory, { recursive: true, force: true }) +}) + test("detects and reaps stale lock entries", async () => { // given const { detectStaleLock, reapStaleLock } = await import("./locks") diff --git a/src/features/team-mode/team-state-store/locks.ts b/src/features/team-mode/team-state-store/locks.ts index 2f7a4d4a0..062be5846 100644 --- a/src/features/team-mode/team-state-store/locks.ts +++ b/src/features/team-mode/team-state-store/locks.ts @@ -1,5 +1,5 @@ import { randomUUID } from "node:crypto" -import { open, readFile, rename, rm, unlink, writeFile } from "node:fs/promises" +import { open, readFile, rename, rm, unlink } from "node:fs/promises" import { tolerantFsync } from "../../../shared/tolerant-fsync" @@ -8,6 +8,12 @@ type LockOptions = { ownerTag?: string } +type AtomicWriteDeps = { + open?: typeof open + rename?: typeof rename + rm?: typeof rm +} + const LOCK_RETRY_MS = 50 const LOCK_WAIT_TIMEOUT_MS = 4_000 @@ -110,21 +116,24 @@ export async function reapStaleLock(lockPath: string): Promise { export async function atomicWrite( filePath: string, content: string | Buffer, - deps: { rename: typeof rename } = { rename }, + deps: AtomicWriteDeps = {}, ): Promise { const tmpPath = `${filePath}.tmp.${randomUUID()}` + const openFile = deps.open ?? open + const renameFile = deps.rename ?? rename + const removeFile = deps.rm ?? rm try { - await writeFile(tmpPath, content) - const fileHandle = await open(tmpPath, "r") + const fileHandle = await openFile(tmpPath, "wx") try { + await fileHandle.writeFile(content) await tolerantFsync(fileHandle, `atomicWrite:${filePath}`) } finally { await fileHandle.close() } - await deps.rename(tmpPath, filePath) + await renameFile(tmpPath, filePath) } catch (error) { - await rm(tmpPath, { force: true }) + await removeFile(tmpPath, { force: true }) throw error } }