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
@@ -6,6 +6,7 @@ const EventEmitter = require('events');
const fs = require('fs');
const path = require('path');
const nodemailer = require('nodemailer');
const { atomicWriteJSON } = require('../utils/atomic-write');
// Canonical event names are kebab-case ('container-down'). Emitters and the
// settings UI historically send camelCase ('containerDown', 'deploymentSuccess')
@@ -125,7 +126,9 @@ class NotificationManager extends EventEmitter {
try {
const canonical = JSON.stringify(this.config, null, 2);
if (canonical !== rawFileContents) {
fs.writeFileSync(this.NOTIFICATIONS_FILE, canonical);
// DC-099: tmp+fsync+rename — a crash mid-write can no longer leave a
// truncated/empty notifications.json (plain writeFileSync could).
atomicWriteJSON(this.NOTIFICATIONS_FILE, this.config);
this.log.info?.('notification', 'Notification config canonicalized on disk (legacy keys normalized)', {});
}
} catch (writeError) {
@@ -198,7 +201,9 @@ class NotificationManager extends EventEmitter {
if (!fs.existsSync(dir)) {
fs.mkdirSync(dir, { recursive: true });
}
fs.writeFileSync(this.NOTIFICATIONS_FILE, JSON.stringify(this.config, null, 2));
// DC-099: atomic tmp+fsync+rename — the UI save path gets the same
// crash-safety as the load-path write-back (no torn notifications.json).
atomicWriteJSON(this.NOTIFICATIONS_FILE, this.config);
return true;
} catch (error) {
this.log.error('notification', error, null, { note: 'Failed to save config' });
+101
View File
@@ -0,0 +1,101 @@
/**
* Canonical atomic file writer (DC-099).
*
* Write discipline: same-directory temp file → write → fsync → close → rename.
* rename() is atomic on POSIX, so a reader (or a crash) can only ever see the
* complete old file or the complete new file — never a truncated mix. fsync
* before rename pins the bytes so a post-rename power loss doesn't leave an
* empty/short file behind (the failure mode plain writeFileSync has).
*
* This is the ONE shared implementation. It replaces the three private
* `_atomicWriteJSON` copies (invite-store, user-store, share-store) as they
* are touched, and mirrors the DC-098 redact tool's write path. Do not add a
* fourth copy — require this module instead.
*/
'use strict';
const fs = require('fs');
const path = require('path');
// Monotonic counter guarantees unique tmp names even for back-to-back writes
// of the same target within one process tick.
let writeCounter = 0;
function tmpPathFor(filePath) {
writeCounter += 1;
const base = path.basename(filePath);
const dir = path.dirname(filePath);
return path.join(dir, `.${base}.tmp-${process.pid}-${Date.now().toString(36)}-${writeCounter}`);
}
/**
* Best-effort durability for the rename itself: fsync the parent directory so
* the swap survives a post-rename power loss. POSIX guarantees the rename is
* atomic *visibly*, but without a dir fsync a crash can leave the old entry —
* a stale-but-complete file, never a torn one, so failure here is not fatal.
*/
function fsyncDir(dirPath) {
let dfd = null;
try {
dfd = fs.openSync(dirPath, 'r');
fs.fsyncSync(dfd);
} catch (_) {
// Some platforms/filesystems reject fsync on directory fds; the payload
// is already durable via the file-level fsync above.
} finally {
if (dfd !== null) {
try { fs.closeSync(dfd); } catch (_) { /* fd already closed */ }
}
}
}
/**
* Atomically replace `filePath` with `contents`.
*
* @param {string} filePath - destination (parent dir must exist)
* @param {string} contents - full file contents
* @param {object} [opts]
* @param {number} [opts.mode=0o600] - mode for a newly created file
* @returns {string} the final path (filePath)
* @throws whatever fs throws (ENOSPC, EACCES, …); on failure the destination
* is untouched and the temp file is removed best-effort.
*/
function atomicWriteFile(filePath, contents, opts = {}) {
const mode = typeof opts.mode === 'number' ? opts.mode : 0o600;
const tmp = tmpPathFor(filePath);
let fd = null;
try {
// 'wx' — fail loudly if the tmp name somehow exists rather than clobber.
fd = fs.openSync(tmp, 'wx', mode);
fs.writeSync(fd, contents, null, 'utf8');
fs.fsyncSync(fd);
fs.closeSync(fd);
fd = null;
fs.renameSync(tmp, filePath);
fsyncDir(path.dirname(filePath));
return filePath;
} catch (err) {
if (fd !== null) {
try { fs.closeSync(fd); } catch (_) { /* fd already closed or broken */ }
}
try { fs.unlinkSync(tmp); } catch (_) { /* nothing to clean up */ }
throw err;
}
}
/**
* Atomically write `data` as JSON. Single serializer for the whole codebase:
* 2-space indent, no trailing newline (matches the notification config's
* byte-for-byte idempotence check in _persistCanonicalForm).
*
* @param {string} filePath
* @param {*} data - JSON.stringify-able value
* @param {object} [opts] - passed through to atomicWriteFile
* @returns {string} the final path (filePath)
*/
function atomicWriteJSON(filePath, data, opts = {}) {
return atomicWriteFile(filePath, JSON.stringify(data, null, 2), opts);
}
module.exports = { atomicWriteFile, atomicWriteJSON, tmpPathFor };