Keep Harper booting when its storage volume is full or at quota - #2245
Keep Harper booting when its storage volume is full or at quota#2245kriszyp wants to merge 24 commits into
Conversation
Harper crash-looped at startup on a quota-exhausted volume: initConfig rewrites harper-config.yaml and the env-config state snapshot on every boot when a HARPER_*_CONFIG env var is set, the sibling temp file failed with EDQUOT, and the error escaped initConfig. The container restarted, the same write failed, and nothing inside it could free space because nothing could start. Boot-path config persistence is now best-effort: the effective config is already derived and validated in memory, so ENOSPC/EDQUOT logs and continues instead of aborting startup. Writes a user asked for (set_configuration and friends) still persist or throw, and install still fails loudly - it has no last-known-good config to fall back on. Alongside that: - atomicWriteFile takes an opt-in skipIfUnchanged, so the two artifacts that are re-derived identically every boot stop writing at all. - The config file is now written before the env-state snapshot, and the snapshot is skipped when that write did not happen. A snapshot describing a file that was never updated makes the next boot read the older value as a manual user edit and stop applying the env layer. - ensureConfigKeysPresent mirrors new keys into the memoized config before persisting, so a refused write cannot leave a built-in dormant for the boot. - A refused log append falls back to the console instead of throwing, which would otherwise make every log statement a crash point on a full volume - including the diagnostic reporting the storage problem. Fixes #847 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
- The env-state snapshot is committed before the config file again, and is removed if that file's write is then refused. Both orderings lose something on their own: snapshot-last loses the user's original values (the file it would have recorded them from is already overwritten), snapshot-first leaves a snapshot ahead of the file, which the next boot reads as a manual user edit and permanently stops applying the env layer. Rolling the snapshot back with an unlink needs no free space, so the pair moves together or not at all. - ensureConfigKeysPresent no longer reports a backfilled key it could not persist. Worker threads re-read the config from disk and never run the backfill, so an in-memory-only key activates nowhere that serves requests while the boot log claims otherwise. - The refused-log-append fallback writes through nativeStdWrite instead of console. installStdioGuard routes console output back into the same file logger when logging.file and logging.console are both on, so a console fallback would recurse until the stack blew - a worse failure than the one being removed. The payload is joined once and written without a second newline. - atomicWriteFile cleans up the temp file when the temp write itself fails, not only when the rename does. On a full volume the open succeeds and the write does not, so every boot was leaving one behind. - Storage-exhaustion coverage moved to unitTests/config/storageExhaustion.test.js, which uses node:assert against the real modules per AGENTS.md rather than extending the stubbed harness. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Round-2 review findings: - The snapshot rollback now only fires when the snapshot write actually rewrote the file, and it fires for a failed config write as well as a refused one. Discarding an unchanged snapshot threw away originals that still described the file on disk, and a non-exhaustion write error (EACCES) escaped past the rollback entirely, leaving the snapshot ahead of the file - the state that permanently disables the env layer. - Log fallbacks write through a single non-throwing helper that bypasses the stdio guard. nativeStdWrite can itself throw EPIPE, which would put the crash back by another route, and the no-descriptor branch previously used console.log (re-entering this same logger) and dropped every entry after the first, so a full volume produced no diagnostics at all. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
There was a problem hiding this comment.
Code Review
This pull request introduces robust handling for storage exhaustion (such as disk full or quota exceeded) during the boot process to prevent crash loops. It adds checks for ENOSPC and EDQUOT errors, allows best-effort configuration persistence during boot, and ensures that log write failures on exhausted volumes fall back to direct stdio writes rather than crashing the process. Additionally, it refactors runtime environment configuration application to support atomic state rollbacks and adds comprehensive unit tests. The review feedback suggests minor TypeScript improvements, such as adding explicit type annotations to function parameters, filtering out potentially undefined platform-specific error numbers to avoid NaN values in a Set, and safely wrapping property accesses on caught errors in try/catch blocks to prevent unexpected crashes.
…o set os.constants.errno.EDQUOT is absent on platforms without quotas, and -undefined is NaN, which sat in the set matching nothing. Filter before negating so the set holds only the errnos the platform actually reports. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
Bot pass triaged: the Windows/EDQUOT one was real and is fixed in 64fe91e — |
|
Reviewed; no blockers found. |
…r the log fallback Round-4 review findings: - discardConfigState now puts back the bytes that were on disk instead of deleting the state file. originalValues accumulates across every prior boot and lives nowhere else - the config file holds the env-derived value - so deleting it made that value the new "original" and the operator's real one unrecoverable. Deletion is now the last resort for when the restore itself cannot be written. - The snapshot carries a pendingConfigWrite mark between its own write and the config write, and a snapshot still marked when it is next loaded is discarded. That closes the interruption window the ordering alone could not: a crash between the two renames used to leave a snapshot ahead of the file, which the next boot reads as a manual user edit, permanently handing those paths to 'user' and disabling the env layer. A boot that re-derives the same state still writes nothing at all. - A refused log append now sets a short cooldown, so a persistently full volume costs one failed syscall every few seconds instead of one per log line on the request path. - The log fallback has real coverage: a child process logging to /dev/full (which refuses every write) must survive, must land its entry on stdout, and must not recurse through the stdio guard. Skipped on platforms without /dev/full; CI runs Linux. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The child process needs no Harper root; the reference was to a binding in a sibling describe and failed lint. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Nothing asserted that the pending mark is cleared only after the config file lands, or that it is left in place when that write fails - the two branches whose wiring the rollback bug lived in. Both use the stubs the file already has. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…er over it The pending marker introduced in 34ca332 wrote itself over the confirmed state, so clearing it was a write - and on the volume this PR is about, that write can be refused and swallowed. The config file would be on disk, the marker still set, and the next boot would discard the state and re-derive "originals" from a config file that already holds the env-derived values. The operator's real value, which lived only in that state file, was gone. No crash was needed to reach it; it was the primary scenario. The new state is now staged in a sidecar (.harper-config-state.pending.json) and promoted over the confirmed record with a rename once the config file has landed. A rename needs no free space, so the confirmed record is never at the mercy of a write an exhausted volume can refuse: a refused staging write leaves the config file alone, a refused config write drops the sidecar, and either way every recorded original survives. A sidecar left by an interrupted commit is cleared on the next boot, which also skips drift detection for that boot - it cannot tell a manual user edit from the write that was in flight, and guessing wrong hands those paths to 'user' permanently. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
… console The recursion the fallback avoids runs through console, and installStdioGuard only routes console output back into the file logger under a wiring the child did not reproduce - so the stack-overflow assertion could pass vacuously. Making any console use throw inside the child asserts the invariant directly. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
Note on the red Unit Test check, since it isn't this PR:
Config and logging suites pass on both macOS and Linux (368 passing), and the new storage-exhaustion file is 16 passing on Linux where |
Both single-file orderings of the config file and its env-config state lose something, and the reason is not visible from either call site: the state file is the only copy of the operator's pre-env values, because the config file already holds the env-derived one. Write down why the commit is staged and promoted by rename, and why boot-path writes are best-effort while user-requested ones are not. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…d processes initConfig runs in every worker thread and every CLI invocation, so one fixed sidecar name is a race with three losing outcomes: a peer clears an in-flight commit and its owner's promotion silently no-ops (config file rewritten, confirmed state stale, next boot calls the difference a user edit); the promotion's bare renameSync hits ENOENT and aborts boot on the very volume this PR is about; and each losing thread announces an interrupted commit, turning drift detection off on a healthy boot. The sidecar is now named per pid, recovery only clears one whose owning process is gone, the promotion goes through the same rename-retry loop atomicWriteFile uses (Windows holds the destination open) and logs when it cannot promote, and only the main thread persists at all - workers derive the same config and have nothing to add by writing it. Also, takeInterruptedCommit no longer claims an interruption it could not clear, which would have disabled drift detection on every subsequent boot. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
| // Every worker thread runs initConfig and derives the same merged config, so letting them all | ||
| // persist it means N threads racing over one pair of files for a result they already agree on. | ||
| // The main thread owns the on-disk copy; a worker runs on the in-memory one. | ||
| if (!isMainThread) return; |
There was a problem hiding this comment.
What: This if (!isMainThread) return; guard is the crux of 6a7bf1a37 (the fix for workers racing to persist config), but no test exercises the worker-thread leg — every existing test runs applyRuntimeEnvVarConfig on the main thread, so a regression here (e.g. an inverted condition, or a check that stops matching after a future refactor) would silently reintroduce the exact multi-thread write race this commit fixes, with nothing to catch it.
Why it matters: this is a "new runtime-shape branch" per the testing guidance — both legs need coverage, and the worker-thread leg (the actual bug fix) currently has none.
Suggested fix: unitTests/config/configUtils-runtimeEnvVars.test.js already uses rewire (configUtils.__get__(...)), so the worker branch can be pinned without adding a new stubbing mechanism: configUtils.__set__('isMainThread', false) for a case asserting saveEnvConfigStateStub/fsWriteFileSyncStub/fsRenameSyncStub are never called when not on the main thread.
| // Every worker thread runs initConfig and derives the same merged config, so letting them all | ||
| // persist it means N threads racing over one pair of files for a result they already agree on. | ||
| // The main thread owns the on-disk copy; a worker runs on the in-memory one. | ||
| if (!isMainThread) return; |
There was a problem hiding this comment.
What: This if (!isMainThread) return; guard is the crux of 6a7bf1a37 (the fix for workers racing to persist config), but no test exercises the worker-thread leg — every existing test runs applyRuntimeEnvVarConfig on the main thread, so a regression here (e.g. an inverted condition, or a check that stops matching after a future refactor) would silently reintroduce the exact multi-thread write race this commit fixes, with nothing to catch it.
Why it matters: this is a "new runtime-shape branch" per the testing guidance — both legs need coverage, and the worker-thread leg (the actual bug fix) currently has none.
Suggested fix: unitTests/config/configUtils-runtimeEnvVars.test.js already uses rewire (configUtils.__get__(...)), so the worker branch can be pinned without adding a new stubbing mechanism: configUtils.__set__('isMainThread', false) for a case asserting saveEnvConfigStateStub/fsWriteFileSyncStub/fsRenameSyncStub are never called when not on the main thread.
… guessing a dead pid The isMainThread guard is the whole of the cross-thread race fix and nothing exercised the worker leg - only a real worker can, since the guard reads worker_threads.isMainThread. Removing the guard from the built output makes this test fail, which is the point. The liveness case now takes a pid from a child process that has exited rather than hardcoding one and hoping it is free. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…env vars The fixture ran createConfigFile in a child that inherited the mocha process env. With a sibling suite's HARPER_SET_CONFIG still set, install wrote the very env-config state file this test asserts a worker does not create - so the test failed on the suite and passed standalone. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Worker threads share process.pid, so the liveness check exempted other processes but not sibling threads: a worker's recovery scan deleted the main thread's in-flight sidecar as if it were the last boot's wreckage. The main thread's promote then found nothing, leaving the config file on disk with the confirmed state still describing the old values - the exact outcome the protocol exists to prevent - and the worker announced an interrupted commit on a healthy boot. Recovery now runs only on the main thread, which is also the only thread that stages anything. The log fallback's one-shot diagnostic flag is reset alongside the cooldown, so a volume that fills again months later reports itself again instead of rerouting to stdout silently. DESIGN.md records both, and states plainly that the pair commits as a unit within a process only - two live processes can still interleave, which is pre-existing and unserialized anywhere in the repo. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…etection An observing process left the live owner's sidecar alone, as it should, but then compared the config file against a state the owner had not promoted yet - so a CLI invocation racing a service start could have its half-written pair read as a manual edit, permanently reassigning those paths to 'user'. A pair someone else is mid-commit on is no more comparable than one an interruption left behind, so both now skip drift for the boot. The worker test staged its sidecar after constructing the Worker, so on a fast runner the assertion could pass without anything having looked at the file; it now stages first, and an 'error' handler means a worker that dies outside its try fails the test instead of timing out and leaking the temp root. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…tection Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
| // halfway through is no more comparable than an interrupted one, so drift detection is off | ||
| // for this boot either way. | ||
| interrupted = true; | ||
| continue; |
There was a problem hiding this comment.
Suggestion (non-blocking): the new interrupted = true on a live-foreign-sidecar path (fixed in bb4ad1802) has no test asserting its actual consequence — that detectConfigDrift is skipped and state.sources[path] is not reassigned to 'user' when a live foreign sidecar coincides with a file/state mismatch. unitTests/config/storageExhaustion.test.js's "leaves another live process's staged commit alone" only asserts the sidecar file itself survives. Worth extending that test (or adding one) to also drift the file relative to the recorded state and assert the path's source is untouched — that's the exact regression this commit's own message describes fixing, and nothing currently pins it.
The flag had no test tying it to its consequence. These two do, with a control: a config file that differs from the confirmed snapshot is claimed as a user edit - permanently - when nothing is mid-commit, and is not claimed while another process is. Reverting the fail-safe in the built output makes the second one fail. Setting them up also documents a layer semantic worth knowing: HARPER_DEFAULT_ CONFIG fills gaps rather than overriding the operator, so it only ever owns a path whose value it supplied. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Suspending drift detection for a live foreign owner turned a harmless leak into a permanent one: a sidecar left by a SIGKILLed process whose pid is later reused looks mid-commit on every boot, so drift detection would stay off for good and HARPER_DEFAULT_CONFIG would silently reclaim paths the operator had since edited. A commit is three synchronous steps, so a sidecar older than a minute is wreckage whatever its pid says. Also, the debug line that answers "did something rewrite my config file?" said it had on every boot with an env var set, including the byte-identical ones it skipped. It now distinguishes the two. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…the sidecar age-out Gating recovery on isMainThread left workers running drift detection without it. A worker re-deriving inside the main thread's commit window compares the half-written config file against an unpromoted snapshot, calls the difference a manual edit, and drops the env-supplied value for itself alone - serving different config than its siblings with nothing on disk to show why. A worker never owns the state; in the normal sequence the main thread has already classified and persisted before any worker runs. The 60s age-out was also short enough to delete a slow-but-live writer's sidecar - a CLI stalled under a debugger or a hung overlay write - stranding its config file against an unpromoted state, and it compares mtimes across writers that may not share a clock. Recovery from a recycled pid only has to be eventual, so the threshold is now an hour: unreachable by a stalled writer or plausible skew, still bounded for wreckage. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
Second CI-flake data point, since the Unit Test check has now gone red three times on this branch for three different tests, none of them in this diff:
The one red that was mine ( |
|
Last CI note, so nobody re-derives it: Integration Tests 1/6 (Windows, Node.js v24) is a permanent red on I checked rather than assumed, since this change does touch startup; the base-branch history settles it. Net: on this repo today, "all checks green" is unreachable for any PR that runs the Windows shards, and the unit suite is separately stochastic (previous comment). The signals that are meaningful for this PR — config and logging suites, all Linux/Bun/uWS integration shards, the Next.js adapter, and unit on v22/v24/v26 — are green, with 373 passing on Linux at head. |
Co-Authored-By: GPT-5 Codex <noreply@openai.com>
Co-Authored-By: GPT-5 Codex <noreply@openai.com>
Co-Authored-By: GPT-5 Codex <noreply@openai.com>
Co-Authored-By: GPT-5 Codex <noreply@openai.com>
Harper crash-looped at startup on a storage-exhausted volume: every boot rewrites
harper-config.yamland the env-config state snapshot when aHARPER_*_CONFIGvariable is set, the sibling temp file was refused with EDQUOT, and the error escapedinitConfig— so the container restarted, the same write failed again, and nothing inside it could free space because nothing could start. Boot-path persistence of derived config is now best-effort: the effective configuration is already merged and validated in memory, so ENOSPC/EDQUOT is logged and startup continues on it, while install and user-requested writes (set_configurationand friends) still persist or throw. A refused log append also degrades to stdout instead of throwing, which otherwise made every log statement on a full volume a crash point — including the one reporting the storage problem.Necessary but, on its own, not sufficient: this gets configuration initialization and logging through an exhausted volume. A full
harper startthere still needs writable space for the storage engine, so "one process starts and the scheduled cleanup runs" is not proven by this change. #846 remains the reason a hosted node reaches this state.Fixes #847.
For the human reviewer
Boot continues on the in-memory config instead of failing fast. The alternative is to keep aborting and require operator intervention, which is what produced the outage. The trade is a guaranteed-consistent on-disk config for availability — everything below is a consequence of admitting that divergence. Reversible in one function (
persistConfigDuringBoot), but if you disagree with the premise, the rest of the PR follows from it.Only ENOSPC/EDQUOT degrade; EROFS, EACCES and EIO still abort boot. A read-only remount produces the identical un-breakable restart loop, so this leaves the same field outage reachable by another route. Narrow now on purpose, trivially widened later by adding codes to one set — I'd rather not widen it on speculation.
The two artifacts move together, or not at all — and the confirmed record is never at the mercy of a write. The env-config state records the file's pre-env values and is the only copy of them, so the new state is staged in a sidecar, the config file is written, and the sidecar is then promoted with a rename. A rename needs no free space, which is the whole point: a refused staging write leaves the config file alone, a refused config write drops the sidecar, and in both cases every recorded original survives untouched. A sidecar left by an interrupted commit is cleared on the next boot, which also skips drift detection for that boot — it cannot distinguish a manual user edit from the write that was in flight, and guessing wrong hands those paths to
userpermanently. A boot that re-derives the same state writes nothing at all. This is the subtlest part of the PR and where I'd look hardest; two earlier shapes of it (snapshot-last, then a pending marker written over the confirmed record) each had their own data-loss path, both caught in review.ensureConfigKeysPresentreports nothing it could not persist, so on an exhausted volume a newly-introduced built-in (waf,secretCustody) stays dormant for that boot and the startup log says so. The rejected alternative was activating it in the main thread's memory: worker threads re-read the config from disk and never run the backfill, so that gives a boot where the log claims WAF is on and every request-handling worker is without it. Dormant-and-honest beats active-in-name-only, but it is a security-component policy call and yours to make.Install still fails hard on a full volume. There is no last-known-good config to fall back on and no process to keep alive, so its snapshot write stays mandatory. Worth a line in the release note: boot tolerates a full volume, install does not.
skipIfUnchangedis opt-in insideatomicWriteFile. Skipping means no mtime bump and therefore no watcher event, so it is a watcher-visible semantic that every call site can now switch. Only the two artifacts that are re-derived identically each boot pass it. A separatewriteFileIfChangedwas the alternative; cheap to move, awkward to un-ship once callers depend on the flag.Log fallbacks bypass the stdio guard, and mirror to stdout unconditionally.
installStdioGuardroutes console output back into the file logger whenlogging.fileandlogging.consoleare both on, so the obviousconsole.logfallback recurses until the stack blows; everything now goes through one non-throwingwriteToStdioDirectly. Two consequences worth your ruling: the no-descriptor branch used to drop every entry after the first and now emits them, and the mirror does not consultlogging.console— so a deployment that deliberately keeps log content out of stdout will see it there while the volume is full. I took diagnostics over silence, but gating the mirror on the console setting is a one-line change if you'd rather. A refused append also now sets a 5s cooldown, so a persistently full volume costs one failed syscall every few seconds instead of one per log line on the request path.The deeper simplification I did not take. All of this subtlety exists because boot rewrites the operator's own
harper-config.yamlwith env-derived values, which is what makes a separate record of the pre-env values necessary in the first place. Stop doing that — keep the user's file untouched and hold the env overlay separately — and the two-artifact ordering problem disappears entirely rather than being solved. I did not do it here: it changes what operators see when they read the config file, changes restoration semantics, and needs a migration for existing state/YAML pairs, none of which belongs in a P1 availability fix. Worth its own issue if you agree.Verification
Unit —
unitTests/config/storageExhaustion.test.js(new,node:assertagainst the real modules per AGENTS.md): errno classification including Linux's unmapped-122, the swallow/rethrow boundary,skipIfUnchangedbehaviour and the default path, temp-file cleanup when the write itself fails, the two-phase snapshot contract (including that an unchanged boot writes nothing and that a still-pending snapshot is discarded on load), and the rollback primitive. The log fallback is covered for real rather than with stubs: a child process logging to/dev/full— which refuses every write — must survive, land its entry on stdout, and not recurse through the stdio guard. It skips where/dev/fulldoes not exist (macOS) and runs on CI.Fails on base — with
config/andutility/logging/restored fromorigin/mainanddistforce-rebuilt, the same command gives374 passing, 14 failing: 13 of the 14 new cases plus the runtime-env-var file'sbeforehook, which cannot stub a function that does not exist on base. (The fourteenth — temp cleanup on an ENOENT write — passes on base, which never creates the temp file in that path.) Restored and rebuilt: green again.End-to-end, on a genuinely full volume —
~/dev/scripts/harper-full-volume-boot-check.sh: a 20MB HFS+ image with an existing config file, an existing log file and the standard directories, filled to zero free bytes, theninitConfigwith aHARPER_SET_CONFIGlayer that must be merged, plus log lines larger than the volume's remaining slack.Same rig on
origin/mainexits non-zero atinitConfigwithENOSPC … backup/.harper-config-state.json.<pid>.<tid>.<hex>.tmp— the reported wedge.Not covered by the repeatable suite: the coordinator's failure sequences (which write is refused in which order) and the log-append degradation. Both need a filesystem that refuses writes, which no unit test can arrange portably, and AGENTS.md rules out reaching for stubs to fake it — hence the live rig above.
test:unit:mainandtest:integration:allwere not run locally (the shared RocksDB system-database lock on this machine fails them before any test executes); CI covers them.Outside-review coverage: nine completed rounds — Codex + Gemini + Cursor Composer + Harper-domain adjudication at
6ee5ded, Codex + Gemini at292edf53, Codex + Gemini + Harper-domain adjudication at6ededadd, and delta rounds (same reviewers, resumed sessions) through this head. Rounds 1–7 each found a real defect in the previous round's fix, all inside the same ~20 lines of commit ordering: snapshot-last lost the operator's originals; snapshot-first left a snapshot ahead of the file; the rollback unlink destroyed originals accumulated over months; the pending marker needed a write an exhausted volume refuses; one fixed sidecar name raced every worker thread; and the pid liveness check exempted processes but not sibling threads, which share a pid. Round 8 was the first with no major, and its two minors (a test assertion that could pass vacuously, and an observer comparing against another process's half-committed pair) are fixed. That history — six data-loss paths and one boot-abort, none of which my own testing found — is the argument for looking hard at item 3 specifically, and the reason this is still draft.Complexity: complicated
Review-Coverage: authored=codex; ran=claude,gemini; declined=cursor-grok,cursor-composer,domain; rounds=4 @ b7180c9
Human-Review-Need: 3 @ b7180c9