fix(config): extend the Windows atomic-write rename-retry budget - #2191
fix(config): extend the Windows atomic-write rename-retry budget#2191kriszyp wants to merge 10 commits into
Conversation
Co-Authored-By: GPT-5 Codex <noreply@openai.com>
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 — |
Co-Authored-By: GPT-5 Codex <noreply@openai.com>
Co-Authored-By: GPT-5 Codex <noreply@openai.com>
Co-Authored-By: GPT-5 Codex <noreply@openai.com>
Co-Authored-By: GPT-5 Codex <noreply@openai.com>
Keep RootConfigWatcher reads within the change callback so a following synchronous Windows config write cannot deadlock its own file handle behind Atomics.wait. Co-Authored-By: GPT-5 Codex <noreply@openai.com>
Prevent same-worker Windows rename self-contention in both root config watchers, retain the exported retry controls, validate the deadline, and avoid speculative EBUSY retries. Co-Authored-By: GPT-5 Codex <noreply@openai.com>
Validate retry timing inputs, retain a finite attempt safety cap, retry Windows sharing violations, preserve the prior POSIX window, and derive synchronous root reads from file identity. Co-Authored-By: GPT-5 Codex <noreply@openai.com>
Use one bounded synchronous reader for both root watchers and keep downstream listener errors out of config-file ENOENT recovery. Co-Authored-By: GPT-5 Codex <noreply@openai.com>
Co-Authored-By: GPT-5 Codex <noreply@openai.com>
| const read: Promise<void> = readFile(this.#filePath, 'utf-8') | ||
| .then((contents) => this.#applyContents(contents)) | ||
| .catch((error) => this.#handleReadError(error)) |
There was a problem hiding this comment.
Low: the async branch still conflates an apply-time ENOENT with a missing config file
The new synchronous branch above carefully splits the two failure modes — read errors to #handleReadError (:137-140), apply errors straight to emit('error') (:141-145) — and the new test does not treat an ENOENT from a change listener as a missing config file guards exactly that. This branch kept the old single chain, so a throw out of #applyContents (a yaml.parse failure, or any ready/change/remove listener throwing) lands in the same .catch as a read failure. When that error carries code: 'ENOENT' — e.g. a plugin's change listener doing a readFileSync of a file that isn't there — #handleReadError takes the file-is-missing path at :205-207: #resetConfig() + emit('remove'), which Scope's remove listener turns into a component teardown. The scope's config is discarded because a listener threw.
This branch serves application-scope watchers, and Scope.ts:407 (this.listenerCount('change') > 1) shows third-party change listeners are expected, so the trigger is real if narrow. Pre-existing, but this PR fixed one of the two branches and left the other plus a test that only covers the fixed half.
Suggested fix — two-argument .then, so only read rejections reach #handleReadError:
| const read: Promise<void> = readFile(this.#filePath, 'utf-8') | |
| .then((contents) => this.#applyContents(contents)) | |
| .catch((error) => this.#handleReadError(error)) | |
| const read: Promise<void> = readFile(this.#filePath, 'utf-8') | |
| .then( | |
| (contents) => { | |
| try { | |
| this.#applyContents(contents); | |
| } catch (error) { | |
| this.emit('error', error); | |
| } | |
| }, | |
| (error) => this.#handleReadError(error) | |
| ) |
—
Generated by Barber AI
| const RENAME_RETRY_MAX_ATTEMPTS = 12; | ||
| // EPERM/EACCES/EBUSY while another worker or AV holds the destination open. Root config watchers use | ||
| // readConfigFileSync so this blocking retry cannot wait on a read owned by its own worker. | ||
| const RENAME_RETRY_BUDGET_MS = process.platform === 'win32' ? 10_000 : 3_630; |
There was a problem hiding this comment.
Nit: neither budget constant is explained in source any more
The comment rewrite correctly dropped the harper#2036 miscitation and the "910ms exhausted twice, so ~10s" non-sequitur, but it also removed the only in-source rationale for the numbers. 3_630 is the opaque one: it is the sum of the previous 12-attempt backoff schedule (10+20+40+80+160+320+500x6), chosen so non-Windows behavior is byte-for-byte unchanged — a reader can't recover that, and a later tweak to RENAME_RETRY_INITIAL_DELAY_MS or RENAME_RETRY_MAX_DELAY_MS silently desynchronizes it. The 10s Windows rationale now lives only in the PR body, which won't survive to the next reader of this file.
Suggested fix:
| const RENAME_RETRY_BUDGET_MS = process.platform === 'win32' ? 10_000 : 3_630; | |
| // Non-Windows keeps the prior 12-attempt window exactly (10+20+40+80+160+320+500*6 = 3,630ms). | |
| // The Windows 10s budget is a deliberate, unmeasured safety margin over the ~910ms that was | |
| // observed exhausting on CI; it is a synchronous stall on the calling worker (harper#2191). | |
| const RENAME_RETRY_BUDGET_MS = process.platform === 'win32' ? 10_000 : 3_630; |
—
Generated by Barber AI
| @@ -0,0 +1,35 @@ | |||
| import { readFileSync } from 'node:fs'; | |||
|
|
|||
| const READ_RETRY_BUDGET_MS = 500; | |||
There was a problem hiding this comment.
Medium: this 500ms budget is per-watcher, and a worker runs ~10+ root-config watchers that now read serially on its own thread
Every root-declared plugin gets its own Scope (componentLoader.ts:674), each of which builds its own OptionsWatcher (Scope.ts:156) on the same root config path — configPath is computed once at componentLoader.ts:466 from the root component directory and shared by all of them. TRUSTED_RESOURCE_PLUGINS has 20 entries and 10+ of them export handleApplication (http, REST, graphql, authentication, mqtt, static, roles, login, dataLoader, scheduler, mcp, ...), so a typical worker holds ~10+ independent chokidar watchers on one file, each of which calls #handleChange on that file's change event — and on the watcher's own ready event (OptionsWatcher.ts:129) at boot.
Before this PR those were concurrent async threadpool reads with no retry: a transient EPERM failed fast and blocked the event loop for ~0ms. Now each one is a synchronous read that can spin up to 500ms in Atomics.wait, and they all run on the same worker JS thread, so they serialize. When the destination lock outlives the budget — exactly the Windows AV scenario this PR targets — one config change costs the worker ~N x 500ms of fully blocked event loop (~5s at 10 watchers), on every worker at once. The happy path is unaffected, and once one watcher's retry succeeds the rest read immediately, so this only bites when the lock outlasts the burst.
This is the same failure mode as the write-side stall this PR set out to bound, just moved to the read side and multiplied by the watcher count.
Suggested fix: share one deadline across the burst rather than giving each watcher its own — e.g. take an optional deadline parameter so callers can pass a per-event deadline, or memoize the last successful (mtimeMs, contents) read for a short window so sibling watchers reuse it instead of each re-entering the retry loop.
—
Generated by Barber AI
| try { | ||
| contents = readConfigFileSync(this.#filePath); | ||
| } catch (error) { | ||
| this.#handleReadError(error); |
There was a problem hiding this comment.
Low: #handleReadError is called outside the try that guards #applyContents
The sync rewrite wraps #applyContents in a try/catch (:141-145) so a throwing ready/change/remove listener surfaces as emit('error') instead of escaping. This call is unguarded, even though #handleReadError also emits ready and remove synchronously on the ENOENT path (:207, :211). A listener throwing there propagates straight out of #handleChange into chokidar's emit — the same asymmetry the rewrite was meant to remove, just on the other branch.
Suggested fix:
| this.#handleReadError(error); | |
| } catch (error) { | |
| try { | |
| this.#handleReadError(error); | |
| } catch (handlerError) { | |
| this.emit('error', handlerError); | |
| } |
—
Generated by Barber AI
Windows configuration writes now retry transient destination locks against a monotonic 10-second deadline with a 25-attempt safety cap, while non-Windows retains its prior 3.63-second window. Both root-config watchers use one bounded synchronous reader so a worker cannot block waiting for its own open handle; this directly addresses the 10-second
EPERMexhaustion reproduced by the Windows integration job on the earlier PR head.For the human reviewer
EPERM,EACCES, andEBUSYwithout yielding so they never leave a same-worker descriptor open across the writer's blocking sleep. Matching the 10-second writer budget would improve eventual convergence but could multiply stalls across per-component watchers; moving the writer async is a substantially larger API change.RootConfigWatcherreads preserve the last valid config silently. This retains existing behavior after the new retry is exhausted, but a worker can remain stale until another edit. Logging or scheduling another read would improve diagnosis/convergence and is cheap to add, but changes boot/runtime error policy beyond this focused fix.OptionsWatcheremits parse/listener errors whileRootConfigWatcherpreserves its prior swallowing behavior. Unifying them is mechanically small, but could turn previously quiet logger-bootstrap failures into startup failures.EBUSYjoins the existingEPERM/EACCESset because libuv can surface Windows sharing violations that way. On POSIX, a genuine permission failure can now cost the bounded retry window before surfacing; platform-gating the codes is a one-condition change.Verification
npm run build— passed atdd6dfa925387.npm run lint:required— passed atdd6dfa925387.npx mocha unitTests/config/configUtils.test.js --grep "Test atomicWriteFile function"— 11 passed.OptionsWatcherandRootConfigWatcherinvariant tests — 6 passed.npm run test:integration -- integrationTests/apiTests/configuration.test.mjs— 25 passed atdd6dfa925387.origin/mainas expected because the prior attempt-count loop takes about 3.63 seconds, beyond the test's 1-second upper bound.set_configurationexhausted the 10-second deadline while its own async watcher read could not close. The current head removes that async-handle cycle; current Windows CI is the end-to-end confirmation.The unit tests run on Linux and verify retry classification, deadline exhaustion, cleanup, option compatibility, and synchronous watcher completion. They do not emulate native Windows sharing semantics.
Review coverage
Authored by GPT-5 Codex. Full implementation rounds used Claude Opus 5, Gemini via agy (default model), Cursor Composer 2.5, and Claude Opus 5 Harper-domain adjudication. The current-head delta was reviewed by Claude Opus 5 with Harper-domain adjudication; Gemini returned no output, while Cursor Composer and Grok were pruned. Receipt @
dd6dfa925387.Human-Review-Need: 4 (decisions: sync-read-vs-async-write, win32-10s-budget, ebusy-retryable, silent-read-failure-policy, sync-mode-keyed-on-filename, test-only-hatch) @ 196154b