feat(shared): add tolerant-fsync utility for synced-folder filesystems
Adds isToleratedFsyncError, tolerantFsync (async, FileHandle), and tolerantFsyncSync (sync, fd) helpers that swallow filesystem-limitation errors during fsync (EPERM, EACCES, ENOTSUP, EINVAL) while still propagating real errors (EIO, ENOSPC, EBADF, etc.). Synced folders like iCloud Drive, OneDrive, and antivirus-locked files reject fsync with EPERM even though the underlying write+rename succeeded; for the runtime data this codebase persists, losing the durability hint is acceptable in exchange for not blocking the operation entirely. The helper is intentionally not barrel-exported (consumers import the file directly), matching the existing convention for write-file-atomically.
This commit is contained in:
@@ -0,0 +1,135 @@
|
||||
import { describe, expect, it } from "bun:test"
|
||||
import { fsyncSync } from "node:fs"
|
||||
import type { FileHandle } from "node:fs/promises"
|
||||
|
||||
import { isToleratedFsyncError, tolerantFsync, tolerantFsyncSync } from "./tolerant-fsync"
|
||||
|
||||
function makeFsError(code: string, message?: string): NodeJS.ErrnoException {
|
||||
const error = new Error(message ?? `${code}: simulated`) as NodeJS.ErrnoException
|
||||
error.code = code
|
||||
return error
|
||||
}
|
||||
|
||||
function fakeHandleWithSyncError(error: NodeJS.ErrnoException): FileHandle {
|
||||
return {
|
||||
sync: async () => {
|
||||
throw error
|
||||
},
|
||||
} as FileHandle
|
||||
}
|
||||
|
||||
describe("isToleratedFsyncError", () => {
|
||||
it("#given EPERM error #when checked #then returns true", () => {
|
||||
expect(isToleratedFsyncError(makeFsError("EPERM"))).toBe(true)
|
||||
})
|
||||
|
||||
it("#given EACCES error #when checked #then returns true", () => {
|
||||
expect(isToleratedFsyncError(makeFsError("EACCES"))).toBe(true)
|
||||
})
|
||||
|
||||
it("#given ENOTSUP error #when checked #then returns true", () => {
|
||||
expect(isToleratedFsyncError(makeFsError("ENOTSUP"))).toBe(true)
|
||||
})
|
||||
|
||||
it("#given EINVAL error #when checked #then returns true", () => {
|
||||
expect(isToleratedFsyncError(makeFsError("EINVAL"))).toBe(true)
|
||||
})
|
||||
|
||||
it("#given EIO error #when checked #then returns false", () => {
|
||||
expect(isToleratedFsyncError(makeFsError("EIO"))).toBe(false)
|
||||
})
|
||||
|
||||
it("#given ENOSPC error (disk full) #when checked #then returns false", () => {
|
||||
expect(isToleratedFsyncError(makeFsError("ENOSPC"))).toBe(false)
|
||||
})
|
||||
|
||||
it("#given EBADF error (bad fd) #when checked #then returns false", () => {
|
||||
expect(isToleratedFsyncError(makeFsError("EBADF"))).toBe(false)
|
||||
})
|
||||
|
||||
it("#given non-Error value #when checked #then returns false", () => {
|
||||
expect(isToleratedFsyncError("EPERM string")).toBe(false)
|
||||
expect(isToleratedFsyncError(null)).toBe(false)
|
||||
expect(isToleratedFsyncError(undefined)).toBe(false)
|
||||
expect(isToleratedFsyncError({ code: "EPERM" })).toBe(false)
|
||||
})
|
||||
|
||||
it("#given Error without code #when checked #then returns false", () => {
|
||||
expect(isToleratedFsyncError(new Error("no code"))).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe("tolerantFsync (async)", () => {
|
||||
it("#given fsync throws EPERM #when called #then resolves without throwing", async () => {
|
||||
const handle = fakeHandleWithSyncError(makeFsError("EPERM", "operation not permitted, fsync"))
|
||||
await expect(tolerantFsync(handle, "test:async-eperm")).resolves.toBeUndefined()
|
||||
})
|
||||
|
||||
it("#given fsync throws EACCES #when called #then resolves without throwing", async () => {
|
||||
const handle = fakeHandleWithSyncError(makeFsError("EACCES"))
|
||||
await expect(tolerantFsync(handle, "test:async-eacces")).resolves.toBeUndefined()
|
||||
})
|
||||
|
||||
it("#given fsync throws ENOTSUP #when called #then resolves without throwing", async () => {
|
||||
const handle = fakeHandleWithSyncError(makeFsError("ENOTSUP"))
|
||||
await expect(tolerantFsync(handle, "test:async-enotsup")).resolves.toBeUndefined()
|
||||
})
|
||||
|
||||
it("#given fsync throws EINVAL #when called #then resolves without throwing", async () => {
|
||||
const handle = fakeHandleWithSyncError(makeFsError("EINVAL"))
|
||||
await expect(tolerantFsync(handle, "test:async-einval")).resolves.toBeUndefined()
|
||||
})
|
||||
|
||||
it("#given fsync throws EIO #when called #then propagates the error", async () => {
|
||||
const handle = fakeHandleWithSyncError(makeFsError("EIO"))
|
||||
await expect(tolerantFsync(handle, "test:async-eio")).rejects.toThrow("EIO: simulated")
|
||||
})
|
||||
|
||||
it("#given fsync throws ENOSPC #when called #then propagates the error", async () => {
|
||||
const handle = fakeHandleWithSyncError(makeFsError("ENOSPC"))
|
||||
await expect(tolerantFsync(handle, "test:async-enospc")).rejects.toThrow("ENOSPC: simulated")
|
||||
})
|
||||
|
||||
it("#given fsync succeeds #when called #then resolves and sync was invoked", async () => {
|
||||
let syncCalled = false
|
||||
const handle = {
|
||||
sync: async () => {
|
||||
syncCalled = true
|
||||
},
|
||||
} as FileHandle
|
||||
await tolerantFsync(handle, "test:async-success")
|
||||
expect(syncCalled).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe("tolerantFsyncSync (synchronous)", () => {
|
||||
it("#given fsyncSync throws EPERM #when called #then returns without throwing", () => {
|
||||
const fakeFsync = ((_fileDescriptor: number): void => {
|
||||
throw makeFsError("EPERM", "operation not permitted, fsync")
|
||||
}) as typeof fsyncSync
|
||||
expect(() => tolerantFsyncSync(123, "test:sync-eperm", fakeFsync)).not.toThrow()
|
||||
})
|
||||
|
||||
it("#given fsyncSync throws EACCES #when called #then returns without throwing", () => {
|
||||
const fakeFsync = ((_fileDescriptor: number): void => {
|
||||
throw makeFsError("EACCES")
|
||||
}) as typeof fsyncSync
|
||||
expect(() => tolerantFsyncSync(123, "test:sync-eacces", fakeFsync)).not.toThrow()
|
||||
})
|
||||
|
||||
it("#given fsyncSync throws EIO #when called #then propagates the error", () => {
|
||||
const fakeFsync = ((_fileDescriptor: number): void => {
|
||||
throw makeFsError("EIO")
|
||||
}) as typeof fsyncSync
|
||||
expect(() => tolerantFsyncSync(123, "test:sync-eio", fakeFsync)).toThrow("EIO: simulated")
|
||||
})
|
||||
|
||||
it("#given fsyncSync succeeds #when called #then returns and impl was invoked", () => {
|
||||
let called = false
|
||||
const fakeFsync = ((_fileDescriptor: number): void => {
|
||||
called = true
|
||||
}) as typeof fsyncSync
|
||||
tolerantFsyncSync(123, "test:sync-success", fakeFsync)
|
||||
expect(called).toBe(true)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,52 @@
|
||||
import { fsyncSync } from "node:fs"
|
||||
import type { FileHandle } from "node:fs/promises"
|
||||
|
||||
import { log } from "./logger"
|
||||
|
||||
const TOLERATED_FSYNC_CODES: ReadonlySet<string> = new Set([
|
||||
"EPERM",
|
||||
"EACCES",
|
||||
"ENOTSUP",
|
||||
"EINVAL",
|
||||
])
|
||||
|
||||
export function isToleratedFsyncError(error: unknown): boolean {
|
||||
if (!(error instanceof Error)) return false
|
||||
const code = (error as NodeJS.ErrnoException).code
|
||||
return code !== undefined && TOLERATED_FSYNC_CODES.has(code)
|
||||
}
|
||||
|
||||
export async function tolerantFsync(
|
||||
fileHandle: FileHandle,
|
||||
contextLabel: string,
|
||||
): Promise<void> {
|
||||
try {
|
||||
await fileHandle.sync()
|
||||
} catch (error) {
|
||||
if (!isToleratedFsyncError(error)) throw error
|
||||
log("fsync skipped due to filesystem limitation", {
|
||||
event: "fsync-skipped",
|
||||
contextLabel,
|
||||
code: (error as NodeJS.ErrnoException).code,
|
||||
message: error instanceof Error ? error.message : String(error),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
export function tolerantFsyncSync(
|
||||
fileDescriptor: number,
|
||||
contextLabel: string,
|
||||
fsyncImpl: typeof fsyncSync = fsyncSync,
|
||||
): void {
|
||||
try {
|
||||
fsyncImpl(fileDescriptor)
|
||||
} catch (error) {
|
||||
if (!isToleratedFsyncError(error)) throw error
|
||||
log("fsync skipped due to filesystem limitation", {
|
||||
event: "fsync-skipped",
|
||||
contextLabel,
|
||||
code: (error as NodeJS.ErrnoException).code,
|
||||
message: error instanceof Error ? error.message : String(error),
|
||||
})
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user