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' });