From 7accb53cbb0ed996ca758f4653b6801f322ee1b9 Mon Sep 17 00:00:00 2001 From: YeonGyu-Kim Date: Sun, 12 Apr 2026 02:28:05 +0900 Subject: [PATCH] fix(shared): handle Windows rename-over-existing in write-file-atomically (#3222) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit On Windows, renameSync fails with EPERM/EACCES when the target file already exists. Fall back to unlink + rename on Windows permission errors while preserving atomic semantics on other platforms. 🤖 Generated with OhMyOpenCode assistance https://github.com/code-yeongyu/oh-my-opencode --- src/shared/write-file-atomically.test.ts | 54 ++++++++++++++++++++++++ src/shared/write-file-atomically.ts | 23 ++++++++-- 2 files changed, 73 insertions(+), 4 deletions(-) create mode 100644 src/shared/write-file-atomically.test.ts diff --git a/src/shared/write-file-atomically.test.ts b/src/shared/write-file-atomically.test.ts new file mode 100644 index 000000000..ce4a5c8f9 --- /dev/null +++ b/src/shared/write-file-atomically.test.ts @@ -0,0 +1,54 @@ +import { describe, it, expect, beforeEach, afterEach } from "bun:test" +import { readFileSync, writeFileSync, existsSync, rmSync, mkdirSync } from "fs" +import { join } from "path" +import { tmpdir } from "os" +import { writeFileAtomically } from "./write-file-atomically" + +const testDir = join(tmpdir(), "write-file-atomically-test-" + Date.now()) + +beforeEach(() => { + mkdirSync(testDir, { recursive: true }) +}) + +afterEach(() => { + rmSync(testDir, { recursive: true, force: true }) +}) + +describe("writeFileAtomically", () => { + it("writes content to a new file", () => { + // given + const filePath = join(testDir, "new-file.txt") + const content = "hello world" + + // when + writeFileAtomically(filePath, content) + + // then + expect(existsSync(filePath)).toBe(true) + expect(readFileSync(filePath, "utf-8")).toBe(content) + }) + + it("#given target file exists #when writeFileAtomically called #then overwrites successfully", () => { + // given + const filePath = join(testDir, "existing-file.txt") + const originalContent = "original content" + const newContent = "new content" + writeFileSync(filePath, originalContent, "utf-8") + + // when + writeFileAtomically(filePath, newContent) + + // then + expect(existsSync(filePath)).toBe(true) + expect(readFileSync(filePath, "utf-8")).toBe(newContent) + expect(existsSync(`${filePath}.tmp`)).toBe(false) + }) + + it("#given parent directory does not exist #when writeFileAtomically called #then throws", () => { + // given + const filePath = join(testDir, "nonexistent", "deep", "file.txt") + + // when/then + expect(() => writeFileAtomically(filePath, "content")).toThrow() + }) +}) diff --git a/src/shared/write-file-atomically.ts b/src/shared/write-file-atomically.ts index 81bcc5249..9e9f123bc 100644 --- a/src/shared/write-file-atomically.ts +++ b/src/shared/write-file-atomically.ts @@ -1,13 +1,28 @@ -import { closeSync, fsyncSync, openSync, renameSync, writeFileSync } from "node:fs" +import { closeSync, fsyncSync, openSync, renameSync, unlinkSync, writeFileSync } from "node:fs" export function writeFileAtomically(filePath: string, content: string): void { - const tempPath = `${filePath}.tmp` - writeFileSync(tempPath, content, "utf-8") + const tempPath = `${filePath}.tmp` + writeFileSync(tempPath, content, "utf-8") const tempFileDescriptor = openSync(tempPath, "r") try { fsyncSync(tempFileDescriptor) } finally { closeSync(tempFileDescriptor) } - renameSync(tempPath, filePath) + + try { + renameSync(tempPath, filePath) + } catch (error) { + const isWindows = process.platform === "win32" + const isPermissionError = + error instanceof Error && + (error.message.includes("EPERM") || error.message.includes("EACCES")) + + if (isWindows && isPermissionError) { + unlinkSync(filePath) + renameSync(tempPath, filePath) + } else { + throw error + } + } }