refactor(persistence): migrate caddy-upstream-watcher state to canonical atomic-write util (DC-105) [glm-grade=A]
CI / Test & Lint (push) Canceled after 0s
CI / Security audit (push) Canceled after 0s

_saveState drops its private fixed-name .tmp + writeFileSync (no fsync)
copy and delegates to src/utils/atomic-write.js atomicWriteJSON — same
wx/fsync/rename/dir-fsync discipline as the six previously migrated
stores (DC-099..DC-104). A crash can no longer tear
caddy-upstreams.json (mute list + probe state): power loss through the
old path could leave an empty/short state file and silently drop every
mute; concurrent saves (60s probe loop vs setMuted) collided on the
shared tmp name.

Test mock extended with the fd-level fs API (openSync/writeSync/
fsyncSync/closeSync/unlinkSync + closedTmp stash) so the canonical
path is exercised under the existing file-wide fs mock; fsState renamed
mockFsState (jest.mock out-of-scope-variable hoist rule). New DC-105
pin: wx+fsync+rename required, no fixed .tmp, zero leftover tmp files,
destination JSON complete with mute preserved.

Judge: GLM-5.3 cold read, round-1 A/ship (deleg_5f57fcce).
URN urn:ump:iuhgj3ajr5llshjasttsvelnptv6iqaek6xji5l3klcl72nseqdq
Full suite: 121 suites / 2779 tests green.
This commit is contained in:
Hermes
2026-08-23 02:31:16 -07:00
parent 3c04a740e4
commit b1464d9b85
2 changed files with 104 additions and 32 deletions
@@ -16,6 +16,8 @@
*
* State persisted to <dataDir>/caddy-upstreams.json. Mute list is part of
* the same file so atomic-write semantics keep state + mutes consistent.
* Writes go through the canonical atomic-write util (DC-099/DC-105):
* fsync'd same-dir tmp + rename — a crash can never tear the mute list.
*
* The probe DOES NOT use Caddy's health_uri (that's Caddy's own probe and
* the source of the spam). The probe also stamps `X-DashCaddy-HealthCheck: 1`
@@ -31,6 +33,7 @@ const https = require('https');
const http = require('http');
const EventEmitter = require('events');
const platformPaths = require('../../platform-paths');
const { atomicWriteJSON } = require('../utils/atomic-write');
/** Default probe interval: 60s. Independent of Caddy's 10s internal probe. */
const PROBE_INTERVAL_MS = parseInt(process.env.CADDY_UPSTREAM_PROBE_INTERVAL_MS || '60000', 10);
@@ -532,9 +535,12 @@ class CaddyUpstreamWatcher extends EventEmitter {
verifiedViaBridge: !!v.verifiedViaBridge
};
}
const tmp = STATE_FILE + '.tmp';
fs.writeFileSync(tmp, JSON.stringify({ muted: Array.from(this.muted), upstreams }, null, 2));
fs.renameSync(tmp, STATE_FILE);
// Canonical atomic-write (DC-099, migrated DC-105): fsync'd same-dir
// tmp + rename via the shared util. A crash mid-write can no longer
// tear caddy-upstreams.json (muted list + probe state) — the old
// writeFileSync-to-fixed-.tmp had no fsync, so a power loss could
// leave an empty/short state file and silently drop every mute.
atomicWriteJSON(STATE_FILE, { muted: Array.from(this.muted), upstreams });
} catch (e) {
this.log.warn?.('caddy-upstream-watcher', `state save failed: ${e.message}`);
}