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 { autoUpdater } from 'electron-updater';
|
||||
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 {
|
||||
setDebugLogFn, initToolDirs,
|
||||
@ -539,30 +540,6 @@ function loadConfig(): Config {
|
||||
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 {
|
||||
try {
|
||||
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