diff --git a/electron/ipc/project/atomicSave.test.ts b/electron/ipc/project/atomicSave.test.ts new file mode 100644 index 000000000..34646af34 --- /dev/null +++ b/electron/ipc/project/atomicSave.test.ts @@ -0,0 +1,112 @@ +import fs from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; + +import { getProjectBackupPath, writeProjectFileAtomically } from "./atomicSave"; + +describe("writeProjectFileAtomically", () => { + let tempDir: string; + let projectPath: string; + + beforeEach(async () => { + tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "recordly-atomic-project-")); + projectPath = path.join(tempDir, "demo.recordly"); + }); + + afterEach(async () => { + await fs.rm(tempDir, { recursive: true, force: true }); + }); + + async function expectNoTemporaryArtifacts() { + const entries = await fs.readdir(tempDir); + expect(entries.filter((entry) => entry.endsWith(".tmp"))).toEqual([]); + } + + it("commits a complete new project without creating a backup", async () => { + await writeProjectFileAtomically(projectPath, '{"version":1}'); + + await expect(fs.readFile(projectPath, "utf-8")).resolves.toBe('{"version":1}'); + await expect(fs.access(getProjectBackupPath(projectPath))).rejects.toMatchObject({ + code: "ENOENT", + }); + await expectNoTemporaryArtifacts(); + await expectNoTemporaryArtifacts(); + }); + + it("removes a stale backup when the target has no previous generation", async () => { + await fs.writeFile(getProjectBackupPath(projectPath), "stale-project"); + + await writeProjectFileAtomically(projectPath, '{"version":1}'); + + await expect(fs.access(getProjectBackupPath(projectPath))).rejects.toMatchObject({ + code: "ENOENT", + }); + }); + + it("preserves the previous complete generation before replacement", async () => { + await writeProjectFileAtomically(projectPath, '{"version":1,"name":"old"}'); + await writeProjectFileAtomically(projectPath, '{"version":1,"name":"new"}'); + + await expect(fs.readFile(projectPath, "utf-8")).resolves.toBe('{"version":1,"name":"new"}'); + await expect(fs.readFile(getProjectBackupPath(projectPath), "utf-8")).resolves.toBe( + '{"version":1,"name":"old"}', + ); + await expectNoTemporaryArtifacts(); + }); + + it("keeps the active generation unchanged when backup commit fails", async () => { + await writeProjectFileAtomically(projectPath, '{"version":1,"name":"old"}'); + await fs.mkdir(getProjectBackupPath(projectPath)); + + await expect( + writeProjectFileAtomically(projectPath, '{"version":1,"name":"new"}'), + ).rejects.toBeDefined(); + + await expect(fs.readFile(projectPath, "utf-8")).resolves.toBe('{"version":1,"name":"old"}'); + await expectNoTemporaryArtifacts(); + + await fs.rm(getProjectBackupPath(projectPath), { recursive: true }); + await writeProjectFileAtomically(projectPath, '{"version":1,"name":"retry"}'); + await expect(fs.readFile(projectPath, "utf-8")).resolves.toBe( + '{"version":1,"name":"retry"}', + ); + await expect(fs.readFile(getProjectBackupPath(projectPath), "utf-8")).resolves.toBe( + '{"version":1,"name":"old"}', + ); + await expectNoTemporaryArtifacts(); + }); + + it("serializes overlapping writes to the same project", async () => { + await writeProjectFileAtomically(projectPath, '{"revision":1}'); + + // Each call enters the queue synchronously before its first await, preserving invocation order. + await Promise.all([ + writeProjectFileAtomically(projectPath, '{"revision":2}'), + writeProjectFileAtomically(projectPath, '{"revision":3}'), + ]); + + await expect(fs.readFile(projectPath, "utf-8")).resolves.toBe('{"revision":3}'); + await expect(fs.readFile(getProjectBackupPath(projectPath), "utf-8")).resolves.toBe( + '{"revision":2}', + ); + await expectNoTemporaryArtifacts(); + }); + + it.skipIf(process.platform === "win32")( + "preserves exact project permission bits despite the process umask", + async () => { + await fs.writeFile(projectPath, '{"revision":1}', { mode: 0o666 }); + await fs.chmod(projectPath, 0o666); + const previousUmask = process.umask(0o077); + + try { + await writeProjectFileAtomically(projectPath, '{"revision":2}'); + } finally { + process.umask(previousUmask); + } + + expect((await fs.stat(projectPath)).mode & 0o777).toBe(0o666); + }, + ); +}); diff --git a/electron/ipc/project/atomicSave.ts b/electron/ipc/project/atomicSave.ts new file mode 100644 index 000000000..d2440ffe5 --- /dev/null +++ b/electron/ipc/project/atomicSave.ts @@ -0,0 +1,146 @@ +import { randomUUID } from "node:crypto"; +import { constants as fsConstants } from "node:fs"; +import fs from "node:fs/promises"; +import path from "node:path"; + +const pendingWrites = new Map>(); + +const unsupportedDirectorySyncErrors = new Set([ + "EACCES", + "EINVAL", + "EISDIR", + "ENOSYS", + "ENOTSUP", + "EOPNOTSUPP", + "EPERM", +]); + +export function getProjectBackupPath(projectPath: string): string { + return `${projectPath}.bak`; +} + +function getQueueKey(projectPath: string): string { + const resolvedPath = path.resolve(projectPath); + return process.platform === "win32" ? resolvedPath.toLowerCase() : resolvedPath; +} + +function createTemporaryPath(parentDir: string, label: string): string { + return path.join(parentDir, `.recordly-${label}-${process.pid}-${randomUUID()}.tmp`); +} + +async function getExistingFileMode(filePath: string): Promise { + try { + return (await fs.stat(filePath)).mode & 0o777; + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "ENOENT") { + return undefined; + } + throw error; + } +} + +async function writeSyncedTemporaryFile( + filePath: string, + contents: string, + mode?: number, +): Promise { + const handle = await fs.open(filePath, "wx", mode); + try { + await handle.writeFile(contents, "utf-8"); + if (mode !== undefined) { + await handle.chmod(mode); + } + await handle.sync(); + } finally { + await handle.close(); + } +} + +async function syncExistingFile(filePath: string): Promise { + const handle = await fs.open(filePath, "r+"); + try { + await handle.sync(); + } finally { + await handle.close(); + } +} + +async function syncParentDirectory(parentDir: string): Promise { + if (process.platform === "win32") { + return; + } + + try { + const handle = await fs.open(parentDir, "r"); + try { + await handle.sync(); + } finally { + await handle.close(); + } + } catch (error) { + const code = (error as NodeJS.ErrnoException).code; + if (!code || !unsupportedDirectorySyncErrors.has(code)) { + throw error; + } + } +} + +async function preservePreviousGeneration( + targetPath: string, + backupPath: string, + backupTemporaryPath: string, +): Promise { + try { + await fs.copyFile(targetPath, backupTemporaryPath, fsConstants.COPYFILE_EXCL); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "ENOENT") { + await fs.rm(backupPath, { force: true }); + return; + } + throw error; + } + + await syncExistingFile(backupTemporaryPath); + await fs.rename(backupTemporaryPath, backupPath); +} + +async function commitProjectFile(projectPath: string, contents: string): Promise { + const targetPath = path.resolve(projectPath); + const parentDir = path.dirname(targetPath); + const backupPath = getProjectBackupPath(targetPath); + const temporaryPath = createTemporaryPath(parentDir, "project"); + const backupTemporaryPath = createTemporaryPath(parentDir, "backup"); + const existingMode = await getExistingFileMode(targetPath); + + try { + await writeSyncedTemporaryFile(temporaryPath, contents, existingMode); + await preservePreviousGeneration(targetPath, backupPath, backupTemporaryPath); + await fs.rename(temporaryPath, targetPath); + await syncParentDirectory(parentDir); + } finally { + await Promise.all([ + fs.rm(temporaryPath, { force: true }).catch(() => undefined), + fs.rm(backupTemporaryPath, { force: true }).catch(() => undefined), + ]); + } +} + +export async function writeProjectFileAtomically( + projectPath: string, + contents: string, +): Promise { + const queueKey = getQueueKey(projectPath); + const previousWrite = pendingWrites.get(queueKey) ?? Promise.resolve(); + const currentWrite = previousWrite + .catch(() => undefined) + .then(() => commitProjectFile(projectPath, contents)); + pendingWrites.set(queueKey, currentWrite); + + try { + await currentWrite; + } finally { + if (pendingWrites.get(queueKey) === currentWrite) { + pendingWrites.delete(queueKey); + } + } +} diff --git a/electron/ipc/register/project.ts b/electron/ipc/register/project.ts index 61dbdbaa8..f1fa43e26 100644 --- a/electron/ipc/register/project.ts +++ b/electron/ipc/register/project.ts @@ -9,6 +9,7 @@ import { LEGACY_PROJECT_FILE_EXTENSIONS, PROJECT_FILE_EXTENSION, } from "../constants"; +import { getProjectBackupPath, writeProjectFileAtomically } from "../project/atomicSave"; import { getProjectsDir, getProjectThumbnailPath, @@ -305,7 +306,10 @@ export function registerProjectHandlers() { : null if (trustedExistingProjectPath) { - await fs.writeFile(trustedExistingProjectPath, JSON.stringify(preparedProject.projectData, null, 2), 'utf-8') + await writeProjectFileAtomically( + trustedExistingProjectPath, + JSON.stringify(preparedProject.projectData, null, 2), + ) setCurrentProjectPath(trustedExistingProjectPath) await saveProjectThumbnail(trustedExistingProjectPath, thumbnailDataUrl) await rememberRecentProject(trustedExistingProjectPath) @@ -345,7 +349,10 @@ export function registerProjectHandlers() { } } - await fs.writeFile(result.filePath, JSON.stringify(preparedProject.projectData, null, 2), 'utf-8') + await writeProjectFileAtomically( + result.filePath, + JSON.stringify(preparedProject.projectData, null, 2), + ) setCurrentProjectPath(result.filePath) await saveProjectThumbnail(result.filePath, thumbnailDataUrl) await rememberRecentProject(result.filePath) @@ -411,7 +418,10 @@ export function registerProjectHandlers() { return overwriteCheck } - await fs.writeFile(targetProjectPath, JSON.stringify(preparedProject.projectData, null, 2), 'utf-8') + await writeProjectFileAtomically( + targetProjectPath, + JSON.stringify(preparedProject.projectData, null, 2), + ) await saveProjectThumbnail(targetProjectPath, thumbnailDataUrl) await rememberRecentProject(targetProjectPath) @@ -422,6 +432,7 @@ export function registerProjectHandlers() { } }) await fs.rm(getProjectThumbnailPath(activeProjectPath), { force: true }).catch(() => undefined) + await fs.rm(getProjectBackupPath(activeProjectPath), { force: true }).catch(() => undefined) const recentProjectPaths = await loadRecentProjectPaths() const filteredRecentProjectPaths: string[] = []