diff --git a/src/shared/classify-path-environment.test.ts b/src/shared/classify-path-environment.test.ts new file mode 100644 index 000000000..0fc45c4b7 --- /dev/null +++ b/src/shared/classify-path-environment.test.ts @@ -0,0 +1,56 @@ +import { describe, expect, it } from "bun:test" + +import { + classifyPathEnvironment, + describePathClassification, +} from "./classify-path-environment" + +describe("classifyPathEnvironment", () => { + it("classifies macOS iCloud path as icloud", () => { + expect( + classifyPathEnvironment( + "/Users/x/Library/Mobile Documents/com~apple~CloudDocs/project/file.txt", + ), + ).toBe("icloud") + }) + + it("classifies OneDrive path on unix style", () => { + expect(classifyPathEnvironment("/Users/x/OneDrive/foo")).toBe("onedrive") + }) + + it("classifies OneDrive path on windows style", () => { + expect(classifyPathEnvironment("C:\\Users\\x\\OneDrive\\foo")).toBe("onedrive") + }) + + it("classifies macOS Desktop path as desktop-sync", () => { + expect(classifyPathEnvironment("/Users/x/Desktop/foo")).toBe("desktop-sync") + }) + + it("classifies /Volumes path as network-drive", () => { + expect(classifyPathEnvironment("/Volumes/NetworkShare/foo")).toBe("network-drive") + }) + + it("classifies random path as unknown", () => { + expect(classifyPathEnvironment("/tmp/foo")).toBe("unknown") + }) + + it("classifies empty string as unknown", () => { + expect(classifyPathEnvironment("")).toBe("unknown") + }) + + it("matches OneDrive case-insensitively", () => { + expect(classifyPathEnvironment("/Users/x/oNeDrIvE/foo")).toBe("onedrive") + }) +}) + +describe("describePathClassification", () => { + it("returns human-readable descriptions", () => { + expect(describePathClassification("icloud")).toBe("iCloud Drive") + expect(describePathClassification("onedrive")).toBe("OneDrive") + expect(describePathClassification("desktop-sync")).toBe("Desktop sync (macOS)") + expect(describePathClassification("network-drive")).toBe("Network drive") + expect(describePathClassification("unknown")).toBe( + "filesystem that does not support fsync", + ) + }) +}) diff --git a/src/shared/classify-path-environment.ts b/src/shared/classify-path-environment.ts new file mode 100644 index 000000000..fe2974d54 --- /dev/null +++ b/src/shared/classify-path-environment.ts @@ -0,0 +1,68 @@ +import { homedir } from "node:os" +import path from "node:path" + +export type PathClassification = + | "icloud" + | "onedrive" + | "desktop-sync" + | "network-drive" + | "unknown" + +function normalizeInputPath(absolutePath: string): string { + return absolutePath.replaceAll("\\", "/") +} + +function isUnderPath(normalizedPath: string, normalizedParentPath: string): boolean { + return normalizedPath === normalizedParentPath || normalizedPath.startsWith(`${normalizedParentPath}/`) +} + +export function classifyPathEnvironment(absolutePath: string): PathClassification { + if (absolutePath.length === 0) return "unknown" + + const normalizedPath = normalizeInputPath(absolutePath) + const lowercasePath = normalizedPath.toLowerCase() + if (lowercasePath.includes("/onedrive") || lowercasePath.includes("/onedrive/")) { + return "onedrive" + } + + if (normalizedPath.includes("/Library/Mobile Documents/")) { + return "icloud" + } + + if (isUnderPath(normalizedPath, "/Volumes")) { + return "network-drive" + } + + if ( + normalizedPath.startsWith("/Users/") + && (normalizedPath.includes("/Desktop/") || normalizedPath.endsWith("/Desktop") + || normalizedPath.includes("/Documents/") || normalizedPath.endsWith("/Documents")) + ) { + return "desktop-sync" + } + + const normalizedHome = normalizeInputPath(homedir()) + const desktopPath = normalizeInputPath(path.join(normalizedHome, "Desktop")) + const documentsPath = normalizeInputPath(path.join(normalizedHome, "Documents")) + + if (isUnderPath(normalizedPath, desktopPath) || isUnderPath(normalizedPath, documentsPath)) { + return "desktop-sync" + } + + return "unknown" +} + +export function describePathClassification(pathClassification: PathClassification): string { + switch (pathClassification) { + case "icloud": + return "iCloud Drive" + case "onedrive": + return "OneDrive" + case "desktop-sync": + return "Desktop sync (macOS)" + case "network-drive": + return "Network drive" + case "unknown": + return "filesystem that does not support fsync" + } +} diff --git a/src/shared/fsync-skip-tracker.test.ts b/src/shared/fsync-skip-tracker.test.ts new file mode 100644 index 000000000..897e5efe8 --- /dev/null +++ b/src/shared/fsync-skip-tracker.test.ts @@ -0,0 +1,100 @@ +import { beforeEach, describe, expect, it } from "bun:test" + +import { + clearAllSkips, + drainSkipsAfter, + recordFsyncSkip, +} from "./fsync-skip-tracker" + +type PathClassification = + | "icloud" + | "onedrive" + | "desktop-sync" + | "network-drive" + | "unknown" + +function recordSkip(index: number, pathClassification: PathClassification = "unknown"): void { + recordFsyncSkip({ + filePath: `/tmp/file-${index}.txt`, + contextLabel: `atomicWrite:/tmp/file-${index}.txt`, + errorCode: "EPERM", + message: "operation not permitted", + pathClassification, + }) +} + +describe("fsync-skip-tracker", () => { + beforeEach(() => { + clearAllSkips() + }) + + it("recordFsyncSkip adds entry with timestamp", () => { + const before = Date.now() + recordSkip(1) + const entries = drainSkipsAfter(0) + + expect(entries).toHaveLength(1) + expect(entries[0]?.filePath).toBe("/tmp/file-1.txt") + expect(entries[0]?.timestamp).toBeGreaterThanOrEqual(before) + }) + + it("drainSkipsAfter(timestamp) returns entries strictly after the timestamp", async () => { + recordSkip(1) + const firstTimestamp = Date.now() + + await Bun.sleep(2) + + recordSkip(2) + const drained = drainSkipsAfter(firstTimestamp) + expect(drained).toHaveLength(1) + expect(drained[0]?.filePath).toBe("/tmp/file-2.txt") + }) + + it("drainSkipsAfter removes drained entries from buffer", () => { + recordSkip(1) + recordSkip(2) + + const drained = drainSkipsAfter(0) + expect(drained).toHaveLength(2) + expect(drainSkipsAfter(0)).toEqual([]) + }) + + it("buffer is bounded to max 200 entries and drops oldest on overflow", () => { + for (let index = 1; index <= 205; index += 1) { + recordSkip(index) + } + + const drained = drainSkipsAfter(0) + expect(drained).toHaveLength(200) + expect(drained[0]?.filePath).toBe("/tmp/file-6.txt") + expect(drained[199]?.filePath).toBe("/tmp/file-205.txt") + }) + + it("multiple records with same path are kept", () => { + recordSkip(1) + recordFsyncSkip({ + filePath: "/tmp/file-1.txt", + contextLabel: "acquireLock:/tmp/file-1.txt", + errorCode: "EPERM", + message: "second", + pathClassification: "unknown", + }) + + const drained = drainSkipsAfter(0) + expect(drained).toHaveLength(2) + expect(drained[0]?.filePath).toBe("/tmp/file-1.txt") + expect(drained[1]?.filePath).toBe("/tmp/file-1.txt") + }) + + it("drainSkipsAfter(0) returns all entries", () => { + recordSkip(1) + recordSkip(2) + + const drained = drainSkipsAfter(0) + expect(drained).toHaveLength(2) + }) + + it("empty buffer returns empty array", () => { + expect(drainSkipsAfter(0)).toEqual([]) + }) +}) diff --git a/src/shared/fsync-skip-tracker.ts b/src/shared/fsync-skip-tracker.ts new file mode 100644 index 000000000..3ee7a7125 --- /dev/null +++ b/src/shared/fsync-skip-tracker.ts @@ -0,0 +1,42 @@ +import type { PathClassification } from "./classify-path-environment" + +export type FsyncSkipEntry = { + filePath: string + contextLabel: string + errorCode: string + message: string + pathClassification: PathClassification + timestamp: number +} + +const MAX_SKIPS = 200 +const fsyncSkips: FsyncSkipEntry[] = [] + +export function recordFsyncSkip(entry: Omit): void { + fsyncSkips.push({ ...entry, timestamp: Date.now() }) + + if (fsyncSkips.length > MAX_SKIPS) { + fsyncSkips.splice(0, fsyncSkips.length - MAX_SKIPS) + } +} + +export function drainSkipsAfter(timestampMs: number): FsyncSkipEntry[] { + const drainedEntries: FsyncSkipEntry[] = [] + const retainedEntries: FsyncSkipEntry[] = [] + + for (const entry of fsyncSkips) { + if (entry.timestamp > timestampMs) { + drainedEntries.push(entry) + continue + } + + retainedEntries.push(entry) + } + + fsyncSkips.splice(0, fsyncSkips.length, ...retainedEntries) + return drainedEntries +} + +export function clearAllSkips(): void { + fsyncSkips.length = 0 +}