Skip to content

Windows: set_configuration returns 500 because the config write's rename retry blocks the thread that would release the handle #2313

Description

@kriszyp

Summary

On Windows, set_configuration intermittently returns HTTP 500. The atomic config write fails
with EPERM: operation not permitted, rename, and the operation's thread is stalled for the
full ~3.6 s retry budget before the error surfaces.

Seen on main: https://github.com/HarperFast/harper/actions/runs/32809913865, job
Integration Tests 6/6 (Windows, Node.js v24), failing
integrationTests/apiTests/configuration.test.mjs:319
(expected 200 "OK", got 500 "Internal Server Error").

2026-08-25T04:46:04.396Z [main/0] [error]: Error: EPERM: operation not permitted, rename
  ...\harper-config.yaml.6828.0.cc696ba1.tmp -> ...\harper-config.yaml
    at atomicWriteFile (dist/config/configUtils.js:168)
    at updateConfigValue -> setConfiguration

Root cause

The retry loop is self-defeating: it waits out the holder by blocking the very thread that must
run to release it.

  1. Windows rename() over an existing destination fails while any descriptor is open on that
    destination.
  2. Harper's own root-config watchers are that holder. Every thread runs a RootConfigWatcher
    (utility/logging/harper_logger.ts) and one OptionsWatcher per root-config component scope
    (components/Scope.ts); each reacts to a change with fsPromises.readFile on
    harper-config.yaml (config/RootConfigWatcher.ts, components/OptionsWatcher.ts). That
    read's close() continuation runs on the owning thread's event loop, so the descriptor is
    held across event-loop turns. One config write fans out dozens of these reads.
  3. atomicWriteFile (config/configUtils.ts) retries renameSync with Atomics.wait on the
    calling thread. When the holder is an in-flight read on that same thread, its close() can
    never be scheduled while we wait — so the holder's lifetime is exactly the retry budget and
    every attempt is guaranteed to fail.

The trigger is back-to-back writes. In the failing run the first set_configuration at
04:46:00.610 added a component key (the largest possible watcher fan-out — it creates a new
scope); the second at 04:46:00.638, 28 ms later, started while those reads were in flight and
errored at 04:46:04.388. The backoff sums to exactly 3630 ms; the observed gap is 3750 ms, and
the main thread logs nothing at all in between.

This also explains the fix history: #1714 and #2036 each widened this budget. Neither could work
— widening it only lengthens the failure.

Measured on the Windows CI runner

Node v24.19.0, windows-latest, reproduced identically on two independently dispatched runs
(1,
2):

case result
control: rename over destination, no handles open ok
destination held by a single Node fs.openSync(dest, 'r') descriptor EPERM
same, immediately after closing that descriptor ok
destination held by an in-flight fsPromises.readFile while the production retry loop blocks the thread EPERM on all 13 attempts
same, immediately after awaiting that read ok
source (the .tmp) held open by a descriptor ok
destination watched by fs.watch (file), fs.watch (dir), or chokidar ok
chokidar watching + in-flight read, loop blocking EPERM on all 13 attempts

Three consequences, all measured rather than assumed: a Node read descriptor on the destination
does block Windows rename despite libuv opening files with FILE_SHARE_DELETE; the watch
handle is innocent, which is why the failure is intermittent rather than permanent; and holding
the source open does not block, so antivirus scanning the freshly written temp file is not the
mechanism.

Impact

  • set_configuration returns 500 and the config change is not applied. The write stays atomic —
    the previous config is intact and the temp file is cleaned up — so this is a failed operation,
    not corruption.
  • The operation's thread is blocked for ~3.6 s per occurrence, so every other request on that
    thread stalls with it.
  • Windows only. POSIX rename over an open file is legal, so Linux and macOS are unaffected.
  • Present in shipped versions: the retry loop has been in place since fix(windows): widen atomicWriteFile rename-retry backoff for EPERM/EACCES #1714.

Fix direction

Bound the descriptor to a single syscall — read the root config synchronously in both watchers,
so no config read outlives the event-loop turn it started in. Application configs are written in
place (fs.outputFile), never by rename-over, so they keep the non-blocking read.

Related: #1143 (atomic config write hardening), #1747 (a config write lost at the watcher layer),
#525 (Windows integration testing).

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Labels

bugSomething isn't working

Type

Fields

Priority

P2

Projects

No projects

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions