Skip to content

fix(config): read root config synchronously and bound rename retries by wall clock - #2191

Open
kriszyp wants to merge 9 commits into
mainfrom
kris/win-rename-retry-budget
Open

fix(config): read root config synchronously and bound rename retries by wall clock#2191
kriszyp wants to merge 9 commits into
mainfrom
kris/win-rename-retry-budget

Conversation

@kriszyp

@kriszyp kriszyp commented Aug 17, 2026

Copy link
Copy Markdown
Member

The Windows EPERM: rename harper-config.yaml.tmp was never a budget that was too short: the worker was blocked against a descriptor its own root-config watcher held. libuv opens a fsPromises.readFile descriptor on the threadpool but closes it from JS, which cannot run while the same thread sits in atomicWriteFile's Atomics.wait — so no rename budget could have been long enough, and the extra 6.4 seconds an earlier head bought was pure event-loop stall on a path set_configuration reaches from a live request thread.

So the write side goes back to the 3,630 ms window it had before this branch, and the read side stops holding a handle across a yield: both root-config watchers read through readConfigFileSync, and a read that still loses is retried from a timer, which holds no descriptor either.

Review follow-up tightened two boundaries around that rule. An OptionsWatcher only blocks when it is explicitly the root watcher, so an application config remains asynchronous even when the component calls it harper-config.yaml; the production-shaped regression pins that distinction. And a terminal read fallback still discards an earlier staged value, while a watcher scan error preserves a successfully staged config because no later read superseded it; the mirrored boot tests cover both outcomes.

A later round closed the last path that could reach Scope without settling its barrier, and CI found the last test still encoding the contract that changed under it. #handleUnlink cancels the read ladder — the deletion is what a pending read was retrying — so a deletion landing while the first read was still on that ladder cancelled the only thing that would ever have emitted ready, and Scope, still inside await scope.ready, answered the remove by requesting a restart of a component that never booted. A boot-window deletion now settles on the defaults and reports remove only after ready, the ruling the ENOENT read path beside it already applies, and it claims the current read sequence so a rung already in flight on the asynchronous path cannot report the same deletion again behind it. On the test side should handle default config resolution still waited for a change where a scope that booted on its truthy default now takes the ready its arrival is; with timeout: 0 in .mocharc.json that wedged the whole unit job on all three Node versions rather than failing the one case.

main shipped an independent fix for this same root cause while the branch was open#2339, merged as 99169ebca. Both make the root-config read synchronous; they disagree on the machinery, and landing both is not an option, so this branch is now rebased onto it and reconciled rather than merged alongside it. This branch's mechanism is kept because it is the superset — a shared per-thread blocking window, a wall-clock ladder instead of PartialReadRetry's attempt count, an arm gate over chokidar's unarmed window, credential-scrubbed parse errors, and a boot barrier every terminal read outcome settles — and PartialReadRetry / isPartialReadError / watcherFallback's additions go with it. What main had and this branch did not is ported rather than lost: a read that parses to nothing is a mid-write read like an empty one; atomicWriteFile reports the attempts and elapsed time behind a rename it could not complete; and configReadHandleLifetime.test.js keeps pinning the descriptor lifetime, against this branch's API. Which implementation wins is the first item below.

Making the read synchronous exposed three states the threadpool round-trip had been hiding, each of which ends with a worker holding a config nothing will ever correct:

  • A write can land before the watch is armed. ready used to be emitted from inside chokidar's initial add; on darwin a write in that window is lost outright (0 ms lost, 5 ms delivered, as measured on this PR). ready is now gated on chokidar's own ready plus a darwin-only grace, and arming always re-reads rather than publishing what an earlier read staged.
  • A non-atomic writer truncates before it writes, and the synchronous read is fast enough to land in that window — while chokidar throttles away the event carrying the content as a duplicate. An empty read now takes the same bounded ladder a locked one does, in both watchers. On Linux this was already reproducible: should instantiate and watch the root Harper config file failed 4 of 6 runs at this branch's earlier head and 0 of 6 at its merge base.
  • A read can end with no config at all — the ladder spent, the file unparseable, no file to read. harper_logger.start() and Scope.ready await those barriers with no timeout, so each outcome now boots on defaults and logs what failed instead of hanging the worker. It settles carrying nothing rather than {}, because updateLogSettings reads an empty config as "rotation off, console off" — the one boot that could not read its config is the worst one to silently disable logging on.

A config that arrives after a scope has settled is the other half of settling early: nothing downstream re-runs on it, because componentLoader is long past its await scope.ready. Scope now answers a repeat ready the same way it answers remove — by requesting a restart — so a worker that read the file in a truncate window cannot keep serving the defaults while its siblings serve the operator's config.

Two smaller ones on the way through: a parse failure goes through parseConfigFile with logLevel: 'error', so neither yaml's framed error nor its process.emitWarning path can put config source — credentials included — into a log line; and a chokidar callback queued before close() is dropped on entry instead of reading a file a shutting-down worker no longer wants. DESIGN.md carries all of these invariants so neither watcher gets "modernized" back to fsPromises.readFile.

For the human reviewer

Framing-Verdict: chosen-approach-sound

  1. This branch replaces main's shipped Fix intermittent set_configuration 500s on Windows caused by the config write's rename retry #2339 implementation rather than layering on it. The reconciliation removes PartialReadRetry, isPartialReadError and warnWatcherListenerError and returns utility/watcherFallback.ts to its pre-Fix intermittent set_configuration 500s on Windows caused by the config write's rename retry #2339 shape, because the two answer the same invariant with different machinery — an attempt count against a wall clock — and running both ladders over one read is worse than either. The narrower alternative is to keep Fix intermittent set_configuration 500s on Windows caused by the config write's rename retry #2339's read and land only the arm gate and boot-barrier work on top of it, which drops the shared blocking window (point 10) and the wall-clock budget the original review thread asked for. Everything Fix intermittent set_configuration 500s on Windows caused by the config write's rename retry #2339 had that this branch lacked is carried over commit-by-commit in 13af84b8; that commit is the one to read if you want to check nothing was dropped in the trade.
  2. A root-config read that gives up is now reported per scope, not once per file. main's PartialReadRetry carried a module-level partialReadWarned set keyed by file path, so exactly one watcher's give-up was ever logged for harper-config.yaml; the reconciliation drops that set with the rest of that machinery, and each of the 10+ per-worker OptionsWatchers now warns under its own scope tag. Deliberate rather than dropped: on this branch a give-up is not only "the read failed", it is "this scope settled on the defaults", which is a per-scope consequence an operator needs named — but the cost is 10+ tagged lines per worker per incident where main emitted one, and a shared gate would silence 9 of them. Reinstating one is a module-level Map<path, boolean> cleared on the next successful read; the reviewer asked for it explicitly.
  3. Root-config watchers now block the event loop to read (readFileSync, plus up to 500 ms of Atomics.wait on win32). The alternative is to make the writer stop blocking — an async rename retry, or moving set_configuration's write off the request thread — which leaves the 3.63 s synchronous stall this change preserves at the other end. That is the larger API change, and reversing this one later means re-touching both watchers and DESIGN.md.
  4. ready is not once-per-watcher, and a repeat now costs a restart. OptionsWatcher emits it whenever a scope goes from having no config of its own to having one — the recreated-config-file path, and a scope that booted while the file was unreadable — and Scope converts a repeat into requestRestart(), on the same terms as its remove listener (a plugin with its own ready handler owns the response). The narrower alternative is to leave the arrival inert and accept per-worker config divergence until the next restart. Worth a look: #applyScopedConfig tracks whether the scope was configured separately from the value it holds, so a re-read of an unchanged falsy scope value (myPlugin: with no body) is not a transition, and the six scopes DEFAULT_CONFIG names do not read their own boot fallback as a block the file supplied.
  5. The arming re-read deliberately does not report a deletion. Its job is the write that landed while the watch was unarmed; answering its ENOENT with remove reported the deletion ahead of the unlink that would confirm it, and on darwin ahead of chokidar finishing its own teardown — so a config recreated on the strength of that early remove landed where its add was not observed at all. It now goes back to the loop once and chokidar's own unlink cancels it, which is what fixed the reviewer's 3/3 macOS failure of does not write an applied config into the shared defaults. It is still a deferral rather than an observable signal: a platform where chokidar never emits unlink for a real deletion would see one extra read and no remove.
  6. The read gates its retry to win32; the rename does not (same three codes, any platform). Deliberate: a misclassified read falls through to the timer ladder and still recovers, a rename has nothing to fall through to, and process.platform does not answer whether this filesystem can replace an open file (WSL drvfs, CIFS/SMB, Docker Desktop bind mounts all report linux). The cost is that a genuine Linux EACCES on rename spends the 3.63 s budget before surfacing. One-line flip either way. ENOENT is excluded from the read ladder entirely — a missing file is not a lock, and OptionsWatcher has always settled it at once as the install window.
  7. A terminal failure in the boot window starts the scope on defaults, rather than failing the boot closed. It matches the ENOENT branch beside it, but a worker can now boot a plugin on defaults where it previously would have waited — and a Harper running on the wrong config is arguably worse than one that refuses to start. That policy call is the same in both watchers by design; changing it is a one-place change in each.
  8. The read-retry ladder gives up permanently. After 500 ms of blocking plus a 3.1 s ladder the watcher warns and keeps the last valid config, and no further event is coming — the rename already fired. A lock outliving ~3.6 s therefore leaves that one worker on the old config until the next write or a restart, while its siblings apply the new one, and set_configuration still reports success. Strictly better than main's silent swallow; a slow re-arm or a periodic reconciliation would close it and is purely additive.
  9. An empty file past the ladder is kept by a running worker and taken as defaults by a booting one. Both reviewers have now asked for a ruling on this asymmetry. An empty read that outlives the ladder is believed, and at that point #settleUnconfigured is a no-op on a watcher that has already emitted ready — so an operator who truncates harper-config.yaml at runtime gets a warning and keeps the config in force, while any worker that boots against that same empty file starts on the defaults. Keeping what is applied is the safer half (an empty file is far more often a truncate window than an intent); making the two halves agree means either believing the emptying at runtime, which hands a truncate window the power to reset every scope, or holding the boot open, which is the hang this branch exists to remove.
  10. One 500 ms read deadline per path per thread, with a one-budget grace after it expires. A worker holds 10+ OptionsWatchers over the same root config, so a per-call budget would serialize into N × 500 ms of blocked event loop; sharing it means the first watcher spends the window and its siblings fail fast into the ladder. What remains per config event is N synchronous reads of one small file, which used to run on the threadpool. The grace is why a second lock arriving inside one budget of the first one's expiry blocks not at all and goes straight to the ladder — recoverable, but it is a real corner.
  11. The 20 ms darwin arming grace is one measurement on one machine. 0 ms loses the write, 5 ms delivers it, and chokidar's own ready alone does not close the race (the reviewer tested it). A slower or loaded darwin host silently reopens the window. An observable arming signal would remove the timing assumption; chokidar does not expose one. Two things make it auditable from Linux CI rather than only from a mac: the value is a function of the platform, and ArmGate takes it as a constructor argument, so the timer branch itself is exercised on a host whose own grace is 0. Before that, forcing the grace to 0 unconditionally left the arming suite green.
  12. OptionsWatcher shares the arming re-read, but not the arming barrier. This PR's switch to synchronous reads is what opened the unarmed window for the 10+ root-config OptionsWatchers componentLoader creates per worker (at the merge base they all read via fsPromises.readFile, whose threadpool round-trip deferred past it), so the gate is now shared rather than left to RootConfigWatcher. What OptionsWatcher takes is the re-read that recovers the otherwise-undeliverable write; its ready still goes out on the first read, so it means "the config has been read", not "armed". The ordering difference is safe because Scope attaches its listeners in its constructor, before any read — the recovered write arrives as a post-ready change rather than being lost. Holding ready behind arming too would need every terminal outcome to open a second barrier per scope, with a boot hang as the failure mode. Still declined and left for a follow-up: the lock ladder stays gated to root-config filenames, so an application config.yaml held by AV on Windows goes stale with only a log line. That one is genuinely pre-existing — an async read of a locked file failed the same way — and closing it means touching every component scope's readiness.
  13. Three new test seams. OptionsWatcher gains _refreshForTests() and a read counter — they match the class's existing members and are what lets a test tell a ladder rung from a chokidar event (chokidar reports the unlocking chmod as a change of its own), but _refreshForTests() does expose a synchronous config re-read to any caller. harper_logger gains _applyLogSettingsForTests: both guards this branch added to updateLogSettings were unreachable from the suite, and the watcher that function builds resolves a process-wide path, so the only alternative was the rewire AGENTS.md forbids. The split is behaviour-preserving — the watcher wiring stays in updateLogSettings, the applying half moves down into applyLogSettings(rootConfigObject) — but it is a new export on a module almost everything imports. ArmGate's grace becomes a constructor argument for the same reason (point 11); every production site passes none.
  14. atomicWriteFile now throws RangeError on non-finite/negative retry options instead of clamping. A new throw on the config-write path, whose only non-default callers are tests.
  15. Two writer tests stub renameSync and writeFileSync (Sinon), which AGENTS.md tells new tests not to do, and a third now spends the whole 3.63 s default budget. Deliberate on both counts: neither stubbed case can be provoked from a real Linux filesystem — a POSIX rename over an open file succeeds, and a part-way ENOSPC write cannot be staged — and the default-budget case exists because every other case overrides retryBudgetMs, so widening the shipped default to effectively unbounded left the whole suite green. The reader-side tests take the no-stub route (a real mode-000 file). One watcher case also drives its own re-read through _refreshForTests() rather than the chokidar event, because recreating a watched file inside chokidar's unlink teardown is not reliably reported on any platform — see ## Verification.
  16. A late exhaustion error from the generation that already failed is attributed to its replacement. Both watchers now guard the terminal "the replacement failed too" branch on "no reopen is coming" rather than #openCount > 1 (which left a watcher polling from construction with nothing to settle its barrier at all), but an ENOSPC still draining from generation 1 settles the barrier as a failure even though the polling watcher is healthy. The consequence is bounded: the settle is on the defaults, and the replacement's own arming re-read then publishes the real config as a change, so it costs an early settle rather than a blind scope. Distinguishing them properly means tagging errors with the generation that emitted them, in both watchers — worth doing, not worth doing here.
  17. An intentionally empty config costs a 3.1 s boot. An empty read is believed only after the full ladder, so an operator who deliberately leaves harper-config.yaml empty delays harper_logger.start() and every root scope by the whole budget, once per boot. The alternative is distinguishing a truncate window from an empty file by size or mtime, which is a detection scheme rather than a constant — cheap to retune, not cheap to replace.
  18. The atomic write neither preserves the file's mode nor fsyncs it. Both are pre-existing and both were declined here rather than folded into a branch this size. (a) writeFileSync creates the temp file at 0666 & ~umask and renameSync carries that inode's mode onto the destination, so an operator's chmod 600 harper-config.yaml is silently reverted to 0644 by the next set_configuration — below blocker under the one-process-per-container threat model, but the file holds authentication material. The fix is a statSync + chmodSync on the temp path before the rename, plus a decision about what to do when the destination does not exist yet. (b) No fsync on the temp descriptor or the parent directory, so on ext4 with delayed allocation a crash shortly after set_configuration can leave a present, zero-length config file — which the new empty-read handling downgrades from a hang to "boot on defaults with a warning", but the operator's configuration is silently not in force until someone rewrites it. An fsync on the set_configuration request thread is its own latency decision.
  19. A deletion in the boot window is now a ready on the defaults, not a remove (#handleUnlink). This is the ruling the last round left open. remove before the first ready is not a coherent report in either direction: nothing consumes it — componentLoader is inside await scope.ready — and Scope answers it with requestRestart(), so the same deletion that cancelled the ladder also asks for a restart of a component that never started. Settling on the defaults matches the ENOENT branch beside it and every other terminal outcome on this branch. The alternative ruling — that a boot-window deletion means remove, and the barrier is settled some other way — is a one-line flip here plus a second settle path. What it costs as written: an operator who deletes the config inside the boot window gets a worker on defaults with a warning, where a running worker would have got a restart.
  20. A deletion the asynchronous read observes first still reports twice, and that half is unchanged. A rung's readFile can reject ENOENT before chokidar delivers its unlink (held back ~100 ms by its own atomic-write window): the read settles boot ready on the defaults, and the unlink then finds #readyEmitted and emits remove, so one deletion produces ready + a restart request. That ordering predates this branch and is untouched by it — item 19 only changes what happens when the unlink wins. Making it idempotent properly means gating both remove sites on #scopeConfigured captured before the reset, rather than on #rootConfig's truthiness, which is the same unification #applyContents already applies to a scope removed from a file that still exists; it changes what a deletion means for a scope that was never configured, which is why it is not folded in here. Raised by the pre-push reviewer in both orders across two rounds.
  21. The in-flight-read guard has no direct test. #handleUnlink claims the read sequence so an asynchronous rung still in flight is outranked instead of reporting the deletion a second time. Provoking exactly that order in a test means holding a readFile past chokidar's ~100 ms unlink delay, which needs libuv's threadpool saturated and pins the test to UV_THREADPOOL_SIZE; I judged that worse than the two lines it would pin. The guard is a strict narrowing — only a read issued before the deletion is suppressed, and every read issued after it still applies — and the synchronous half of the same handler is covered by the boot-window deletion regression.
  22. EBUSY is retryable on every platform, and on Linux rename(2) returns it permanently for a mount point. A single-file bind mount (-v ./harper-config.yaml:/…/harper-config.yaml) is a documented Docker pattern, so set_configuration on such a deployment now blocks the calling worker for the full 3,630 ms budget before throwing the error it used to throw at once, and repeated calls compound. Pre-existing on this branch rather than new in this round, and the same one-line platform-gating question as item 6 — raised independently by the pre-push reviewer in both rounds.
  23. main also shipped an independent, unrelated fix while this branch was open — #2364, the Windows lost-native-watch (EPERM, async) guard — and it touched the exact call sites this branch restructured. #2364 adds guardedWatch()/claimLostNativeWatchError() to utility/watcherFallback.ts and wires both into #openWatcher()/handleError() on OptionsWatcher and RootConfigWatcher — the same two methods this branch already rewrote for the generation-tracked reopen and the ConfigReadRetry/ArmGate machinery. Rebasing onto it was a merge, not a choice between implementations (unlike Fix intermittent set_configuration 500s on Windows caused by the config write's rename retry #2339 at item 1): guardedWatch() wraps the underlying chokidar.watch() call this branch already made, and claimLostNativeWatchError(error) is now the first line of both handleError methods, ahead of this branch's own exhaustion/closed-watcher handling. Nothing from either side was dropped; git diff against origin/main shows both features present together.

Verification

  • npm run build, npm run lint, npx prettier --check — pass at this head (lint reports 13 warnings, 0 errors, all in integrationTests/, bin/ and unitTests/ files this branch does not touch; they reproduce on the merge base).

  • npm run test:unit:main — 5243 passing, 195 pending, 2 failing at this head. Both failures are environmental and outside this diff: configValidator's domain-socket path-length warning, which trips on the long worktree path this ran in, and gitCredentials' grants the credential environment to the spawn that clones, which sees the GIT_CONFIG_GLOBAL/GIT_EDITOR this session's harness exports — it passes with those two unset. Neither file, nor anything either imports, is touched by this branch.

  • npm run test:unit:logging — 141 passing, 14 pending, 0 failing, including two new cases that pin applyLogSettings against a bare myComponent: key and against a barrier that settles carrying no config. Both fail on a build with the guards removed.

  • npm run test:unit:resources — 1 failing, unitTests/resources/vectorIndex.test.js's HNSW greedy routing assertion. Untouched by this diff, and the same assertion failed and then passed on re-run in this branch's own CI history.

  • npx mocha "unitTests/components/**/*test*.js" — 1578 passing, 1 pending; npx mocha "unitTests/config/**/*.js" — 325 passing. Together these cover both watchers, Scope, componentLoader, configUtils, the arm gate and the env overlay.

  • CI's unit job hung at a5d5b7f36 on all three Node versions, and that is what this round fixes. The wedge is should handle default config resolution, which waits for a change where a scope that booted on its truthy DEFAULT_CONFIG value now takes the ready its first source config is — the contract b0c788592 introduced for exactly that case. .mocharc.json sets timeout: 0, so the event that never comes hangs the run instead of failing the case, which is why the job died on a 10-minute step timeout with a green last line. Reproduced locally 3/3 at that head (npx mocha unitTests/components/OptionsWatcher.test.js never returns, and the same test alone hangs), and green 3/3 here. Earlier in the branch the same job wedged on does not write an applied config into the shared defaults, which recreated the config file from a microtask of chokidar's own unlink dispatch; that case now drives its post-recreate read through _refreshForTests() and carries its own timeout, and delivery of a recreate stays asserted by should continue to watch if file is removed and recreated.

  • Every check is green at this head, and Unit Test (Node.js v22) now completes inside its cap with 55 s to spare — but the reason it was slow is not the one recorded above. The two cancels at 10m17s/10m18s were duration, not a hang; re-measured at 4929b4fa the job runs 9m05s against .github/workflows/unit-test.yml's job-level timeout-minutes: 10, which covers install, build and every test phase. Step timings across the matrix put the cost where it actually is:

    job total Run tests test:types install
    v22 9m05s 502 s 1 s 19 s
    v24 8m39s 469 s 25 s
    v26 8m28s 454 s 25 s

    So v22 is not the slowest because it alone runs npm run test:types — that step costs 1 second. It is 33 s slower than v24 in Run tests alone, on the same suite, which is Node 22's own runtime. main runs the same job in 7m01s–7m47s, and what this branch adds is about 45 s of deliberate wall clock: roughly fourteen unit tests let ConfigReadRetry's 3,100 ms budget expire in real time, plus configUtils' 3,631 ms shipped-default case and a 6,202 ms two-budget case. Measured locally at this head, unitTests/config/** takes 33 s and unitTests/components/** 52 s, of which ~47 s is budget expiry. The decision is unchanged and still yours — raise the cap (one line; the job is 92 % Run tests, so a hung run would burn the extra minutes), or make the read budget injectable so those tests stop spending it, which is another test seam of the kind this PR's review has already pushed back on, across fourteen cases. Not done here either way. Worth knowing that 55 s of headroom is inside runner variance: the same commit has now produced 8m11s, 9m05s and two 10m+ cuts.

  • Integration Tests 5/6 (Bun) failed once at this head on Jobs - Delete Jobs_test schema (a 45 s drop_schema timeout that cascaded through the rest of the shard) and passed on re-run. Nothing in that path touches config reads.

  • Each new regression test was checked against the un-fixed build: reverting the fix in dist/ makes it fail, so none of them pass vacuously. Worth knowing when reading earlier verification claims on this branch: #src/* resolves to ./dist/*.js unless NODE_OPTIONS=--conditions=typestrip, so a unit run without a preceding npm run build exercises the previous build, not the working tree.

  • The boot-window deletion regression was checked the same way: with the settle reverted in the built #handleUnlink, settles ready when the file is deleted before any read had a config to give times out at 10 s rather than passing, so it pins the settle and not the ladder. It waits on the ladder's own read count rather than a wall clock, so a loaded runner moves the delete with it; three consecutive runs took 924 ms each.

  • npm run test:integration -- integrationTests/apiTests/configuration.test.mjs — 25 passed on an earlier head of this branch; the config surface has not changed since. The full integration gate is CI's, and the Windows shard is the only end-to-end oracle for the platform this targets.

  • The deadline test fails on origin/main as expected: the attempt-count loop it replaced takes ~3.63 s, past the test's upper bound.

  • Linux unit runs do not exercise Windows sharing semantics. The retry cases fabricate a denied read with a mode-000 file, which is inert on Windows and ignored by root, so those cases skip on exactly the platform the fix targets. The Windows integration job — which reproduced the original EPERM exhaustion on an earlier head — is the only end-to-end oracle here.

  • Rebase re-verification (this round, onto origin/main at 9a3c75013): npm run build clean; npm run test:unit:main — 5303 passing, 196 pending, 2 failing, same two environmental failures as above (configValidator domain-socket path length under this run's long worktree path, gitCredentials git-env-var leakage from this session's harness) and neither file touched by this diff; targeted config/component/logging suites green. The two conflicts from the rebase (OptionsWatcher.ts, RootConfigWatcher.ts — see item 23) were resolved by hand and covered by the existing watcher suites, not by a new test.

  • Two of this PR's own CI checks are red, and both predate this branch — they already fail identically on origin/main's own tip. Format Check fails on unitTests/resources/query-array-scoping.test.js (from Pin element-scoping semantics of queries over array-valued properties #2437, untouched by this diff; npx prettier --check reproduces it against origin/main directly). Unit Test (Windows, Node.js v24) fails 7 cases in unitTests/utility/watcherFallback.test.js's "lost native watch guard" suite — the harness subprocess these tests spawn crashes with a native libuv assertion (Assertion failed: !_wcsnicmp(filename, dir, dirlen), file src\win\fs-event.c, line 72, exit 3221226505) instead of exercising the guard. git diff shows this file and its test are byte-identical between this branch and origin/main, and origin/main's own CI has been red on this same job since e966f2787 (the commit that merged Stop a deleted watched path from raising an uncaught EPERM (Windows) #2364) — run 33591393232 and the current tip's run 33592149944 both show it. Neither is this PR's regression to fix.

One CI datum worth a second look, because a Linux run cannot produce it. An earlier CI pass on this branch failed Integration Tests 2/6 (Windows)describe-metadata-upgrade.test.ts, whose restart_service probe never became ready inside 120 s. It passed on the re-run and every other shard was green, so it is a flake by the usual test, but the mechanism is close enough to this change to name: the Harper log for the failed run carries JavaScript execution has taken too long and is not allowing proper event queue cycling on main/0 covering 17.5 s, starting the moment Restarting http_workers was logged. The same test passes on main (255 s vs the 383 s timeout here). 17.5 s is far past any single budget this branch sets (500 ms of read blocking, 3.63 s of rename retry), so if it is this change it would have to be a burst of config events each taking a fresh blocking window on the main thread rather than one long wait — which is point 10's shared-deadline behaviour under a rename storm. One green re-run does not settle that; the next Windows run that stalls should be read with this in mind.

One reviewer nit is partly declined. The in-source rationale for RENAME_RETRY_BUDGET_MS reads as narration against Harper's zero-new-comment default, but an earlier review thread on this PR asked for exactly that ("neither budget constant is explained in source any more"), so it stays and the mechanics live in DESIGN.md. The // Test-only: markers on the _*ForTests seams stay for the same reason — they were dropped once on this branch and restored on review — and each says what the seam is for (telling a ladder rung from a chokidar event; reading now rather than at whatever granularity an event arrives), which the accessor name does not. This round's pre-push review raised the same nit again on the rebase; the two accessors that added nothing beyond their own name (_usingPollingForTests, and half of _openCountForTests's comment) were trimmed in both watchers, and the ones defended above were left as they were.

Refs #2339

Complexity: complicated

Review-Coverage: authored=codex; ran=gemini,claude; blocked=domain(timeout); declined=cursor-grok,cursor-composer; rounds=8 @ 131a0c6

Human-Review-Need: 4 @ 131a0c6

@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 increases the maximum retry attempts for renaming files (RENAME_RETRY_MAX_ATTEMPTS) from 12 to 25 in config/configUtils.ts to extend the retry budget to approximately 10 seconds. The corresponding unit test in unitTests/config/configUtils.test.js has been updated to expect 26 total attempts instead of 13. There are no review comments, and I have no additional feedback to provide.

@claude

claude Bot commented Aug 17, 2026

Copy link
Copy Markdown
Contributor

Reviewed; no blockers found.

@cb1kenobi

Copy link
Copy Markdown
Member

Reviewed ca9c7d12 — no issues found. This PR looks good, nice job!


Generated by Barber AI

Comment thread config/configUtils.ts
Comment thread config/configUtils.ts Outdated
Comment thread unitTests/config/configUtils.test.js Outdated
Comment thread components/OptionsWatcher.ts Outdated
Comment thread config/configUtils.ts Outdated
Comment thread config/readConfigFileSync.ts
Comment thread components/OptionsWatcher.ts Outdated
kriszyp added a commit that referenced this pull request Aug 25, 2026
The Windows rename budget goes back to the 3,630ms window it had before this
branch. The 10-second budget was never the fix: the failing worker was blocked
against a descriptor its own watcher held, so no budget could have been long
enough, and the extra 6.4 seconds was pure event-loop stall on a path
`set_configuration` reaches from a live request thread.

- `readConfigFileSync` retries only on Windows, where these codes mean a writer
  is swapping the file in, and shares one deadline per path across every caller
  on the thread so a 10+ watcher burst costs one budget rather than ten.
- `RootConfigWatcher` retries a read that still loses off the watcher event
  (where it holds no descriptor) instead of sitting on a stale config until the
  next edit, and logs parse and listener failures it previously swallowed.
- `OptionsWatcher`'s async branch now separates read rejections from apply and
  listener throws, so a listener's ENOENT can no longer be read as "the config
  file is gone" and tear the scope down.
- `EBUSY` is retried on Windows only; on POSIX it is a real condition.

Refs #2191

Co-Authored-By: Claude Opus <noreply@anthropic.com>
kriszyp added a commit that referenced this pull request Aug 25, 2026
…ladder

Independent review found `OptionsWatcher` short of the recovery
`RootConfigWatcher` had just gained: a lock outliving the read's 500ms budget
emits no new watcher event when it clears, so the root watcher recovered
through its ladder while every scope on the same thread held a stale config
until the next write — two divergent views of one file.

Both now share `ConfigReadRetry`, whose budget is wall clock rather than a
count of attempts. Watcher callbacks and timer callbacks enter through the same
method, so a rename burst delivering several chokidar events in milliseconds
used to spend the whole ladder before the writer had let go.

- `atomicWriteFile` retries `EPERM`/`EACCES` only on Windows, matching the
  classifier added beside it for reads. A POSIX rename over an open file
  succeeds, so those codes are permanent there and the retry only parked the
  calling worker's event loop for 3.6 seconds before failing anyway.
- A config parse failure no longer logs the yaml error message: `prettyErrors`
  frames the offending source lines into it, and config files hold credentials.
  Read and listener failures log through `errorForLog` so the stack survives.
- The new `readConfigFileSync` and watcher-retry tests deny reads with a real
  mode-000 path instead of stubbing `node:fs`, per AGENTS.md, which also makes
  them independent of how `#src` resolves.

Refs #2191

Co-Authored-By: Claude Opus <noreply@anthropic.com>
kriszyp added a commit that referenced this pull request Aug 25, 2026
The blocking budget and the timer ladder composed instead of layering: the
per-path deadline retires one budget after it expires, so a rung landing past
that opened a *fresh* 500ms window. One Windows lock outliving a budget stalled
a worker four separate times over ~4s — quadruple what DESIGN.md described, on
a thread serving HTTP, MQTT and replication.

A rung now passes `waitForLock: false` and takes a single attempt: the ladder
already owns the retry, so the blocking budget is spent once, on the first
watcher-driven read, where it still catches a sub-millisecond rename without a
timer round-trip.

- `ConfigReadRetry` derives its backoff from elapsed time rather than from how
  many times it was armed. One atomic rename can deliver add + change + change,
  and each re-armed the ladder further out, so a writer releasing at 150ms could
  leave every scope stale for another ~1.45s with the file readable throughout.
- `atomicWriteFile` goes back to classifying by error code alone. Gating it on
  win32 dropped the retry for a Linux worker whose rootPath is on WSL drvfs, a
  CIFS/SMB mount, or a Docker Desktop bind mount — all report `linux` and all
  return these codes transiently. The reader stays gated because a
  misclassified read falls through to the ladder; a rename has nothing to fall
  through to.
- `#handleUnlink` resets the ladder: the deletion settles what a pending read
  was retrying, and a rung landing after it emitted a second `remove`.
- `OptionsWatcher.#read` returns early once closed, so a chokidar callback
  queued before `close()` can no longer emit into an emitter whose listeners
  have been removed.
- The ladder gets direct unit tests; the watcher case that could not distinguish
  a retry from a watcher event was replaced with what it can actually prove.

Refs #2191

Co-Authored-By: Claude Opus <noreply@anthropic.com>
kriszyp added a commit that referenced this pull request Aug 25, 2026
…after close()

Independent review found the two watchers disagreeing about hazards the diff
had already settled on one side.

- `yaml.parse` errors carry the offending source lines in `message` (yaml's
  `prettyErrors` is on by default), and the root config holds credentials.
  `RootConfigWatcher` was taught to log only the code and position; the same
  file read by `OptionsWatcher` emitted the raw error, which `Scope` logs.
  Both now parse through `parseConfigFile`, so neither can frame a credential
  into a log line.
- `RootConfigWatcher.#read` had no `#closed` guard on entry, only in its catch.
  A chokidar callback queued before `close()` could block the shutting-down
  worker in the read budget and then repopulate the config of a closed watcher.
- Comments that narrated the mechanics went back to a pointer at the DESIGN.md
  section that carries the reasoning, per the zero-new-comment default.

Refs #2191

Co-Authored-By: Claude Opus <noreply@anthropic.com>
kriszyp added a commit that referenced this pull request Aug 25, 2026
Independent review caught a cycle this branch introduced: `harper_logger`
imports `RootConfigWatcher` at its own bottom to break their dependency, so
building a tagged logger at module scope runs `loggerWithTag()` before
`mainLogger` is initialized — a TDZ `ReferenceError` on the native
type-stripped path, which compiled CommonJS masks. The logger is now built on
first use, the same shape `config/harperConfigEnvVars.ts` uses for this cycle.

The `OptionsWatcher` recovery case asserted only that no error was emitted, so
it stayed green if the watcher never re-read the file at all. It now writes new
contents and locks them in one synchronous block — the queued watcher event is
already denied when it runs — and asserts the options come back current. What
it cannot assert is *which* path delivered them: chokidar reports the unlocking
chmod as a change of its own, so the ladder that covers the no-event case is
proven in `configReadRetry.test.js` instead. The sibling root-watcher case
carried the same claim in a comment; it now says what it proves.

Refs #2191

Co-Authored-By: Claude Opus <noreply@anthropic.com>
kriszyp added a commit that referenced this pull request Aug 25, 2026
A terminal read or parse failure emitted only `error`, which settles nothing
that matters at boot: `Scope` logs it and returns, and componentLoader waits on
`Scope.ready` with no timeout. On the platform this branch targets — a Windows
worker whose startup config write collides with its own watcher's first read —
the read burns the blocking budget, spends the ladder, and that plugin's
`handleApplication` is never called. The scope now falls back to the defaults
and emits `ready` before surfacing the error, exactly as the ENOENT branch
beside it already does, and for the reason recorded there.

- `RootConfigWatcher` no longer claims to be "continuing with the previously
  loaded configuration" when the read that failed was the first one.
- `OptionsWatcher` counts read attempts, so a test can tell a ladder rung from a
  chokidar event. The new case locks the file, touches nothing else, and asserts
  the ladder re-reads on its own — the coverage two review rounds asked for, and
  it fails when the rung's callback is stubbed out.
- The test that releases a lock from another process handles its `spawn` failing
  rather than turning a skippable case into an unhandled `error` event.

Refs #2191

Co-Authored-By: Claude Opus <noreply@anthropic.com>
kriszyp added a commit that referenced this pull request Aug 25, 2026
`ConfigParseError` was replacing every failure's message, including one thrown
by the parser itself rather than by the document. Only yaml's own parse errors
frame the source lines — and so the credentials — into `message`, so anything
without them now propagates unchanged, where the message is the whole of the
debugging context.

The ladder-wiring test waits for the read count to advance rather than sleeping
a fixed 700ms, so event-loop delay cannot fail a correct implementation.

Refs #2191

Co-Authored-By: Claude Opus <noreply@anthropic.com>
Comment thread config/RootConfigWatcher.ts Outdated
Comment thread config/configUtils.ts
@kriszyp kriszyp changed the title fix(config): extend the Windows atomic-write rename-retry budget fix(config): read root config synchronously and bound rename retries by wall clock Aug 25, 2026
kriszyp added a commit that referenced this pull request Aug 26, 2026
The synchronous root-config read observes two states the async read never
did, and both end with the thread holding a stale config forever.

`ready` used to be emitted from inside chokidar's initial `add` dispatch,
before the native watch is armed; on darwin a write in that window is lost
outright (review measurement: 0ms lost, 5ms delivered). Gate `ready` on
chokidar's own `ready` plus a darwin-only grace, and stage the first config
rather than emitting a `change` ahead of it.

A non-atomic writer truncates before it writes, and the synchronous read is
fast enough to land in that window. chokidar throttles change events per path
for 50ms and drops the throttled ones, so the event carrying the content is
swallowed as a duplicate of the truncate's and a discarded empty read is the
last read that config gets. Route an empty read through `ConfigReadRetry`,
the same ladder a lock takes, in both watchers.

On Linux this was already reproducible: `should instantiate and watch the
root Harper config file` failed 4 of 6 runs at this branch's head and 0 of 6
at its merge base; it is green 8 of 8 with these fixes.

Refs #2191

Co-Authored-By: Claude Opus <noreply@anthropic.com>
kriszyp added a commit that referenced this pull request Aug 26, 2026
Pre-push review, round 1.

A write that lands while the watch is still unarmed produces no event, so
emitting the pre-arming staged config left `ready` carrying a value nothing
would ever correct. Re-read when the gate opens; a failed or empty re-read
falls back to what was staged.

The empty-read guard moves into `OptionsWatcher.#applyContents` so it covers
the asynchronous read too. That path is much less likely to land in a
truncate window, but the consequence there is a spurious `remove` that tears
the scope down rather than a stale value.

Refs #2191

Co-Authored-By: Claude Opus <noreply@anthropic.com>
kriszyp added a commit that referenced this pull request Aug 26, 2026
Gating `emit('ready')` on `#config` made a truthy config the barrier's condition,
so a file that parses to nothing — comments only, `---`, an empty document —
never settled it. `harper_logger.start()` awaits that promise with no timeout, so
the worker would hang at boot instead of coming up on the logging defaults. Track
that a read completed (`#configRead`) rather than that it produced a value; it
also stops a null config re-emitting `ready` in place of `change` on every
subsequent read.

`updateLogSettings()` takes the other half: the barrier can now settle with a
null config, so its consumer has to land on the defaults rather than throw out of
the boot path.

Refs #2191

Co-Authored-By: Claude Opus <noreply@anthropic.com>
kriszyp added a commit that referenced this pull request Aug 26, 2026
`RootConfigWatcher.ready` and `Scope.ready` are awaited with no timeout, so a
read that ends without a config had to settle them or the worker hangs at boot
instead of failing. Three outcomes did not: a read the ladder could not complete,
a file still empty when the ladder was spent, and a file that would not parse.
Each now boots on defaults and logs what failed, matching what `OptionsWatcher`
already does on its ENOENT and read-failure paths — the two watchers must not
disagree about that policy. A file that becomes readable later still arrives, as
a `change`.

`OptionsWatcher` also stopped falling through a spent empty-read ladder into
`parseConfigFile('')`, which read an empty file as a removed scope.

Also from the pre-push round:
- A darwin arm grace still counting down is cancelled when the watcher falls back
  to polling, so `ready` cannot mean "watching" on a generation that failed.
- The async read path guards its completion handlers on `#closed`: close() cancels
  the retry state and drops the listeners while a read can still be in flight.
- The two new `remove` assertions use a plain listener rather than a sinon spy
  (AGENTS.md forbids new sinon uses).

Refs #2191

Co-Authored-By: Claude Opus <noreply@anthropic.com>
kriszyp added a commit that referenced this pull request Aug 26, 2026
…se the arming gate

The pre-push round's domain leg found that `#resetConfig()` aliased the
module-level `DEFAULT_CONFIG` rather than cloning it. This branch routes three
more paths into that reset, and `#merge` writes an applied config into
`#scopedConfig` in place — so a scope that started on the defaults and was then
configured wrote its own values into the object every later reset hands out, for
the life of the thread. The new test fails without the clone.

The arming gate had the same shape of hole the read paths just closed: chokidar
emits `ready` with no `add` when there is no file to report, so `#markArmed`
skipped its re-read and `ready` stayed pending — the absent-config-file boot hang.
Arming now always re-reads (ENOENT takes the ladder and settles on defaults), and
defers the emit when that read armed a retry so `ready` carries the newer config
rather than the pre-arm one. `close()` settles the barrier too.

`{}` is not "the logging defaults": `updateLogger` reads an absent `rotation` as
rotation off and an absent `console` as console off, so applying a config-less
read would have silently disabled logging on the very boot that could not read
its config. `updateLogSettings` now keeps what `initLogSettings()` established
until a real config arrives.

Also: `OptionsWatcher` tracks whether `ready` has gone out instead of inferring it
from config truthiness — a scope absent from a config that read fine leaves
`#rootConfig` set with no `ready` behind it — and its async `.catch` guards on
`#closed` like both `.then` arms already do.

Refs #2191

Co-Authored-By: Claude Opus <noreply@anthropic.com>
kriszyp added a commit that referenced this pull request Aug 26, 2026
The pre-push round's graded leg found the logger fallback ineffective: the
`{}` `RootConfigWatcher` staged for a read that carried no config is an object,
so `updateLogSettings()`'s guard let it through — and `updateLogger` reads an
absent `rotation` as rotation off and an absent `console` as console off, which
silently disabled logging on the very boot that could not read its config. An
empty object is a configuration; "no configuration" has to be spelled as such,
so `#stageBootFallback` settles the barrier carrying nothing and leaves a
previously loaded config in place.

Also from that round: the synchronous read path's outer catch guards on
`#closed` like the asynchronous one already does — a listener of what
`#applyContents` emitted can close the watcher, after which `emit('error')` has
no listener left to reach.

Declined from the same round: that a scope arriving after an unconfigured boot
should be a `change` rather than a second `ready`. `ready` is not
once-per-watcher here — it is how this watcher says the scope has config again,
`Scope` consumes every one with `.on`, and the `remove` → recreated-file path
has emitted it that way all along (`OptionsWatcher.test.js` asserts it). The two
call sites that make that transition now share one `#applyScopedConfig`, and a
test pins the post-fallback arrival.

Refs #2191

Co-Authored-By: Claude Opus <noreply@anthropic.com>
kriszyp added a commit that referenced this pull request Aug 26, 2026
…d stop ENOENT taking the ladder

Round 8 of the pre-push review took apart the previous commit's claim that a
repeated `ready` is consumed. It is not: `Scope.#handleOptionsWatcherReady` only
re-emits, `Scope.ready` is a settled `once`, and what recovers the recreated-file
path is the `remove` listener calling `requestRestart()` — which never fires on a
scope that booted with nothing applied. So a worker that read the root config in
a truncate window ran `handleApplication` on the defaults and kept serving them
after the operator's config landed, disagreeing with every worker that read the
file cleanly. A second `ready` now requests a restart, on the same terms as the
`remove` listener: a plugin with its own `ready` handler owns the response.

`RootConfigWatcher` also sent ENOENT into the retry ladder, which
`readConfigFileSync` deliberately does not retry and `OptionsWatcher` settles at
once as the install window. Every boot with no config file — an env-var-only
deployment, an empty mounted rootPath — therefore spent the whole 3.1s budget
inside `harper_logger.start()`. Two watchers, one policy: ENOENT settles
immediately. The config suite drops from 21s to 18s on that alone.

Three narrower ones from the same round:
- `close()` settles the barrier through `#emitReady` rather than duplicating the
  emit bare, so a throwing `ready` listener can no longer skip the teardown under
  it and leave the watcher and its arm timer running.
- A non-exhaustion chokidar scan error is a terminal outcome for arming too:
  chokidar may never reach its own `ready` after one, and nothing else would
  settle the barrier.
- `OptionsWatcher.#surfaceFailure` wraps its `error` emit. On the async path a
  throwing listener had nothing left awaiting it, so it reached Node as an
  unhandled rejection and took the process down over a failed config read.

Refs #2191

Co-Authored-By: Claude Opus <noreply@anthropic.com>
kriszyp added a commit that referenced this pull request Aug 26, 2026
…stop a falsy scope restarting

Round 9's adjudicated major is a regression the previous commit introduced.
`once(this, 'ready')` attaches an `error` listener as well as a `ready` one and
drops both when the barrier settles, so settling before reporting — which the
scan-error path now does — is exactly what guarantees the `error` emit has no
listener left. `harper_logger` never registers one. An `error` with no listener
throws synchronously out of chokidar's dispatch, so a worker that had just
decided to survive an EIO/EACCES scan error would instead die of it. It now
reports through the logger when nothing is listening, and `handleError` guards
`#closed` like the read paths do.

Two more from the same round:
- `parseConfigFile` parses with `logLevel: 'error'`. yaml routes warnings
  through `process.emitWarning` rather than a throw, so a framed warning went
  around the scrub entirely and put config source — credentials included — on
  stderr. The `YAMLWarning` branch in `isYamlParseError` was dead code for the
  same reason.
- `#applyScopedConfig` keyed the unconfigured → configured transition on the
  scope value's truthiness, but `myPlugin:` with nothing under it is a
  configured scope whose value is null. Every ladder rung and rename-burst
  re-read of one therefore looked like the transition, and each now costs a
  restart request. A re-read of an unchanged falsy value is not a transition.

Refs #2191

Co-Authored-By: Claude Opus <noreply@anthropic.com>
kriszyp added a commit that referenced this pull request Aug 26, 2026
Round 10 found the previous commit reused `#armed` for two things: "chokidar's
scan finished" and "the barrier may settle". Setting it on a scan error made
`#handleArmed` return early when chokidar went on to arm normally, so the
re-read that exists solely to recover a write that landed in the unarmed window
never ran — the exact loss the gate was added for. A terminal outcome now opens
the barrier's own gate and leaves `#armed` to mean what it says. The test asserts
the re-read, not just that `ready` settled.

Also tightens the yaml-warning case to yaml's own warnings, so an unrelated
deprecation emitted in the same window cannot fail it with a misleading message.

Refs #2191

Co-Authored-By: Claude Opus <noreply@anthropic.com>
kriszyp added a commit that referenced this pull request Aug 28, 2026
…stop a falsy scope restarting

Round 9's adjudicated major is a regression the previous commit introduced.
`once(this, 'ready')` attaches an `error` listener as well as a `ready` one and
drops both when the barrier settles, so settling before reporting — which the
scan-error path now does — is exactly what guarantees the `error` emit has no
listener left. `harper_logger` never registers one. An `error` with no listener
throws synchronously out of chokidar's dispatch, so a worker that had just
decided to survive an EIO/EACCES scan error would instead die of it. It now
reports through the logger when nothing is listening, and `handleError` guards
`#closed` like the read paths do.

Two more from the same round:
- `parseConfigFile` parses with `logLevel: 'error'`. yaml routes warnings
  through `process.emitWarning` rather than a throw, so a framed warning went
  around the scrub entirely and put config source — credentials included — on
  stderr. The `YAMLWarning` branch in `isYamlParseError` was dead code for the
  same reason.
- `#applyScopedConfig` keyed the unconfigured → configured transition on the
  scope value's truthiness, but `myPlugin:` with nothing under it is a
  configured scope whose value is null. Every ladder rung and rename-burst
  re-read of one therefore looked like the transition, and each now costs a
  restart request. A re-read of an unchanged falsy value is not a transition.

Refs #2191

Co-Authored-By: Claude Opus <noreply@anthropic.com>
kriszyp added a commit that referenced this pull request Aug 28, 2026
Round 10 found the previous commit reused `#armed` for two things: "chokidar's
scan finished" and "the barrier may settle". Setting it on a scan error made
`#handleArmed` return early when chokidar went on to arm normally, so the
re-read that exists solely to recover a write that landed in the unarmed window
never ran — the exact loss the gate was added for. A terminal outcome now opens
the barrier's own gate and leaves `#armed` to mean what it says. The test asserts
the re-read, not just that `ready` settled.

Also tightens the yaml-warning case to yaml's own warnings, so an unrelated
deprecation emitted in the same window cannot fail it with a misleading message.

Refs #2191

Co-Authored-By: Claude Opus <noreply@anthropic.com>
Comment thread components/OptionsWatcher.ts
Comment thread utility/logging/harper_logger.ts
Comment thread unitTests/components/OptionsWatcher.test.js Outdated
Comment thread config/configUtils.ts Outdated
@kriszyp
kriszyp force-pushed the kris/win-rename-retry-budget branch from fc15ab2 to b161039 Compare August 31, 2026 21:08
@kriszyp

kriszyp commented Aug 31, 2026

Copy link
Copy Markdown
Member Author

Rebased onto fb762a365 (force-push, fc15ab216b16103994). The branch now sits on a main that had rewritten the same two functions this PR does, so the conflict was resolved by hand rather than replayed.

What main landed in the meantime: #2245 (boot-path config persistence for #847) split atomicWriteFile into atomicWriteFile + renameWithRetry and added isStorageExhausted / persistConfigDuringBoot, a skipIfUnchanged write skip, an isMainThread persist guard, and the two-artifact env-config commit protocol in harperConfigEnvVars.ts.

How it composes with this PR: #2245's structure is kept whole; this PR's retry work is grafted into renameWithRetry's loop alone — the RENAME_RETRY_BUDGET_MS wall-clock deadline (attempt count demoted to a degenerate-option guard), isRetryableRenameError with EBUSY, and the option validation, lifted into validateRenameRetryOptions so it also covers renameWithRetry's one direct caller (the state-sidecar promotion) and still runs before the temp file is written. Nothing of #2245's exhaustion handling, write-skip, isMainThread guard or state commit is dropped.

DESIGN.md merged as intended: this branch's "Root config watchers must read synchronously" replaces the section it supersedes, and main's "Boot-path config persistence" and "Query-plan range estimation" sections are untouched. components/Scope.ts and utility/logging/harper_logger.ts auto-merged.

Because 17 of the branch's 47 commits touched config/configUtils.ts, the branch is re-expressed as two commits instead of replaying them. The non-configUtils.ts half of the PR diff is byte-identical to the pre-rebase diff (verified by diffing the two PR diffs), so nothing else was re-derived by hand.

Verified at this head: npm run build clean; test:unit:config 349 passing; test:unit:resources 1872 passing; test:unit:main 5235 passing with the one known environmental configValidator domain-socket-path failure (long worktree path, untouched by this diff); test:integration -- integrationTests/apiTests/configuration.test.mjs 35/35.

One finding from the pre-push review that is worth your call, not mine (it predates the rebase and I did not change behaviour to fix it): OptionsWatcher.#handleUnlink calls #readRetry.reset() and then takes the remove branch without settling the boot barrier. If the first read is empty/unparseable the ladder is armed with ready unemitted, and a deletion landing inside that window cancels the only thing that would have emitted ready#resetConfig() leaves #rootConfig truthy, so the follow-up ENOENT read emits remove again and never ready, and sequentiallyHandleApplication's scope.ready.then(...) never runs. #handleReadError's ENOENT branch already guards exactly this ("remove … nothing consumes at boot → ready would hang forever"); #handleUnlink is the surface that did not get the same guard. The symmetric fix is if (this.#readyEmitted) this.#emitRemove(); else this.#emitReady(this.#scopedConfig); after #resetConfig() — but which of ready/remove a boot-window deletion should mean is the same contract question your decision ledger already has open, so I left it to you.

Also worth knowing: EBUSY being retryable on every platform means a Linux single-file bind mount (-v ./harper-config.yaml:/…/harper-config.yaml, a documented Docker pattern) now blocks the calling worker for the full 3,630 ms before throwing the error it used to throw immediately — on set_configuration that is a live request thread.

Rebase run by Claude Sonnet 5 via dev-agent dispatch. Pre-push review at this head: gemini + harper-domain, independent=true.

Comment thread config/RootConfigWatcher.ts
Comment thread utility/logging/harper_logger.ts
Comment thread components/OptionsWatcher.ts

@cb1kenobi cb1kenobi left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Barbarian reviewed a5d5b7f and found no blocking issues. No new blocking findings were confirmed at this commit. The latest changes correctly distinguish fallback defaults from source configuration and trigger recovery.


Generated by Barber AI

@kriszyp

kriszyp commented Sep 1, 2026

Copy link
Copy Markdown
Member Author

CI's unit job was hanging at a5d5b7f36, on all three Node versions — not flaking, wedging. The last test to report was green, so the job died on the 10-minute step timeout with nothing red in it. The wedge is should handle default config resolution: it waits for a change where a scope that booted on its truthy DEFAULT_CONFIG value now takes the ready its first source config is, which is the contract b0c788592 introduced for exactly that case. .mocharc.json sets timeout: 0, so an event that never arrives hangs the run rather than failing the one case. Reproduced locally 3/3 at that head, green 3/3 here. The test now pins the arrival as a ready carrying the written value, with no accompanying change.

And the #handleUnlink gap raised above is closed rather than left open. It reads as a contract question, but remove before the first ready is not a coherent report in either direction: nothing consumes it — componentLoader is still inside await scope.ready — and Scope answers it with requestRestart(), so the same deletion that cancelled the ladder (via #readRetry.reset()) also asks for a restart of a component that never started, with nothing left to settle the barrier. A boot-window deletion now settles on the defaults and reports remove only after ready, which is the ruling the ENOENT branch beside it already applies and the one every other terminal outcome on this branch takes. Reverting that settle in the built #handleUnlink makes the new regression time out at 10 s, so it pins the settle and not the ladder. If you want the other ruling it is a one-line flip plus a second settle path — item 19 in the description says what it costs as written.

Two follow-ups from the pre-push review, both in the description rather than the diff:

  • A deletion the asynchronous read observes first still reports twice (ready on defaults, then remove from the delayed unlink). That ordering predates this branch and is untouched by it; the guard added here covers the other order, where the unlink wins, by having it claim the current read sequence. Making both orders idempotent means gating the remove sites on #scopeConfigured rather than #rootConfig's truthiness, which changes what a deletion means for a scope that was never configured — item 20.
  • The EBUSY-on-Linux bind-mount cost you flagged was raised independently by the reviewer in both rounds. Recorded as item 22, same one-line platform-gating question as item 6.

npm run test:unit:main at this head: 5243 passing, 195 pending, 2 failing — configValidator's domain-socket path-length warning (long worktree path) and gitCredentials seeing this harness's GIT_CONFIG_GLOBAL/GIT_EDITOR; both pass with the environment cleaned and neither file is touched by this branch.

Pre-push review at this head: codex + harper-domain, independent=true; gemini blocked on auth and both Cursor legs pruned by the branch round cap. Run by Claude Opus via dev-agent dispatch.

@cb1kenobi cb1kenobi left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Barbarian reviewed 4929b4f and found no blocking issues. No new blocking findings were confirmed at this commit. The boot-window deletion handling settles readiness and suppresses superseded in-flight reads.


Generated by Barber AI

@kriszyp

kriszyp commented Sep 1, 2026

Copy link
Copy Markdown
Member Author

CI at 4929b4fa: everything green except Unit Test (Node.js v22), which is now over its job timeout rather than hung. Worth separating from the wedge it replaced.

main (33472193263) this head
Node 22 7m47s pass 10m17s / 10m18s cut (re-run twice)
Node 24 7m12s pass 8m39s pass
Node 26 7m01s pass 8m28s pass

Both v22 attempts were cancelled at .github/workflows/unit-test.yml's timeout-minutes: 10, at a different point each time (bin/exitListeners, then the lmdb pass) and with tests still completing 0.3 s before the cancel — so it is duration, not a hang. v22 is the slowest of the three because it alone also runs npm run test:types.

What the branch adds is ≈45 s of deliberate wall clock: about fourteen unit tests let ConfigReadRetry's 3,100 ms budget expire in real time, plus configUtils' 3,631 ms shipped-default case and a 6,202 ms two-budget case. On main the job already sits at 7–8 minutes, so there was ~2 minutes of headroom for the whole repo and this branch spends most of it.

Two ways out, and I did neither because both are yours:

  1. Raise the cap in unit-test.yml — one line, and it also buys back headroom main no longer has. The cost is that a genuinely hung run (.mocharc.json sets timeout: 0, so hangs are the failure mode we just saw twice) burns 15 minutes instead of 10 before reporting.
  2. Make the read budget injectable so those tests stop spending it in real time. That is another test seam of the kind this PR's review has already pushed back on, and it touches ~14 cases.

Recorded in the description's ## Verification as well.

Run by Claude Opus via dev-agent dispatch.

@cb1kenobi cb1kenobi left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Barbarian reviewed 4929b4f and found no blocking issues. No new blocking findings were confirmed at this commit. Existing discussion already covers the known tradeoffs and CI-duration issue.


Generated by Barber AI

@kriszyp

kriszyp commented Sep 1, 2026

Copy link
Copy Markdown
Member Author

All 43 checks are green at 4929b4fa. The one red check was Unit Test (Node.js v22), and it was not this branch: it failed in the lmdb pass of unitTests/apiTests/mqtt-test.mjs on subscribe to retained record with patch operations, asserting messages[1].count was 2 where 3 was expected — the second message was a duplicate of the first, which is Flaky unit test: MQTT 'subscribe to retained record with patch operations' — duplicate retained/live delivery fails as uncaught assertion (~10% under contention) exactly. The same case passed in the rocksdb pass of the same job. Re-run: green.

The v22 duration note needs one correction, and it removes an option. Re-measured from the green run's step timings:

job total Run tests test:types install
v22 9m05s 502 s 1 s 19 s
v24 8m39s 469 s 25 s
v26 8m28s 454 s 25 s

v22 is not slower because it alone runs npm run test:types — that step costs one second. It is 33 s slower than v24 inside Run tests on the same suite, which is Node 22's own runtime, so moving or dropping test:types buys nothing. The cap is job-level (timeout-minutes: 10 on the matrix job in .github/workflows/unit-test.yml) and 92 % of the job is Run tests, so raising it costs the extra minutes only on a genuine hang. Headroom at this head is 55 s, and this same commit has now produced 8m11s, 9m05s and two 10m+ cuts — that is inside runner variance, not a margin. The choice between raising the cap and making the read budget injectable is unchanged and still yours; ## Verification now carries these numbers.

No unresolved review threads remain — all 21 are resolved, and the three automated rounds at this head report no blocking findings.

Run by Claude Opus via dev-agent dispatch. No commits: nothing needed a code change.

@cb1kenobi cb1kenobi left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Barbarian reviewed 4929b4f and found no blocking issues. No new blocking findings were confirmed on changed lines. Existing discussion already covers the known tradeoffs and failure modes.


Generated by Barber AI

@kriszyp
kriszyp force-pushed the kris/win-rename-retry-budget branch from 4929b4f to a91e64f Compare September 2, 2026 06:33
kriszyp and others added 9 commits September 3, 2026 18:10
A count of attempts does not say how long `atomicWriteFile` may block, and the sleep it
retries with blocks the calling worker's event loop, which `set_configuration` reaches from
a live request thread. `renameWithRetry` now spends a wall-clock budget
(`RENAME_RETRY_BUDGET_MS`, the 3630ms window the 12-attempt schedule already spanned) with
the attempt count kept only as a guard against a zero-delay option set, and never sleeps
past the deadline.

`EBUSY` joins `EPERM`/`EACCES` as retryable, classified by code alone rather than by
`process.platform`: WSL drvfs, CIFS/SMB and Docker Desktop bind mounts report `linux` and
still return these codes transiently. Retry options are validated up front, before the temp
file is written, so an option set that could never rename leaves nothing behind.

Grafted onto the `atomicWriteFile`/`renameWithRetry` split from #2245 rather than the
single function this branch was written against: the boot-path storage-exhaustion
handling, the `skipIfUnchanged` write skip, and the env-config state commit protocol are
untouched, and the sidecar promotion in `harperConfigEnvVars.ts` picks up the same budget.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
An async read in a root-config watcher is unsatisfiable by construction on Windows: libuv
opens the descriptor on the threadpool but closes it from JS, which cannot run while the
same thread is blocked in `atomicWriteFile`'s rename retry. The worker deadlocks against
its own watcher and burns the whole budget before failing. Both watchers
(`RootConfigWatcher.handleChange`, `OptionsWatcher.#handleChange`) now read through
`readConfigFileSync`, which holds no descriptor across a yield, gating its own sharing-
violation retry to win32.

A synchronous read lands in windows an async read mostly skipped, so the outcomes that are
not a usable config are handled rather than adopted: an empty or non-object read is a
writer mid-write and goes to `ConfigReadRetry`, a bounded ladder driven by elapsed time and
holding no descriptor, because chokidar throttles and drops the event that would carry the
content. Parsing goes through `parseConfigFile`, whose errors are stripped of yaml's
`prettyErrors` source frame — the root config holds credentials and the frame would reach
the component and config logs. `watcherArming` re-reads at arming so a config written
between construction and the first event is not missed, and every terminal read outcome
settles the boot barrier, including outcomes carrying no config at all: logging keeps what
`initLogSettings` established rather than reading an absent `rotation` as rotation off.

`PartialReadRetry` in `utility/watcherFallback.ts` is replaced by `ConfigReadRetry`.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Co-Authored-By: GPT-5 Codex <noreply@openai.com>
Co-Authored-By: GPT-5 Codex <noreply@openai.com>
A scope that boots on a truthy DEFAULT_CONFIG value still has no source config. Use scopeConfigured as provenance before selecting merge behavior so a repaired config emits ready and Scope requests restart.

Co-Authored-By: GPT-5 Codex <noreply@openai.com>
Exercise the late source-config transition through Scope with a truthy default and prove it emits ready without change. Keep the shared-default clone assertion meaningful after the transition stopped merging.

Co-Authored-By: GPT-5 Codex <noreply@openai.com>
The unit job hung at this head, on all three Node versions, in
`should handle default config resolution`: that test asserts a `change`
for the config file arriving after a scope booted on its truthy default,
and `#applyScopedConfig` now reports that arrival as the unconfigured →
configured `ready` it is. `.mocharc.json` sets `timeout: 0`, so the
missing event wedged the whole run instead of failing one case. The test
now pins the arrival as a `ready` carrying the written value, and that no
`change` accompanies it.

`#handleUnlink` had the gap the last review round left open: it resets
the read ladder and emits `remove` unconditionally, so a deletion landing
while the first read was still riding the ladder cancels the only thing
that would have settled `ready` — and `Scope`, still inside
`await scope.ready`, answers the `remove` by requesting a restart of a
component that never booted. It now settles the barrier on the defaults
before `ready`, and reports `remove` only after it, which is the ruling
the ENOENT read path beside it already applies.

Co-Authored-By: Claude Opus <noreply@anthropic.com>
Pre-push review: cancelling the ladder in `#handleUnlink` covers the rung
not yet armed, but on the asynchronous read path a rung already in flight
still completes, and its ENOENT reports the same deletion a second time —
now after the settle, as a `remove` asking `Scope` to restart a scope
that has just booted on the defaults. The deletion claims the sequence
those reads were issued under, so they are outranked exactly as a newer
read outranks an older one.

The boot-window deletion test waits on the ladder's own read count rather
than a fixed 1.2s sleep, so it tracks a loaded runner instead of racing
it, and asserts the barrier is still unsettled before deleting.

Co-Authored-By: Claude Opus <noreply@anthropic.com>
Pre-push review flagged _usingPollingForTests/_openCountForTests/
_readCountForTests comments in OptionsWatcher.ts and RootConfigWatcher.ts
as pure narration; trimmed to keep only the non-obvious "why" (the
close()-during-fallback race, ladder-vs-watcher provenance) and dropped
the rest.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
@kriszyp
kriszyp force-pushed the kris/win-rename-retry-budget branch from a91e64f to 131a0c6 Compare September 4, 2026 00:23
// — after the settle below, as a `remove` that asks `Scope` to restart a scope that just
// booted on the defaults.
this.#readRetry.reset();
this.#appliedSequence = this.#readSequence;

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): claiming #readSequence here so an in-flight asynchronous read is outranked instead of reporting the deletion twice has no test exercising that branch. The one test this fix touches ('settles ready when the file is deleted before any read had a config to give') constructs the watcher with isRootConfig: true, which forces synchronous reads (#synchronousRead = true) and never reaches this async path.

The PR description (item 21) already discloses this ("The in-flight-read guard has no direct test") with a reasonable justification — reliably provoking the race needs UV_THREADPOOL_SIZE tuning to hold a readFile past chokidar's unlink delay. Given the guard is a genuine behavior change (not just refactoring) in an area this PR is otherwise very thorough about regression-testing, a targeted async-mode test (construct without isRootConfig, induce an in-flight read, delete before it resolves, assert the stale read doesn't clobber the post-deletion reset) still seems worth the two lines it would pin — flagging for a second look rather than blocking on it.

Comment thread config/configUtils.ts
// not answer whether this filesystem can replace an open file — WSL drvfs, CIFS/SMB and Docker
// Desktop bind mounts all report `linux` and return these codes transiently.
function isRetryableRenameError(code: string): boolean {
return code === 'EPERM' || code === 'EACCES' || code === 'EBUSY';

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): making EBUSY retryable unconditionally (previously only EPERM/EACCES were retried, and the read side still gates retries to win32) means a Linux single-file bind mount — a documented Docker pattern — now blocks the calling worker for the full RENAME_RETRY_BUDGET_MS (3,630ms) on every set_configuration call, where it used to fail fast, since rename(2) returns EBUSY permanently for that mount point rather than transiently.

This is explicitly disclosed in the PR description (items 6 and 22) as a deliberate, undecided trade-off — "the same one-line platform-gating question... left to you" — and a prior review thread on this line addressed the budget's magnitude but the platform-gating question itself was carried forward rather than resolved. Since it's a live-request-thread stall on a supported deployment shape rather than a hypothetical, surfacing it again here to make sure it gets the explicit human call the PR description is asking for before merge.

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.

2 participants