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:
parent
640807778c
commit
995e4b62dd
25
src/main.ts
25
src/main.ts
@ -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));
|
||||||
|
|||||||
53
src/main/infra/fs-atomic.test.ts
Normal file
53
src/main/infra/fs-atomic.test.ts
Normal 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
29
src/main/infra/fs-atomic.ts
Normal file
29
src/main/infra/fs-atomic.ts
Normal 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 */ }
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue
Block a user