fix(config): fsync backup and rename metadata

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
DottaandPaperclip committed 2026-08-06 19:28:12 +00:00
1 parent 4203b34330
commit 2ba053e2c3
4 files changed
+62 -4

No files matched your search

+10
View File
@@ -114,15 +114,25 @@ describe("config store", () => {
fs.writeFileSync(configPath, invalidBytes);
fs.writeFileSync(`${configPath}.invalid-1`, "existing backup");
const open = vi.spyOn(fs, "openSync");
const sync = vi.spyOn(fs, "fsyncSync");
const backupPath = backupInvalidConfig(configPath);
expect(backupPath).toBe(`${configPath}.invalid-2`);
expect(fs.readFileSync(backupPath)).toEqual(invalidBytes);
expect(open).toHaveBeenCalledWith(backupPath, "r");
expect(open).toHaveBeenCalledWith(path.dirname(configPath), "r");
expect(sync).toHaveBeenCalled();
expect(() => writeConfig(defaultConfig(), configPath)).toThrow(/Refusing to overwrite invalid config/);
expect(fs.readFileSync(configPath)).toEqual(invalidBytes);
open.mockClear();
sync.mockClear();
const rename = vi.spyOn(fs, "renameSync");
expect(writeConfig(defaultConfig(), configPath, { invalidBackupPath: backupPath })).toBe(true);
expect(rename).toHaveBeenCalledWith(expect.stringMatching(/config\.json\.tmp-\d+-\d+$/), configPath);
expect(open).toHaveBeenCalledWith(path.dirname(configPath), "r");
expect(open.mock.invocationCallOrder.at(-1)!).toBeGreaterThan(rename.mock.invocationCallOrder.at(-1)!);
expect(sync.mock.invocationCallOrder.at(-1)!).toBeGreaterThan(open.mock.invocationCallOrder.at(-1)!);
expect(readConfig(configPath)).not.toBeNull();
expect(fs.readdirSync(path.dirname(configPath)).some((entry) => entry.includes(".tmp-"))).toBe(false);
});
+31 -4
View File
@@ -110,6 +110,34 @@ function effectiveConfig(config: PaperclipConfig): Record<string, unknown> {
};
}
function syncDirectory(directoryPath: string): void {
let directoryDescriptor: number | null = null;
try {
directoryDescriptor = fs.openSync(directoryPath, "r");
fs.fsyncSync(directoryDescriptor);
} catch (error) {
const code = error instanceof Error && "code" in error ? error.code : null;
if (process.platform !== "win32" || !["EACCES", "EINVAL", "EISDIR", "ENOTSUP", "EPERM"].includes(String(code))) {
throw error;
}
} finally {
if (directoryDescriptor !== null) fs.closeSync(directoryDescriptor);
}
}
function durableCopyFile(sourcePath: string, destinationPath: string, flags = 0): void {
fs.copyFileSync(sourcePath, destinationPath, flags);
fs.chmodSync(destinationPath, 0o600);
const backupDescriptor = fs.openSync(destinationPath, "r");
try {
fs.fsyncSync(backupDescriptor);
} finally {
fs.closeSync(backupDescriptor);
}
syncDirectory(path.dirname(destinationPath));
}
function atomicWriteFile(filePath: string, contents: string): void {
let attempt = 0;
@@ -124,6 +152,7 @@ function atomicWriteFile(filePath: string, contents: string): void {
fs.closeSync(fileDescriptor);
fileDescriptor = null;
fs.renameSync(temporaryPath, filePath);
syncDirectory(path.dirname(filePath));
return;
} catch (error) {
if (fileDescriptor !== null) fs.closeSync(fileDescriptor);
@@ -144,8 +173,7 @@ export function backupInvalidConfig(configPath?: string): string {
for (let suffix = 1; ; suffix += 1) {
const backupPath = `${filePath}.invalid-${suffix}`;
try {
fs.copyFileSync(filePath, backupPath, fs.constants.COPYFILE_EXCL);
fs.chmodSync(backupPath, 0o600);
durableCopyFile(filePath, backupPath, fs.constants.COPYFILE_EXCL);
return backupPath;
} catch (error) {
const code = error instanceof Error && "code" in error ? error.code : null;
@@ -193,8 +221,7 @@ export function writeConfig(
// Backup existing config before overwriting
if (fs.existsSync(filePath)) {
const backupPath = filePath + ".backup";
fs.copyFileSync(filePath, backupPath);
fs.chmodSync(backupPath, 0o600);
durableCopyFile(filePath, backupPath);
}
atomicWriteFile(filePath, JSON.stringify(nextConfig, null, 2) + "\n");
@@ -1,3 +1,4 @@
import fsSync from "node:fs";
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
@@ -1029,8 +1030,13 @@ describe("worktree config repair", () => {
delete process.env.PORT;
delete process.env.DATABASE_URL;
const open = vi.spyOn(fsSync, "openSync");
const sync = vi.spyOn(fsSync, "fsyncSync");
maybePersistWorktreeRuntimePorts({ serverPort: 3103, databasePort: 54335 });
expect(open).toHaveBeenCalledWith(paperclipDir, "r");
expect(sync).toHaveBeenCalled();
const writtenConfig = JSON.parse(await fs.readFile(configPath, "utf8"));
expect(writtenConfig).toMatchObject({
topLevelExtension: { enabled: true },
+15
View File
@@ -324,6 +324,21 @@ function atomicWriteFile(filePath: string, contents: string): void {
fs.closeSync(fileDescriptor);
fileDescriptor = null;
fs.renameSync(temporaryPath, filePath);
let directoryDescriptor: number | null = null;
try {
directoryDescriptor = fs.openSync(path.dirname(filePath), "r");
fs.fsyncSync(directoryDescriptor);
} catch (error) {
const code = error instanceof Error && "code" in error ? error.code : null;
if (
process.platform !== "win32" ||
!["EACCES", "EINVAL", "EISDIR", "ENOTSUP", "EPERM"].includes(String(code))
) {
throw error;
}
} finally {
if (directoryDescriptor !== null) fs.closeSync(directoryDescriptor);
}
return;
} catch (error) {
if (fileDescriptor !== null) fs.closeSync(fileDescriptor);