From 995e4b62dd478e66e2dbbac301a81a3713d4b393 Mon Sep 17 00:00:00 2001 From: xRangerDE Date: Mon, 11 May 2026 21:43:12 +0200 Subject: [PATCH] refactor: extract writeFileAtomicSync to src/main/infra/fs-atomic + 6 tests Co-Authored-By: Claude Opus 4.7 (1M context) --- src/main.ts | 25 +-------------- src/main/infra/fs-atomic.test.ts | 53 ++++++++++++++++++++++++++++++++ src/main/infra/fs-atomic.ts | 29 +++++++++++++++++ 3 files changed, 83 insertions(+), 24 deletions(-) create mode 100644 src/main/infra/fs-atomic.test.ts create mode 100644 src/main/infra/fs-atomic.ts diff --git a/src/main.ts b/src/main.ts index eb496c9..8412708 100644 --- a/src/main.ts +++ b/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)); diff --git a/src/main/infra/fs-atomic.test.ts b/src/main/infra/fs-atomic.test.ts new file mode 100644 index 0000000..c75148c --- /dev/null +++ b/src/main/infra/fs-atomic.test.ts @@ -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); + }); +}); diff --git a/src/main/infra/fs-atomic.ts b/src/main/infra/fs-atomic.ts new file mode 100644 index 0000000..c761c65 --- /dev/null +++ b/src/main/infra/fs-atomic.ts @@ -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 */ } + } +}