Skip to content

fix(config): extend the Windows atomic-write rename-retry budget - #2191

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

fix(config): extend the Windows atomic-write rename-retry budget#2191
kriszyp wants to merge 10 commits into
mainfrom
kris/win-rename-retry-budget

Conversation

@kriszyp

@kriszyp kriszyp commented Aug 17, 2026

Copy link
Copy Markdown
Member

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 EPERM exhaustion reproduced by the Windows integration job on the earlier PR head.

For the human reviewer

  1. A 10-second Windows writer ceiling. The available evidence only shows the former 910-millisecond budget being exhausted twice; 10 seconds remains an unmeasured safety margin and synchronously blocks the calling worker. Lowering it is a one-constant change, but may restore intermittent AV-related write failures.
  2. A 500-millisecond synchronous read retry. Root config reads retry EPERM, EACCES, and EBUSY without 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.
  3. Exhausted RootConfigWatcher reads 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.
  4. Watcher error policies remain asymmetric. OptionsWatcher emits parse/listener errors while RootConfigWatcher preserves its prior swallowing behavior. Unifying them is mechanically small, but could turn previously quiet logger-bootstrap failures into startup failures.
  5. Sharing-violation codes are retried cross-platform. EBUSY joins the existing EPERM/EACCES set 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.
  6. The bounded reader is a separate shared module. This enforces the same handle-lifetime invariant for both root watchers at the cost of a second small backoff loop beside the writer. Inlining would reduce module surface but make the two watchers easier to drift apart again.

Verification

  • npm run build — passed at dd6dfa925387.
  • npm run lint:required — passed at dd6dfa925387.
  • npx mocha unitTests/config/configUtils.test.js --grep "Test atomicWriteFile function" — 11 passed.
  • Focused OptionsWatcher and RootConfigWatcher invariant tests — 6 passed.
  • npm run test:integration -- integrationTests/apiTests/configuration.test.mjs — 25 passed at dd6dfa925387.
  • The deadline test fails on origin/main as expected because the prior attempt-count loop takes about 3.63 seconds, beyond the test's 1-second upper bound.
  • The earlier pushed head reproduced the real Windows failure: set_configuration exhausted 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

Co-Authored-By: GPT-5 Codex <noreply@openai.com>

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

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Code Review

This pull request 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
kriszyp and others added 9 commits August 17, 2026 07:39
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>
Comment on lines +149 to +151
const read: Promise<void> = readFile(this.#filePath, 'utf-8')
.then((contents) => this.#applyContents(contents))
.catch((error) => this.#handleReadError(error))

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.

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:

Suggested change
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

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

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.

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:

Suggested change
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;

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.

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);

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.

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:

Suggested change
this.#handleReadError(error);
} catch (error) {
try {
this.#handleReadError(error);
} catch (handlerError) {
this.emit('error', handlerError);
}


Generated by Barber AI

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