fix(config): read root config synchronously and bound rename retries by wall clock - #2191
fix(config): read root config synchronously and bound rename retries by wall clock#2191kriszyp wants to merge 9 commits into
Conversation
There was a problem hiding this comment.
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.
|
Reviewed; no blockers found. |
|
Reviewed — |
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>
…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>
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>
…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>
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>
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>
`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>
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>
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>
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>
`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>
…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>
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>
…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>
…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>
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>
…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>
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>
fc15ab2 to
b161039
Compare
|
Rebased onto What How it composes with this PR: #2245's structure is kept whole; this PR's retry work is grafted into
Because 17 of the branch's 47 commits touched Verified at this head: 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): Also worth knowing: Rebase run by Claude Sonnet 5 via dev-agent dispatch. Pre-push review at this head: gemini + harper-domain, independent=true. |
cb1kenobi
left a comment
There was a problem hiding this comment.
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
|
CI's unit job was hanging at And the Two follow-ups from the pre-push review, both in the description rather than the diff:
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
left a comment
There was a problem hiding this comment.
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
|
CI at
Both v22 attempts were cancelled at What the branch adds is ≈45 s of deliberate wall clock: about fourteen unit tests let Two ways out, and I did neither because both are yours:
Recorded in the description's Run by Claude Opus via dev-agent dispatch. |
|
All 43 checks are green at The v22 duration note needs one correction, and it removes an option. Re-measured from the green run's step timings:
v22 is not slower because it alone runs 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. |
4929b4f to
a91e64f
Compare
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>
a91e64f to
131a0c6
Compare
| // — 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; |
There was a problem hiding this comment.
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.
| // 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'; |
There was a problem hiding this comment.
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.
The Windows
EPERM: rename harper-config.yaml.tmpwas never a budget that was too short: the worker was blocked against a descriptor its own root-config watcher held. libuv opens afsPromises.readFiledescriptor on the threadpool but closes it from JS, which cannot run while the same thread sits inatomicWriteFile'sAtomics.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 pathset_configurationreaches 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
OptionsWatcheronly blocks when it is explicitly the root watcher, so an application config remains asynchronous even when the component calls itharper-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
Scopewithout settling its barrier, and CI found the last test still encoding the contract that changed under it.#handleUnlinkcancels 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 emittedready, andScope, still insideawait scope.ready, answered theremoveby requesting a restart of a component that never booted. A boot-window deletion now settles on the defaults and reportsremoveonly afterready, 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 sideshould handle default config resolutionstill waited for achangewhere a scope that booted on its truthy default now takes thereadyits arrival is; withtimeout: 0in.mocharc.jsonthat wedged the whole unit job on all three Node versions rather than failing the one case.mainshipped an independent fix for this same root cause while the branch was open — #2339, merged as99169ebca. 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 ofPartialReadRetry's attempt count, an arm gate over chokidar's unarmed window, credential-scrubbed parse errors, and a boot barrier every terminal read outcome settles — andPartialReadRetry/isPartialReadError/watcherFallback's additions go with it. Whatmainhad 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;atomicWriteFilereports the attempts and elapsed time behind a rename it could not complete; andconfigReadHandleLifetime.test.jskeeps 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:
readyused to be emitted from inside chokidar's initialadd; on darwin a write in that window is lost outright (0 ms lost, 5 ms delivered, as measured on this PR).readyis now gated on chokidar's ownreadyplus a darwin-only grace, and arming always re-reads rather than publishing what an earlier read staged.should instantiate and watch the root Harper config filefailed 4 of 6 runs at this branch's earlier head and 0 of 6 at its merge base.harper_logger.start()andScope.readyawait 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{}, becauseupdateLogSettingsreads 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.Scopenow answers a repeatreadythe same way it answersremove— 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
parseConfigFilewithlogLevel: 'error', so neither yaml's framed error nor itsprocess.emitWarningpath can put config source — credentials included — into a log line; and a chokidar callback queued beforeclose()is dropped on entry instead of reading a file a shutting-down worker no longer wants.DESIGN.mdcarries all of these invariants so neither watcher gets "modernized" back tofsPromises.readFile.For the human reviewer
Framing-Verdict: chosen-approach-sound
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 removesPartialReadRetry,isPartialReadErrorandwarnWatcherListenerErrorand returnsutility/watcherFallback.tsto 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 in13af84b8; that commit is the one to read if you want to check nothing was dropped in the trade.main'sPartialReadRetrycarried a module-levelpartialReadWarnedset keyed by file path, so exactly one watcher's give-up was ever logged forharper-config.yaml; the reconciliation drops that set with the rest of that machinery, and each of the 10+ per-workerOptionsWatchers 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 wheremainemitted one, and a shared gate would silence 9 of them. Reinstating one is a module-levelMap<path, boolean>cleared on the next successful read; the reviewer asked for it explicitly.readFileSync, plus up to 500 ms ofAtomics.waiton win32). The alternative is to make the writer stop blocking — an async rename retry, or movingset_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 andDESIGN.md.readyis not once-per-watcher, and a repeat now costs a restart.OptionsWatcheremits 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 — andScopeconverts a repeat intorequestRestart(), on the same terms as itsremovelistener (a plugin with its ownreadyhandler 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:#applyScopedConfigtracks 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 scopesDEFAULT_CONFIGnames do not read their own boot fallback as a block the file supplied.removereported the deletion ahead of theunlinkthat would confirm it, and on darwin ahead of chokidar finishing its own teardown — so a config recreated on the strength of that earlyremovelanded where itsaddwas not observed at all. It now goes back to the loop once and chokidar's ownunlinkcancels it, which is what fixed the reviewer's 3/3 macOS failure ofdoes not write an applied config into the shared defaults. It is still a deferral rather than an observable signal: a platform where chokidar never emitsunlinkfor a real deletion would see one extra read and noremove.process.platformdoes not answer whether this filesystem can replace an open file (WSL drvfs, CIFS/SMB, Docker Desktop bind mounts all reportlinux). The cost is that a genuine LinuxEACCESon 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, andOptionsWatcherhas always settled it at once as the install window.set_configurationstill reports success. Strictly better than main's silent swallow; a slow re-arm or a periodic reconciliation would close it and is purely additive.#settleUnconfiguredis a no-op on a watcher that has already emittedready— so an operator who truncatesharper-config.yamlat 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.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.readyalone 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, andArmGatetakes 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.OptionsWatchershares 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-configOptionsWatcherscomponentLoadercreates per worker (at the merge base they all read viafsPromises.readFile, whose threadpool round-trip deferred past it), so the gate is now shared rather than left toRootConfigWatcher. WhatOptionsWatchertakes is the re-read that recovers the otherwise-undeliverable write; itsreadystill goes out on the first read, so it means "the config has been read", not "armed". The ordering difference is safe becauseScopeattaches its listeners in its constructor, before any read — the recovered write arrives as a post-readychangerather than being lost. Holdingreadybehind 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 applicationconfig.yamlheld 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.OptionsWatchergains_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 unlockingchmodas a change of its own), but_refreshForTests()does expose a synchronous config re-read to any caller.harper_loggergains_applyLogSettingsForTests: both guards this branch added toupdateLogSettingswere unreachable from the suite, and the watcher that function builds resolves a process-wide path, so the only alternative was therewireAGENTS.md forbids. The split is behaviour-preserving — the watcher wiring stays inupdateLogSettings, the applying half moves down intoapplyLogSettings(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.atomicWriteFilenow throwsRangeErroron non-finite/negative retry options instead of clamping. A new throw on the config-write path, whose only non-default callers are tests.renameSyncandwriteFileSync(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-wayENOSPCwrite cannot be staged — and the default-budget case exists because every other case overridesretryBudgetMs, 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.#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 achange, 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.harper-config.yamlempty delaysharper_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.writeFileSynccreates the temp file at0666 & ~umaskandrenameSynccarries that inode's mode onto the destination, so an operator'schmod 600 harper-config.yamlis silently reverted to0644by the nextset_configuration— below blocker under the one-process-per-container threat model, but the file holdsauthenticationmaterial. The fix is astatSync+chmodSyncon the temp path before the rename, plus a decision about what to do when the destination does not exist yet. (b) Nofsyncon the temp descriptor or the parent directory, so on ext4 with delayed allocation a crash shortly afterset_configurationcan 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. Anfsyncon theset_configurationrequest thread is its own latency decision.readyon the defaults, not aremove(#handleUnlink). This is the ruling the last round left open.removebefore the firstreadyis not a coherent report in either direction: nothing consumes it —componentLoaderis insideawait scope.ready— andScopeanswers it withrequestRestart(), 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 meansremove, 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.readFilecan rejectENOENTbefore chokidar delivers itsunlink(held back ~100 ms by its own atomic-write window): the read settles bootreadyon the defaults, and theunlinkthen finds#readyEmittedand emitsremove, so one deletion producesready+ a restart request. That ordering predates this branch and is untouched by it — item 19 only changes what happens when theunlinkwins. Making it idempotent properly means gating bothremovesites on#scopeConfiguredcaptured before the reset, rather than on#rootConfig's truthiness, which is the same unification#applyContentsalready 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.#handleUnlinkclaims 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 areadFilepast chokidar's ~100 msunlinkdelay, which needs libuv's threadpool saturated and pins the test toUV_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.EBUSYis retryable on every platform, and on Linuxrename(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, soset_configurationon 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.mainalso 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.#2364addsguardedWatch()/claimLostNativeWatchError()toutility/watcherFallback.tsand wires both into#openWatcher()/handleError()onOptionsWatcherandRootConfigWatcher— the same two methods this branch already rewrote for the generation-tracked reopen and theConfigReadRetry/ArmGatemachinery. 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 underlyingchokidar.watch()call this branch already made, andclaimLostNativeWatchError(error)is now the first line of bothhandleErrormethods, ahead of this branch's own exhaustion/closed-watcher handling. Nothing from either side was dropped;git diffagainstorigin/mainshows 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 inintegrationTests/,bin/andunitTests/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, andgitCredentials'grants the credential environment to the spawn that clones, which sees theGIT_CONFIG_GLOBAL/GIT_EDITORthis 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 pinapplyLogSettingsagainst a baremyComponent: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
a5d5b7f36on all three Node versions, and that is what this round fixes. The wedge isshould handle default config resolution, which waits for achangewhere a scope that booted on its truthyDEFAULT_CONFIGvalue now takes thereadyits first source config is — the contractb0c788592introduced for exactly that case..mocharc.jsonsetstimeout: 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.jsnever returns, and the same test alone hangs), and green 3/3 here. Earlier in the branch the same job wedged ondoes not write an applied config into the shared defaults, which recreated the config file from a microtask of chokidar's ownunlinkdispatch; that case now drives its post-recreate read through_refreshForTests()and carries its own timeout, and delivery of a recreate stays asserted byshould 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 at4929b4fathe job runs 9m05s against.github/workflows/unit-test.yml's job-leveltimeout-minutes: 10, which covers install, build and every test phase. Step timings across the matrix put the cost where it actually is:Run teststest:typesSo 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 inRun testsalone, on the same suite, which is Node 22's own runtime.mainruns the same job in 7m01s–7m47s, and what this branch adds is about 45 s of deliberate wall clock: roughly fourteen unit tests letConfigReadRetry's 3,100 ms budget expire in real time, plusconfigUtils' 3,631 ms shipped-default case and a 6,202 ms two-budget case. Measured locally at this head,unitTests/config/**takes 33 s andunitTests/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 onJobs - Delete Jobs_test schema(a 45 sdrop_schematimeout 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/*.jsunlessNODE_OPTIONS=--conditions=typestrip, so a unit run without a precedingnpm run buildexercises 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 givetimes 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/mainas 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
EPERMexhaustion on an earlier head — is the only end-to-end oracle here.Rebase re-verification (this round, onto
origin/mainat9a3c75013):npm run buildclean;npm run test:unit:main— 5303 passing, 196 pending, 2 failing, same two environmental failures as above (configValidatordomain-socket path length under this run's long worktree path,gitCredentialsgit-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 Checkfails onunitTests/resources/query-array-scoping.test.js(from Pin element-scoping semantics of queries over array-valued properties #2437, untouched by this diff;npx prettier --checkreproduces it againstorigin/maindirectly).Unit Test (Windows, Node.js v24)fails 7 cases inunitTests/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, exit3221226505) instead of exercising the guard.git diffshows this file and its test are byte-identical between this branch andorigin/main, andorigin/main's own CI has been red on this same job sincee966f2787(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, whoserestart_serviceprobe 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 carriesJavaScript execution has taken too long and is not allowing proper event queue cyclingonmain/0covering 17.5 s, starting the momentRestarting http_workerswas logged. The same test passes onmain(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_MSreads 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 inDESIGN.md. The// Test-only:markers on the_*ForTestsseams 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