You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
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.
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.
Windows rename() over an existing destination fails while any descriptor is open on that
destination.
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.
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.
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).
Summary
On Windows,
set_configurationintermittently returns HTTP 500. The atomic config write failswith
EPERM: operation not permitted, rename, and the operation's thread is stalled for thefull ~3.6 s retry budget before the error surfaces.
Seen on
main: https://github.com/HarperFast/harper/actions/runs/32809913865, jobIntegration Tests 6/6 (Windows, Node.js v24), failingintegrationTests/apiTests/configuration.test.mjs:319(
expected 200 "OK", got 500 "Internal Server Error").Root cause
The retry loop is self-defeating: it waits out the holder by blocking the very thread that must
run to release it.
rename()over an existing destination fails while any descriptor is open on thatdestination.
RootConfigWatcher(
utility/logging/harper_logger.ts) and oneOptionsWatcherper root-config component scope(
components/Scope.ts); each reacts to a change withfsPromises.readFileonharper-config.yaml(config/RootConfigWatcher.ts,components/OptionsWatcher.ts). Thatread's
close()continuation runs on the owning thread's event loop, so the descriptor isheld across event-loop turns. One config write fans out dozens of these reads.
atomicWriteFile(config/configUtils.ts) retriesrenameSyncwithAtomics.waiton thecalling thread. When the holder is an in-flight read on that same thread, its
close()cannever 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_configurationat04:46:00.610added a component key (the largest possible watcher fan-out — it creates a newscope); the second at
04:46:00.638, 28 ms later, started while those reads were in flight anderrored at
04:46:04.388. The backoff sums to exactly 3630 ms; the observed gap is 3750 ms, andthe 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):
fs.openSync(dest, 'r')descriptorfsPromises.readFilewhile the production retry loop blocks the thread.tmp) held open by a descriptorfs.watch(file),fs.watch(dir), or chokidarThree 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 watchhandle 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_configurationreturns 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.
thread stalls with it.
renameover an open file is legal, so Linux and macOS are unaffected.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).