refactor: extract writeFileAtomicSync to src/main/infra/fs-atomic + 6 tests

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
xRangerDE 2026-05-11 21:43:12 +02:00
parent 640807778c
commit 995e4b62dd
3 changed files with 83 additions and 24 deletions

View File

@ -6,6 +6,7 @@ import { connect as tlsConnect, TLSSocket } from 'node:tls';
import axios from 'axios'; import axios from 'axios';
import { autoUpdater } from 'electron-updater'; import { autoUpdater } from 'electron-updater';
import { compareUpdateVersions, isNewerUpdateVersion, normalizeUpdateVersion } from './main/domain/update-version-utils'; import { compareUpdateVersions, isNewerUpdateVersion, normalizeUpdateVersion } from './main/domain/update-version-utils';
import { writeFileAtomicSync } from './main/infra/fs-atomic';
import { CustomClip, MergeGroupItem, MergeGroup, QueueItem, DownloadProgress, DownloadResult } from './types'; import { CustomClip, MergeGroupItem, MergeGroup, QueueItem, DownloadProgress, DownloadResult } from './types';
import { import {
setDebugLogFn, initToolDirs, setDebugLogFn, initToolDirs,
@ -539,30 +540,6 @@ function loadConfig(): Config {
return normalizeConfigTemplates(defaultConfig); return normalizeConfigTemplates(defaultConfig);
} }
function writeFileAtomicSync(targetPath: string, payload: string | Buffer): void {
const buffer = Buffer.isBuffer(payload) ? payload : Buffer.from(payload, 'utf-8');
const tmpPath = targetPath + '.tmp';
let fd: number | null = null;
try {
fd = fs.openSync(tmpPath, 'w');
fs.writeSync(fd, buffer, 0, buffer.length, 0);
try { fs.fsyncSync(fd); } catch { /* fsync may fail on some FS; rename is still safer than nothing */ }
} finally {
if (fd !== null) {
try { fs.closeSync(fd); } catch { }
}
}
try {
fs.renameSync(tmpPath, targetPath);
} catch {
// On Windows, rename can fail if target exists or is locked. Fall back to copy.
fs.copyFileSync(tmpPath, targetPath);
try { fs.unlinkSync(tmpPath); } catch { }
}
}
function saveConfig(config: Config): void { function saveConfig(config: Config): void {
try { try {
writeFileAtomicSync(CONFIG_FILE, JSON.stringify(config, null, 2)); writeFileAtomicSync(CONFIG_FILE, JSON.stringify(config, null, 2));

View File

@ -0,0 +1,53 @@
import { test, expect, describe, beforeEach, afterEach } from 'vitest';
import * as fs from 'fs';
import * as os from 'os';
import * as path from 'path';
import { writeFileAtomicSync } from './fs-atomic';
let tmpDir: string;
beforeEach(() => {
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'fsatomic-'));
});
afterEach(() => {
try { fs.rmSync(tmpDir, { recursive: true, force: true }); } catch { /* ignore */ }
});
describe('writeFileAtomicSync', () => {
test('writes a string payload', () => {
const target = path.join(tmpDir, 'a.txt');
writeFileAtomicSync(target, 'hello');
expect(fs.readFileSync(target, 'utf-8')).toBe('hello');
});
test('writes a buffer payload', () => {
const target = path.join(tmpDir, 'b.bin');
writeFileAtomicSync(target, Buffer.from([1, 2, 3, 4]));
expect(fs.readFileSync(target)).toEqual(Buffer.from([1, 2, 3, 4]));
});
test('overwrites existing file', () => {
const target = path.join(tmpDir, 'c.txt');
fs.writeFileSync(target, 'old');
writeFileAtomicSync(target, 'new');
expect(fs.readFileSync(target, 'utf-8')).toBe('new');
});
test('cleans up tmp file after success', () => {
const target = path.join(tmpDir, 'd.txt');
writeFileAtomicSync(target, 'x');
expect(fs.existsSync(target + '.tmp')).toBe(false);
});
test('utf-8 multibyte chars roundtrip', () => {
const target = path.join(tmpDir, 'e.txt');
writeFileAtomicSync(target, 'aeoeue-aeoeue');
expect(fs.readFileSync(target, 'utf-8')).toBe('aeoeue-aeoeue');
});
test('empty payload writes empty file', () => {
const target = path.join(tmpDir, 'f.txt');
writeFileAtomicSync(target, '');
expect(fs.readFileSync(target, 'utf-8')).toBe('');
expect(fs.statSync(target).size).toBe(0);
});
});

View File

@ -0,0 +1,29 @@
import * as fs from 'fs';
/**
* Atomic write via tmp + rename. Survives crash mid-write either old or
* new content, never partial. Windows fallback: copy + unlink if rename
* fails (e.g. target locked by reader). fsync best-effort.
*/
export function writeFileAtomicSync(targetPath: string, payload: string | Buffer): void {
const buffer = Buffer.isBuffer(payload) ? payload : Buffer.from(payload, 'utf-8');
const tmpPath = targetPath + '.tmp';
let fd: number | null = null;
try {
fd = fs.openSync(tmpPath, 'w');
fs.writeSync(fd, buffer, 0, buffer.length, 0);
try { fs.fsyncSync(fd); } catch { /* fsync may fail on some FS; rename is still safer than nothing */ }
} finally {
if (fd !== null) {
try { fs.closeSync(fd); } catch { /* ignore */ }
}
}
try {
fs.renameSync(tmpPath, targetPath);
} catch {
fs.copyFileSync(tmpPath, targetPath);
try { fs.unlinkSync(tmpPath); } catch { /* ignore */ }
}
}