Skip to content

Keep Harper booting when its storage volume is full or at quota - #2245

Open
kriszyp wants to merge 24 commits into
mainfrom
kris/847-edquot-boot-tolerance
Open

Keep Harper booting when its storage volume is full or at quota#2245
kriszyp wants to merge 24 commits into
mainfrom
kris/847-edquot-boot-tolerance

Conversation

@kriszyp

@kriszyp kriszyp commented Aug 20, 2026

Copy link
Copy Markdown
Member

Harper crash-looped at startup on a storage-exhausted volume: every boot rewrites harper-config.yaml and the env-config state snapshot when a HARPER_*_CONFIG variable is set, the sibling temp file was refused with EDQUOT, and the error escaped initConfig — 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_configuration and 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 start there 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

  1. 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.

  2. 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.

  3. 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 user permanently. 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.

  4. ensureConfigKeysPresent reports 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.

  5. 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.

  6. skipIfUnchanged is opt-in inside atomicWriteFile. 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 separate writeFileIfChanged was the alternative; cheap to move, awkward to un-ship once callers depend on the flag.

  7. Log fallbacks bypass the stdio guard, and mirror to stdout unconditionally. installStdioGuard routes console output back into the file logger when logging.file and logging.console are both on, so the obvious console.log fallback recurses until the stack blows; everything now goes through one non-throwing writeToStdioDirectly. 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 consult logging.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.

  8. The deeper simplification I did not take. All of this subtlety exists because boot rewrites the operator's own harper-config.yaml with 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

UnitunitTests/config/storageExhaustion.test.js (new, node:assert against the real modules per AGENTS.md): errno classification including Linux's unmapped -122, the swallow/rethrow boundary, skipIfUnchanged behaviour 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/full does not exist (macOS) and runs on CI.

npx mocha "unitTests/config/**/*.js" "unitTests/utility/logging/**/*.js"   →  401 passing, 14 pending

Fails on base — with config/ and utility/logging/ restored from origin/main and dist force-rebuilt, the same command gives 374 passing, 14 failing: 13 of the 14 new cases plus the runtime-env-var file's before hook, 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, then initConfig with a HARPER_SET_CONFIG layer that must be merged, plus log lines larger than the volume's remaining slack.

free bytes: 0
RESULT append-premise=ENOSPC                      # the log append genuinely cannot land
Harper cannot write to its log file: ENOSPC: no space left on device, write
2026-08-20T13:28:07.154Z [main/0] [error]: unwritable line 0 …   # via nativeStdWrite, no recursion
RESULT initConfig-returned=true
RESULT effective-http-port=8123                   # env layer applied from memory
RESULT config-file-unchanged=true
RESULT leftover-temp-files=none
RESULT env-state-file-present=false               # no snapshot left ahead of the file
exit=0

Same rig on origin/main exits non-zero at initConfig with ENOSPC … 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:main and test:integration:all were 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 at 292edf53, Codex + Gemini + Harper-domain adjudication at 6ededadd, 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

kriszyp and others added 3 commits August 20, 2026 07:04
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>

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread config/configUtils.ts Outdated
Comment thread config/configUtils.ts
Comment thread config/configUtils.ts
Comment thread config/configUtils.ts
…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>
@kriszyp

kriszyp commented Aug 20, 2026

Copy link
Copy Markdown
Member Author

Bot pass triaged: the Windows/EDQUOT one was real and is fixed in 64fe91eos.constants.errno.EDQUOT is absent on platforms without quotas and -undefined is NaN, so the set held a value matching nothing. The three type-annotation suggestions I declined: configUtils.ts types its module-level helpers loosely throughout (atomicWriteFile(filePath, content, …) and neighbours are untyped), and annotating only the functions this PR adds would make the new code the odd one out rather than the file consistent. Happy to type them if we'd rather start converging the file.

@claude

claude Bot commented Aug 20, 2026

Copy link
Copy Markdown
Contributor

Reviewed; no blockers found.

kriszyp and others added 2 commits August 20, 2026 07:50
…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>
Comment thread config/configUtils.ts Outdated
kriszyp and others added 4 commits August 20, 2026 07:58
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>
@kriszyp

kriszyp commented Aug 20, 2026

Copy link
Copy Markdown
Member Author

Note on the red Unit Test check, since it isn't this PR:

  • log writes that cannot land (#847) failed on 34ca3328 with ReferenceError: testRoot — that one was mine, fixed in ac35021a.
  • HNSW greedy routing above layer 0 (ROUTING_EF) (unitTests/resources/vectorIndex.test.js:673) is not. I ran test:unit:resources against origin/main on a Linux box: 3 failing there too (table() randomAccessFields directive among them). Main's Unit Test job has failed on three different tests across its three most recent runs — pp_updated49, reloadMarker (harper-pro#495), and this one — so the suite is flaky right now, and nothing in this diff can reach HNSW neighbour sets.

Config and logging suites pass on both macOS and Linux (368 passing), and the new storage-exhaustion file is 16 passing on Linux where /dev/full exists.

kriszyp and others added 2 commits August 20, 2026 08:14
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>
Comment thread config/configUtils.ts
// 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;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread config/configUtils.ts
// 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;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

kriszyp and others added 5 commits August 20, 2026 08:28
… 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;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

kriszyp and others added 2 commits August 20, 2026 08:50
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>
@kriszyp

kriszyp commented Aug 20, 2026

Copy link
Copy Markdown
Member Author

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:

Run Job Failing test
64fe91e5, ac35021a v26 HNSW greedy routing above layer 0 (vectorIndex.test.js:673)
871c51d6 v22 put with immediate expiration on sourced table should expire immediately (apiTests/cache-test.mjs:73)
871c51d6 v24 HNSW greedy routing above layer 0 again

origin/main fails too — I ran test:unit:resources against it on Linux and got 3 failures (table() randomAccessFields directive among them), and main's own Unit Test job has failed on pp_updated49 and reloadMarker (harper-pro#495) in its recent runs. The suite currently goes green or red by luck, which makes "CI green" a stochastic gate for anything merging right now — worth someone's attention independently of this PR.

The one red that was mine (ReferenceError: testRoot) was fixed in ac35021a. This PR's own tests — 21 in storageExhaustion.test.js, including the /dev/full and worker-thread legs — have passed on every run where the job reached them, and 373 pass on Linux at head.

@kriszyp
kriszyp marked this pull request as ready for review August 20, 2026 15:57
@kriszyp

kriszyp commented Aug 20, 2026

Copy link
Copy Markdown
Member Author

Last CI note, so nobody re-derives it: Integration Tests 1/6 (Windows, Node.js v24) is a permanent red on main, not a flake and not this PR. It has failed on all four recent main runs including b51e92af (this branch's base) and on every sha here, with the shard's requests all returning ECONNREFUSED — the server never finishes coming up. That matches #2243 (Windows RocksDB seed takes 83–238s versus ~2s on Linux, so the seed outruns the readiness window).

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.

kriszyp and others added 4 commits August 21, 2026 07:30
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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Harper crash-loops on EDQUOT during config tmp-file write, with no recoverable path

1 participant