fix(shared): handle Windows rename-over-existing in write-file-atomically (#3222)
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
This commit is contained in:
@@ -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()
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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 {
|
export function writeFileAtomically(filePath: string, content: string): void {
|
||||||
const tempPath = `${filePath}.tmp`
|
const tempPath = `${filePath}.tmp`
|
||||||
writeFileSync(tempPath, content, "utf-8")
|
writeFileSync(tempPath, content, "utf-8")
|
||||||
const tempFileDescriptor = openSync(tempPath, "r")
|
const tempFileDescriptor = openSync(tempPath, "r")
|
||||||
try {
|
try {
|
||||||
fsyncSync(tempFileDescriptor)
|
fsyncSync(tempFileDescriptor)
|
||||||
} finally {
|
} finally {
|
||||||
closeSync(tempFileDescriptor)
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user