refactor(persistence): canonical atomic file writer + notifications.json crash-safety (DC-099) [glm-grade=B]
CI / Test & Lint (push) Canceled after 0s
CI / Security audit (push) Canceled after 0s

- src/utils/atomic-write.js: single shared tmp+fsync+rename writer
  (exclusive-create 0600, unique tmp names, cleanup-on-failure,
  best-effort parent-dir fsync after rename for swap durability)
- notification-manager: both write paths (load-time canonicalization
  write-back + saveConfig) converted from plain writeFileSync — a
  crash mid-write can no longer truncate notifications.json
- DC-097/098 test seams migrated to the atomic path; new suite pins
  syscall discipline (order, wx flags, tmp naming, error cleanup,
  dir-fsync swallow)
- 121 suites / 2768 tests green
- Judge: GLM-5.3 cold read grade B/ship (deleg_23f7abad); polish items
  folded: dir-fsync added, header copy-count corrected. Remaining:
  migrate invite/user/share-store private _atomicWriteJSON copies as
  they are touched (queued).

URN: pending (recorded post-commit)
This commit is contained in:
Hermes
2026-08-22 23:28:36 -07:00
parent 8d42eae6ac
commit 80a82c4cae
5 changed files with 341 additions and 19 deletions
@@ -0,0 +1,198 @@
/**
* DC-099: canonical atomic file writer (src/utils/atomic-write.js).
*
* The notification config's two write paths (load-time canonicalization
* write-back and the UI saveConfig) used plain fs.writeFileSync — a crash or
* power loss mid-write could leave a truncated/empty notifications.json. The
* same risk exists in every store that grew its own private
* _atomicWriteJSON copy (invite-store, user-store, share-store, …).
*
* These tests pin the shared writer's contract:
* - durability: fsync before rename, exclusive create, 0600 default
* - atomicity: destination only ever replaced via rename
* - failure: destination untouched, temp cleaned up, error propagated
* - JSON helper: single serialization shape (2-space, no trailing newline —
* notification-manager._persistCanonicalForm depends on byte-for-byte
* idempotence)
*/
'use strict';
const fs = require('fs');
const os = require('os');
const path = require('path');
const { atomicWriteFile, atomicWriteJSON, tmpPathFor } = require('../src/utils/atomic-write');
// Real-FS tests: the actual syscalls, in a private temp dir.
describe('DC-099 atomic-write (real fs)', () => {
let dir;
beforeEach(() => {
dir = fs.mkdtempSync(path.join(os.tmpdir(), 'dc099-atomic-'));
});
afterEach(() => {
fs.rmSync(dir, { recursive: true, force: true });
});
test('writes contents and returns the final path', () => {
const target = path.join(dir, 'state.json');
const ret = atomicWriteFile(target, '{"a":1}');
expect(ret).toBe(target);
expect(fs.readFileSync(target, 'utf8')).toBe('{"a":1}');
});
test('replaces an existing file completely (no torn writes possible)', () => {
const target = path.join(dir, 'state.json');
atomicWriteFile(target, 'x'.repeat(1000));
atomicWriteFile(target, 'y'.repeat(10));
expect(fs.readFileSync(target, 'utf8')).toBe('y'.repeat(10));
});
test('creates the file 0600 by default', () => {
const target = path.join(dir, 'secret.json');
atomicWriteJSON(target, { ok: true });
expect(fs.statSync(target).mode & 0o777).toBe(0o600);
});
test('honors an explicit mode override', () => {
const target = path.join(dir, 'public.json');
atomicWriteFile(target, '{}', { mode: 0o644 });
expect(fs.statSync(target).mode & 0o777).toBe(0o644);
});
test('leaves no temp files behind after success', () => {
const target = path.join(dir, 'state.json');
atomicWriteFile(target, 'abc');
expect(fs.readdirSync(dir).filter((f) => f.includes('.tmp-'))).toEqual([]);
});
test('two rapid writes both land (unique tmp names per write)', () => {
const target = path.join(dir, 'state.json');
atomicWriteFile(target, 'first');
atomicWriteFile(target, 'second');
expect(fs.readFileSync(target, 'utf8')).toBe('second');
});
test('atomicWriteJSON serializes 2-space, no trailing newline', () => {
const target = path.join(dir, 'conf.json');
atomicWriteJSON(target, { a: { b: 1 } });
const raw = fs.readFileSync(target, 'utf8');
expect(raw).toBe('{\n "a": {\n "b": 1\n }\n}');
});
test('write failure leaves the destination untouched and cleans the temp file', () => {
const target = path.join(dir, 'state.json');
fs.writeFileSync(target, 'ORIGINAL');
const origWrite = fs.writeSync;
fs.writeSync = () => {
throw Object.assign(new Error('ENOSPC: no space left on device'), { code: 'ENOSPC' });
};
try {
expect(() => atomicWriteFile(target, 'NEW-CONTENT')).toThrow(/ENOSPC/);
} finally {
fs.writeSync = origWrite;
}
expect(fs.readFileSync(target, 'utf8')).toBe('ORIGINAL');
expect(fs.readdirSync(dir).filter((f) => f.includes('.tmp-'))).toEqual([]);
});
test('tmpPathFor: unique per call, hidden dotfile in the same directory', () => {
const a = tmpPathFor('/data/x.json');
const b = tmpPathFor('/data/x.json');
expect(a).not.toBe(b);
expect(path.dirname(a)).toBe('/data');
expect(path.basename(a)).toMatch(/^\.x\.json\.tmp-/);
});
});
// Mocked-FS tests: pin the syscall DISCIPLINE itself (order + flags), which
// the real-fs tests can't observe directly.
describe('DC-099 atomic-write syscall discipline (mocked fs)', () => {
const calls = [];
beforeEach(() => {
calls.length = 0;
const rec = (name, impl) =>
jest.spyOn(fs, name).mockImplementation((...args) => {
calls.push(name);
return impl(...args);
});
rec('openSync', () => 3);
rec('writeSync', () => 8);
rec('fsyncSync', () => {});
rec('closeSync', () => {});
rec('renameSync', () => {});
rec('unlinkSync', () => {});
});
afterEach(() => {
jest.restoreAllMocks();
});
test('order: open → write → fsync → close → rename, then dir fsync (open → fsync → close)', () => {
atomicWriteFile('/data/x.json', '{"a":1}');
expect(calls).toEqual([
'openSync', 'writeSync', 'fsyncSync', 'closeSync', 'renameSync',
'openSync', 'fsyncSync', 'closeSync',
]);
});
test('dir fsync opens the PARENT directory (second openSync), not another tmp file', () => {
atomicWriteFile('/data/x.json', '{}');
const dirOpen = fs.openSync.mock.calls[1];
expect(dirOpen[0]).toBe('/data');
expect(dirOpen[1]).toBe('r');
});
test('dir fsync failure is swallowed (write still succeeds)', () => {
let n = 0;
fs.fsyncSync.mockImplementation(() => {
n += 1;
if (n === 2) throw new Error('EINVAL: invalid argument'); // 2nd fsync = dir
});
expect(() => atomicWriteFile('/data/x.json', '{}')).not.toThrow();
expect(fs.renameSync).toHaveBeenCalled();
});
test('open uses exclusive-create with the 0600 default on the tmp path', () => {
atomicWriteFile('/data/x.json', '{}');
const [tmpPath, flags, modeArg] = fs.openSync.mock.calls[0];
expect(tmpPath).toMatch(/^\/data\/\.x\.json\.tmp-/);
expect(flags).toBe('wx');
expect(modeArg).toBe(0o600);
});
test('write passes the payload with utf8 encoding', () => {
atomicWriteFile('/data/x.json', '{"a":1}');
expect(fs.writeSync.mock.calls[0]).toEqual([3, '{"a":1}', null, 'utf8']);
});
test('rename swaps a same-dir temp onto the target', () => {
atomicWriteFile('/data/x.json', '{}');
const [tmp, dest] = fs.renameSync.mock.calls[0];
expect(tmp).toMatch(/\/data\/\.x\.json\.tmp-/);
expect(dest).toBe('/data/x.json');
});
test('rename failure unlinks the temp and propagates the error', () => {
fs.renameSync.mockImplementation(() => {
calls.push('renameSync');
throw new Error('EXDEV: cross-device link not permitted');
});
expect(() => atomicWriteFile('/data/x.json', '{}')).toThrow(/EXDEV/);
expect(calls).toEqual([
'openSync', 'writeSync', 'fsyncSync', 'closeSync', 'renameSync', 'unlinkSync',
]);
});
test('open failure propagates without write/rename (nothing was created)', () => {
fs.openSync.mockImplementation(() => {
calls.push('openSync');
throw new Error('EACCES: permission denied');
});
expect(() => atomicWriteFile('/data/x.json', '{}')).toThrow(/EACCES/);
// best-effort unlink of the never-created temp, then stop
expect(calls).toEqual(['openSync', 'unlinkSync']);
});
});