From 4a615b10ce07c589a5a4fad44e88efc3f8034b65 Mon Sep 17 00:00:00 2001 From: Kjell Tore Guttormsen Date: Tue, 5 May 2026 10:32:13 +0200 Subject: [PATCH] feat(ms-ai-architect): add lib/atomic-write for crash-safe state files [skip-docs] MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Foundation lib for status-fil + lock-fil writes in v1.12.0 cron rewrite. Pattern: writeFileSync to .tmp.. then renameSync to target. Defends against half-written files; readers either see the previous version or the new one, never a partial. - atomicWriteSync(path, content) — string or Buffer - atomicWriteJson(path, obj) — 2-space indent, trailing newline - Windows EEXIST/EPERM defensive fallback (unlink target + rename) - Best-effort tmp cleanup on writeFileSync failure - crypto.randomInt(0, 2**32) two-arg form (unambiguous across Node) 9/9 tests pass including 50-way concurrent-write fuzzer (async-aware withTmp helper). Co-Authored-By: Claude Opus 4.7 --- .../scripts/kb-update/lib/atomic-write.mjs | 53 ++++++++ .../kb-update/test-atomic-write.test.mjs | 115 ++++++++++++++++++ 2 files changed, 168 insertions(+) create mode 100644 plugins/ms-ai-architect/scripts/kb-update/lib/atomic-write.mjs create mode 100644 plugins/ms-ai-architect/tests/kb-update/test-atomic-write.test.mjs diff --git a/plugins/ms-ai-architect/scripts/kb-update/lib/atomic-write.mjs b/plugins/ms-ai-architect/scripts/kb-update/lib/atomic-write.mjs new file mode 100644 index 0000000..d0a9d0f --- /dev/null +++ b/plugins/ms-ai-architect/scripts/kb-update/lib/atomic-write.mjs @@ -0,0 +1,53 @@ +// atomic-write.mjs — Crash-safe writes via tmp+rename. +// Zero dependencies. Defends against half-written files; readers either see +// the previous version or the new one, never a partial. + +import { writeFileSync, renameSync, unlinkSync } from 'node:fs'; +import { randomInt } from 'node:crypto'; + +/** + * Atomically write content to filePath via a tmp file + rename. + * @param {string} filePath — target absolute path + * @param {string|Buffer} content — bytes to write + */ +export function atomicWriteSync(filePath, content) { + if (!filePath || typeof filePath !== 'string') { + throw new Error('atomicWriteSync: filePath required'); + } + const tmp = `${filePath}.tmp.${process.pid}.${randomInt(0, 2 ** 32)}`; + try { + writeFileSync(tmp, content); + try { + renameSync(tmp, filePath); + } catch (err) { + // Windows ERROR_ALREADY_EXISTS / EPERM defensive fallback. + if (err && (err.code === 'EEXIST' || err.code === 'EPERM')) { + try { + unlinkSync(filePath); + } catch { + // best-effort + } + renameSync(tmp, filePath); + } else { + throw err; + } + } + } catch (err) { + // Best-effort cleanup of the tmp file on failure. + try { + unlinkSync(tmp); + } catch { + // best-effort + } + throw err; + } +} + +/** + * Atomically write a JSON-serialized object with 2-space indent. + * @param {string} filePath + * @param {unknown} obj + */ +export function atomicWriteJson(filePath, obj) { + atomicWriteSync(filePath, JSON.stringify(obj, null, 2) + '\n'); +} diff --git a/plugins/ms-ai-architect/tests/kb-update/test-atomic-write.test.mjs b/plugins/ms-ai-architect/tests/kb-update/test-atomic-write.test.mjs new file mode 100644 index 0000000..aaefc0e --- /dev/null +++ b/plugins/ms-ai-architect/tests/kb-update/test-atomic-write.test.mjs @@ -0,0 +1,115 @@ +// tests/kb-update/test-atomic-write.test.mjs +// Unit tests for scripts/kb-update/lib/atomic-write.mjs + +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { mkdtempSync, rmSync, readFileSync, existsSync, readdirSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { atomicWriteSync, atomicWriteJson } from '../../scripts/kb-update/lib/atomic-write.mjs'; + +async function withTmp(fn) { + const dir = mkdtempSync(join(tmpdir(), 'aw-test-')); + try { + return await fn(dir); + } finally { + rmSync(dir, { recursive: true, force: true }); + } +} + +test('atomicWriteSync — writes string content to target', () => { + withTmp((dir) => { + const path = join(dir, 'state.txt'); + atomicWriteSync(path, 'hello'); + assert.equal(readFileSync(path, 'utf8'), 'hello'); + }); +}); + +test('atomicWriteSync — leaves no .tmp orphan after success', () => { + withTmp((dir) => { + const path = join(dir, 'state.txt'); + atomicWriteSync(path, 'ok'); + const orphans = readdirSync(dir).filter((f) => f.includes('.tmp.')); + assert.deepEqual(orphans, []); + }); +}); + +test('atomicWriteSync — overwrites existing file', () => { + withTmp((dir) => { + const path = join(dir, 'state.txt'); + writeFileSync(path, 'old'); + atomicWriteSync(path, 'new'); + assert.equal(readFileSync(path, 'utf8'), 'new'); + }); +}); + +test('atomicWriteSync — accepts Buffer content', () => { + withTmp((dir) => { + const path = join(dir, 'bin'); + atomicWriteSync(path, Buffer.from([0x01, 0x02, 0x03])); + const data = readFileSync(path); + assert.equal(data.length, 3); + assert.equal(data[0], 0x01); + }); +}); + +test('atomicWriteJson — round-trips object', () => { + withTmp((dir) => { + const path = join(dir, 'state.json'); + const obj = { schema_version: 1, status: 'success', items: [1, 2, 3] }; + atomicWriteJson(path, obj); + const read = JSON.parse(readFileSync(path, 'utf8')); + assert.deepEqual(read, obj); + }); +}); + +test('atomicWriteJson — pretty-prints with 2-space indent', () => { + withTmp((dir) => { + const path = join(dir, 'state.json'); + atomicWriteJson(path, { a: 1, b: { c: 2 } }); + const text = readFileSync(path, 'utf8'); + assert.match(text, /\n {2}"a": 1/); + assert.match(text, /\n {4}"c": 2/); + }); +}); + +test('atomicWriteJson — leaves no .tmp orphan', () => { + withTmp((dir) => { + const path = join(dir, 'state.json'); + atomicWriteJson(path, { ok: true }); + const orphans = readdirSync(dir).filter((f) => f.includes('.tmp.')); + assert.deepEqual(orphans, []); + }); +}); + +test('atomicWriteSync — concurrent writes do not corrupt', async () => { + await withTmp(async (dir) => { + const path = join(dir, 'concurrent.json'); + const writes = []; + for (let i = 0; i < 50; i++) { + writes.push( + Promise.resolve().then(() => atomicWriteJson(path, { iter: i, payload: 'x'.repeat(100) })) + ); + } + await Promise.all(writes); + // Final read must be valid JSON, regardless of which write won. + const text = readFileSync(path, 'utf8'); + const obj = JSON.parse(text); + assert.equal(typeof obj.iter, 'number'); + assert.ok(obj.iter >= 0 && obj.iter < 50); + assert.equal(obj.payload.length, 100); + // No .tmp orphans + const orphans = readdirSync(dir).filter((f) => f.includes('.tmp.')); + assert.deepEqual(orphans, []); + }); +}); + +test('atomicWriteSync — tmp filename uses pid + random suffix', () => { + // Indirect verification: write then immediately check the only file in dir is the target. + withTmp((dir) => { + const path = join(dir, 'target.json'); + atomicWriteSync(path, '{}'); + const files = readdirSync(dir); + assert.deepEqual(files, ['target.json']); + }); +});