Stop a deleted watched path from raising an uncaught EPERM (Windows) - #2364
Conversation
There was a problem hiding this comment.
Code Review
This pull request introduces a process-level guard (guardedWatch) to safely handle benign, asynchronous "lost native watch" errors (such as EPERM on Windows when a watched directory is deleted) that occur when using Chokidar with persistent: false. It updates various watchers across the codebase to use this guarded wrapper and adds comprehensive unit tests. The review feedback is highly constructive, pointing out two important edge cases in utility/watcherFallback.ts: resetting the guard state when the listener is removed on an unrelated exception to prevent subsequent watchers from being left unguarded, and cleaning up the global listener in _resetForTests() to ensure proper test isolation.
Every Harper chokidar watcher runs with `persistent: false` so it never holds
the event loop open. That option takes chokidar down the one branch of
`setFsWatchListener` that never attaches an `'error'` listener to the
underlying Node `fs.FSWatcher`:
if (!options.persistent) {
watcher = createFsWatchInstance(path, options, listener, errHandler, rawEmitter);
if (!watcher) return;
return watcher.close.bind(watcher); // <-- no watcher.on('error', ...)
}
`errHandler` there is only consulted for a synchronous throw out of
`fs.watch()`, which is how ENOSPC/EMFILE already reach the polling fallback.
An asynchronous watch failure — on Windows, deleting or replacing the watched
directory — is delivered as `emit('error', err)` on an emitter with no
listener, so Node turns it into an uncaughtException. It never reaches
chokidar's wrapper, so `.on('error')` on the FSWatcher we hold cannot see it;
chokidar's `persistent: true` branch does attach a listener and swallows this
exact error, which is why only our watchers hit it. Still unfixed as of
chokidar 5.0.0, and reproducible with every shape Harper watches.
Add `guardedWatch()` to utility/watcherFallback.ts: `chokidar.watch()` plus an
idempotent, prepended process-level listener that claims only this error shape
(`syscall === 'watch'` with EPERM/ENOENT), marks it `isHandled` so the
thread-level handlers stay quiet, and logs once. Anything it does not claim is
left exactly as fatal as Node would have made it, including when the guard is
the only `uncaughtException` listener. Route all five chokidar call sites
through it, and classify the same error as benign in the three watcher error
handlers for the day chokidar does deliver it.
Verified on Windows 11 / Node v24.14.0 / chokidar 4.0.3: each watcher shape
(EntryHandler's cwd-relative base, the config-file watchers, manageThreads'
directory watcher) crashed with `EPERM: operation not permitted, watch` before
and survives after.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
9b413be to
a897214
Compare
|
Rebased onto latest One real fix needed beyond textual merging: Also closed two small gaps the independent review caught: Build,
None of these are rebase artifacts — all three rounds confirm they're pre-existing in the original diff. Flagging per the decision ledger rather than acting on them, since they're your call as the PR's designer. Full review artifacts available if useful; happy to implement whichever direction you pick. — Claude Sonnet 5 (dev-agent rebase) |
| * the same benign treatment on the day chokidar does deliver it to the wrapper | ||
| * (its polling and `persistent: true` branches already would). | ||
| */ | ||
| export function claimLostNativeWatchError(error: unknown): boolean { |
There was a problem hiding this comment.
1. Lost native watch errors are claimed but never rearmed for process-lifetime watchers
File: utility/watcherFallback.ts:147 (also config/RootConfigWatcher.ts:72, server/threads/manageThreads.js:1521)
What: claimLostNativeWatchError() marks the error handled, logs it once, and returns — it never closes and reopens the watch it just claimed. The sibling exhaustion path in the same handlers does the opposite: RootConfigWatcher.handleError (config/RootConfigWatcher.ts:73-91) and manageThreads.js's watchDir error handler (server/threads/manageThreads.js:1522-1534) both explicitly close() then reopen the watcher on ENOSPC/EMFILE, but neither does anything after if (claimLostNativeWatchError(error)) return; claims the lost-watch case.
Why it matters: RootConfigWatcher is a process-wide singleton created once at boot (utility/logging/harper_logger.ts:148, if (!rootConfig) rootConfig = new RootConfigWatcher()) and never recreated, and manageThreads.js's WATCH_DIR watcher is opened once at module load (server/threads/manageThreads.js:1551) with no equivalent lifecycle hook. Per this PR's own verification table, deleting/replacing the watched file itself (not just a directory) reproduces this exact error for both. Before this PR that crashed the process — loud, but the watcher came back on restart. After this PR the process survives, but that watch is gone for good: Harper's root config (or, for WATCH_DIR, component-reload detection) silently stops hot-reloading, recoverable only by a full process restart, and the only diagnostic is one anonymous warn-level log that doesn't say which path was lost. security/keys.ts is largely covered by its independent periodic re-read safety net (tls.certificateWatchInterval, default 5m) and EntryHandler/OptionsWatcher instances are torn down and recreated on component redeploy, but RootConfigWatcher and WATCH_DIR have neither mitigation. None of the per-class watcher suites (rootConfigWatcher.test.js, watchDirFallback.test.js) exercise claimLostNativeWatchError, so this gap isn't covered either way. This matches the "swallow-vs-rearm" item flagged in the PR's own rebase-conversation decision ledger as still open.
Suggested fix: On a claimed lost-watch error, close the dead watcher and call openWatcher() again — mirroring the exhaustion path already used for ENOSPC/EMFILE in the same handlers — instead of only logging and returning.
|
One blocker carries over, still unaddressed: |
…ilent Cross-model review raised that the process-global guard classifies by error shape alone, so it could swallow an unrelated failure and leave a subsystem silently unwatched. Two of its three parts hold up, and both are now closed. The claimed set was too wide. A synchronous `fs.watch()` throw — the ordinary "watch a path that isn't there" misconfiguration — shares `syscall: 'watch'`, and with ENOENT in the set it matched exactly. Node distinguishes the two: the thrown error carries `path`, the one delivered to the handle's 'error' event does not (it carries `filename: null` and nothing else locating it). Claim only EPERM, and only when `path` is absent. An async ENOENT from a watch handle has never been observed; a synchronous one now stays fatal, with a child-process case proving it. Repeat occurrences were invisible. The default log level is `warn` and only the first occurrence logged there, so a watcher that stopped reporting after the first would never surface. Warn on the first and at each tenfold increase, trace on every one — bounded against a delete storm without suppressing the signal. The message no longer asserts a cause it cannot know: Node gives this error no path, so it says the failure cannot be attributed to a specific watcher and names the Windows deleted-directory case as the usual, not certain, explanation. Not adopted: restricting the exemption to registered guarded roots. It is not implementable — the error carries no path, which is the same fact that makes per-watcher routing impossible in the first place. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…s on
Second cross-model round. Codex, run with repository read access, verified every
claim against the code and found no blocker or significant issue; Gemini raised
five, of which three hold.
Adopted:
- The guard clears its installed flag when it steps aside for an unrelated
exception. Stepping aside is meant to be terminal, but the flag said otherwise:
a process that somehow survived would have run unguarded for the rest of its
life rather than re-arming on the next guardedWatch().
- `_resetForTests` drops the process listener it may have installed, instead of
leaving one attached to the test runner for every case that follows.
- `claimLostNativeWatchError` no longer counts an error instance twice, which
would inflate the tally and trip the decade warning early.
- Named `watch` import rather than the default export. The default export is
real in chokidar 4 and 5, so this changes nothing, but it removes a question a
reader has to answer.
Rejected: that `require('../../utility/watcherFallback.ts')` in a CJS module
throws MODULE_NOT_FOUND. `manageThreads.js` already has eight such requires, and
the build rewrites them to `.js`.
Two tests from Codex's suggestions, both of which failed to exist rather than
failing to pass:
- Prepend ordering was the mechanism the `isHandled` mark depends on and nothing
proved it. The harness now registers a threadServer-style listener *before* the
first watcher and asserts it observes `isHandled=true` — appending would leave
it false.
- The warn cadence had no coverage. Twelve claims must produce exactly two
warnings, at occurrences 1 and 10.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The named `watch` import was adopted last commit as a free tidy-up. It was not free: unitTests/server/threads/watchDirFallback.test.js drives the reopen-on-exhaustion chain by swapping `chokidar.default.watch`, and a named import binds past that seam, so both of its cases saw zero watcher opens. That test arrived with the rebase onto current main and did not exist on the branch's old base, which is how the change looked harmless. Back to the property access, with a comment saying why it has to stay one. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
| * the same benign treatment on the day chokidar does deliver it to the wrapper | ||
| * (its polling and `persistent: true` branches already would). | ||
| */ | ||
| export function claimLostNativeWatchError(error: unknown): boolean { |
There was a problem hiding this comment.
Still open from the prior review (thread on this same line): claimLostNativeWatchError() marks the error handled and returns without closing/reopening the watch, while the sibling ENOSPC/EMFILE path a few lines below in RootConfigWatcher.handleError (config/RootConfigWatcher.ts:72) and manageThreads.js's watchDir handler (server/threads/manageThreads.js:1521) both explicitly reopen. This push's three commits fixed a related-but-different gap (the guard's own lostNativeWatchGuardInstalled flag not resetting on self-removal) — they don't touch this. RootConfigWatcher is a boot-time singleton never recreated, and WATCH_DIR in manageThreads.js is opened once at module load with no other lifecycle hook, so a Windows delete/replace of the watched file still permanently and silently disables config hot-reload / component-reload detection, recoverable only by a full restart. Suggested fix unchanged: on a claimed lost-watch error, close the dead watcher and call openWatcher() again, mirroring the exhaustion path in the same handler.
The guard's listener is prepended, so it classifies every uncaught exception before Harper's own handlers see one. Classification mutates the error (`isHandled = true`), and on an error that is frozen — or with a throwing property getter — that mutation throws from inside an 'uncaughtException' listener: Node then reports the guard's TypeError instead of the original error and exits 7, and threadServer/socketRouter never get their turn. Classify inside a try/catch and treat a failure to classify as "not ours", which leaves the original exception fatal exactly as it would have been unguarded. The new child-process case pins it with a frozen lost-watch-shaped error, so it runs on the ubuntu unit-test CI rather than only on Windows. Also stop the two fixed 1.5s waits in the harness from racing Windows delivery: the deadline is now 5s on win32 (where the error is the thing being waited for and CI runners are loaded) and unchanged elsewhere, and the delete case polls and exits as soon as it observes the claim instead of always sleeping. The prepend-ordering case now skips before spawning off Windows, where it could only ever wait out the deadline and skip. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Last commit made the process guard survive an error it could not mark handled by
treating the failure as "not ours", which was too blunt in one direction and too
narrow in the other. Too blunt: the mark and the log lines are bookkeeping, and
losing them is no reason to hand a failure we just classified as benign back to
Node as fatal — worse, if the mark landed and only the log threw, the guard
would step aside while threadServer/socketRouter still skipped the error as
handled, so nothing reported it at all. Too narrow: the watcher `.on('error')`
routes call claimLostNativeWatchError directly, outside that try/catch, so a
frozen error reaching one of them still threw.
Move the boundary into claimLostNativeWatchError. Classification decides whether
the error is ours; counting, logging and marking follow inside a try/catch and
cannot un-claim it, for the direct callers as much as for the guard. Marking
goes last so a frozen error is still counted and logged. The guard's own
try/catch now covers only classification — an error whose shape cannot even be
read is not ours, and stays fatal.
Tests follow the corrected split: the frozen child case now asserts survival
with the claim counted, a new case pins that an error the guard cannot classify
stays fatal without the guard's own throw replacing it, and a unit case covers
the direct `.on('error')` route.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Three review lenses flagged the comment volume against the house default of
none-unless-it-carries-a-why. The chokidar header keeps the mechanism (why a
process-level listener rather than watcher.on('error')) and loses the source
quote and the history; the rest lose their narration and keep one line each.
DESIGN.md said the guard covers `EPERM`/`ENOENT`; ENOENT was dropped from the
claimed set two commits ago and the doc did not follow.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
| * the same benign treatment on the day chokidar does deliver it to the wrapper | ||
| * (its polling and `persistent: true` branches already would). | ||
| */ | ||
| export function claimLostNativeWatchError(error: unknown): boolean { |
There was a problem hiding this comment.
Still open (see the two prior threads on this line): claimLostNativeWatchError() still never closes/reopens the watch it claims. This push's commits (frozen-error handling, classification/bookkeeping boundary, comment trim) don't touch that gap — RootConfigWatcher (config/RootConfigWatcher.ts:72) and manageThreads.js's WATCH_DIR (server/threads/manageThreads.js:1521) still permanently lose hot-reload after a Windows delete/replace, recoverable only by a full restart. Not re-litigating the mechanism again here — flagging so it stays visible since the PR author's own decision ledger (top-level PR comment) named this "your call as the PR's designer" rather than resolving it.
initLogSettings() falls back to a no-config branch on any host without a harperdb-config.yaml — the install window, and a fresh CI runner. It sets `log_to_file = false; logToStdstreams = true`, which reads as "the streams are the sink now", and then called createLogger() without passing stdStreams. createLogger destructures that option into a local of the same name, which shadows the module-level flag inside logStdOut/logStdErr, so the branch wrote nowhere at all: before a config exists, every log line Harper produced was silently dropped, install errors included. That branch also returns before the stdioLogging() call at the end of initLogSettings(), so the streams it now writes to would have had no EPIPE/EIO listener — `harper install | head -1` closes the reader, and the async error would land on a stream with none and take the install down. Install the guards there too; their write override is inert on this branch, because log_to_file is false. Found from the Windows unit gate, where the guard's warn-cadence case counted 0 of 2 warnings (harper#2364). #2468 has since made that harness bring its own config, which is the right fix for the test; this is the product bug underneath it, which that leaves in place. A child-process case pins both halves: it spawns with ROOTPATH at a directory holding no config, which reaches the fallback on an installed machine and a bare one alike, and asserts the warning on stderr and the guard listener on each stream. It fails on the old createLogger() call, and again without the stdioLogging() call. Verified: `npm run test:unit:windows` (the Windows gate's own groups, on Linux). Refs #2364 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017gPFHKnh2qasUuy53fdzbe
initLogSettings() falls back to a no-config branch on any host without a harperdb-config.yaml — the install window, and a fresh CI runner. It sets `log_to_file = false; logToStdstreams = true`, which reads as "the streams are the sink now", and then called createLogger() without passing stdStreams. createLogger destructures that option into a local of the same name, which shadows the module-level flag inside logStdOut/logStdErr, so the branch wrote nowhere at all: before a config exists, every log line Harper produced was silently dropped, install errors included. That branch also returns before the stdioLogging() call at the end of initLogSettings(), so the streams it now writes to would have had no EPIPE/EIO listener — `harper install | head -1` closes the reader, and the async error would land on a stream with none and take the install down. Install the guards there too; their write override is inert on this branch, because log_to_file is false. Found from the Windows unit gate, where the guard's warn-cadence case counted 0 of 2 warnings (harper#2364). #2468 has since made that harness bring its own config, which is the right fix for the test; this is the product bug underneath it, which that leaves in place. A child-process case pins both halves: it spawns with ROOTPATH at a directory holding no config, which reaches the fallback on an installed machine and a bare one alike, and asserts the warning on stderr and the guard listener on each stream. It fails on the old createLogger() call, and again without the stdioLogging() call. Verified: `npm run test:unit:windows` (the Windows gate's own groups, on Linux). Refs #2364 Claude-Session: https://claude.ai/code/session_017gPFHKnh2qasUuy53fdzbe Co-authored-by: Kris Zyp <kris@harperdb.io> Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
The bug
Windows-only. Deleting or replacing a watched path raises an uncaught
EPERM: operation not permitted, watch(errno -4048,syscall: 'watch') fromnode:internal/fs/watchers. Distinct from the 8.3 short-path abort (#2309) and the config-write EPERM rename (#2339), both of which have since landed and are in this branch's base.Every Harper chokidar watcher runs with
persistent: falseso it never holds the event loop open. That option takes chokidar down the one branch ofsetFsWatchListenerthat never attaches an'error'listener to the underlying Nodefs.FSWatcher:errHandlerthere is only consulted for a synchronous throw out offs.watch()— which is how ENOSPC/EMFILE already reach the polling fallback added in #488. An asynchronous watch failure is delivered asemit('error', err)on an emitter with no listener, so Node turns it into an uncaughtException. It never reaches chokidar's wrapper, so.on('error')on theFSWatcherwe hold cannot see it. chokidar'spersistent: truebranch does attach a listener and swallows this exact error (its node#4337 workaround), which is why only our watchers hit it.Live in CI:
Integration Tests 4/6 (Windows, Node.js v24)on main logs repeateduncaughtException Error: EPERM: operation not permitted, watchduringRedeploy runtime-equivalence proof, which deletes and replaces component directories.Why not the obvious fixes
persistent: trueprintsSURVIVED. No upstream issue covers it (Flaky: Unit Test (Node.js v24) SIGSEGV (exit 139) in schema-change / index-rebuild test group — v24-only #1380 is a different EPERM:syscall: 'rename'from the caller's ownrenameSync). The upstream fix is one line,watcher.on('error', errHandler)in the non-persistent branch.postinstallexists today, and patch-package patches would not apply for anyone installingharperfrom npm, so production Windows users would still crash.fs.watch— appears to work (chokidar's ESM facade fornode:fsis snapshotted lazily at first ESM import, so a CJS-first patch does reach it), but any earlierimport … from 'node:fs'in the graph silently defeats it and we have dozens. Verified both the working and the defeated ordering.persistent: true— deliberately not touched; the option exists so watchers don't hold the event loop open, and chokidar exposes no way to unref the handle.filename: nulland nopath, so it cannot be attributed to the watcher that died.The fix
guardedWatch()inutility/watcherFallback.ts:chokidar.watch()plus an idempotent, prepended process-level listener that supplies the missing handler at the only place the error is observable. All five chokidar call sites route through it —EntryHandler,OptionsWatcher,RootConfigWatcher,security/keys.ts,manageThreads.js— so no call site can forget.Three conditions bound what it claims, and all are load-bearing, because whatever it claims is swallowed process-wide:
syscall === 'watch'— only ever set by fs watch handles.code === 'EPERM'only. Not ENOENT: an async ENOENT from a watch handle has never been observed, whereas a synchronous one is the ordinary "watch a path that isn't there" misconfiguration.path. This separates the async failure from a synchronousfs.watch()throw — Node populatespathon the thrown error but leaves it absent on the one delivered to the handle's'error'event. Without it, a rawfs.watch(missingPath)escaping into an uncaughtException would be mistaken for a benign lost watch and silently swallowed.It also does not become a blanket crash suppressor: for anything it does not claim, if the guard is the only
uncaughtExceptionlistener it removes itself, clears its installed flag, and re-raises onnextTick, preserving Node's report and exit code 1 (a plain rethrow from inside the handler gives exit 7).The claim is the classification; the bookkeeping is best-effort. Counting, logging and the
isHandledmark all run inside atry, in that order, and cannot un-claim an error the shape check already accepted — the same function is called directly by the five watchers' own'error'routes, outside the guard. A frozen error or a logger that threw would otherwise turn a failure just classified as benign into a fatal one, and this listener runs first, so its throw would take Node's report (exit 7) and the thread-level handlers' turn with it. Only classification can still throw, and an error whose shape cannot even be read is not the guard's to claim: it stays fatal.Claimed errors are marked
isHandled, sothreadServer.jsstops logging them as worker-level uncaughtExceptions.socketRouter.tswas missing that same check — added, matching the contract threadServer already documents. Prepending is the mechanism: Node calls every listener regardless of order, so the mark only suppresses those handlers if the guard runs first.Logging never goes fully silent. The default level is
warn, so a warn on the first occurrence and trace thereafter would hide every subsequent one. It warns on the first and at each tenfold increase, traces every one. The message states only what is known — that the failure cannot be attributed to a specific watcher — and names the Windows deleted-directory case as the usual, not certain, cause.DESIGN.md's watch-site section records the wrapper's relationship to the canonicalization invariant:
guardedWatcharms the watch but resolves no path of its own, so every caller still canonicalizes before calling it.No polling fallback for this case, deliberately. Polling a path that no longer exists just burns CPU. The recovery case that would have justified it does not need it: an atomic rename-replace of a watched config file does not kill the watcher (
add,change, then a livechangeon a subsequent in-place write). The trigger is specifically removal of the watched directory, where the component is being torn down and the watcher recreated anyway.For the human reviewer
The pre-push rounds ran without the domain adjudicator (auto policy pruned it on a low-risk delta), so this is the ledger written from the standing findings rather than a generated one.
pathandfilename: null, so it cannot be attributed to a watcher, and the trigger is the watched directory being removed, where polling would burn CPU on a path that no longer exists. What makes it liveable is that the case where it would matter (an atomic rename-replace of a watched file) does not kill the watcher at all, and the warn line names the symptom. A "no" here means per-call-site re-arming — each site already owns a reopen chain for exhaustion, so it is a contained follow-up, not a redesign of this PR.'error'routes call the same function with no guard around them. Cost of a "no": a frozen lost-watch-shaped error takes the thread down. One commit either way.socketRouter.tsreadsisHandledoff a possibly-primitive error. Two lenses flag it each round. Left as a plain property read to match the pre-existing.coderead on the next line:throw nullcrashed that handler before this PR too, so?.on one line moves the crash rather than removing it. Making that handler null-safe is a separate change with its own semantics to decide.chokidar.default.watchtest seam. One lens calls this a blocker every round, arguing the CJS mock cannot interceptguardedWatch. It demonstrably does: adopting the suggested namedwatchimport made bothwatchDirFallbackcases see zero watcher opens, which is why 7a8acb2 reverted it, and the suites pass as they stand. Ruling on it here would stop it being re-litigated per round.Verification (Windows 11, Node v24.14.0, chokidar 4.0.3)
Every shape Harper actually watches crashed before and survives after:
watch('.', {cwd, persistent:false, followSymlinks:false, ignored})— EntryHandlerwatch(file, {persistent:false})— OptionsWatcher / RootConfigWatcher / keys.tswatch(dir, {persistent:false, ignored})— manageThreadsTests
unitTests/utility/watcherFallback.test.js— 23 passing, 1 pending. Predicate cases pin the shapes it must not claim: a synchronous throw (carriespath),ENOENT,EPERMwithsyscall: 'rename', and the exhaustion codes that belong to the polling route. A unit case covers the watchers' direct'error'route: an error the claim cannot mark is claimed anyway rather than thrown back at the caller.Seven child-process cases via
fixtures/lostWatchHarness.cjs. Child process is required: mocha's ownuncaughtExceptionlistener would absorb the crash and the test would pass either way.fs.watchfailure on a missing path is still fatalisHandled=true— appending would leave it false, so this is what pins the ordering (win32 only; it is the one pending case elsewhere)TypeErrorpathgetter, re-raised as itself rather than replaced by the guard's throwThe delete case asserts survival on every platform and
lostWatchCount > 0on win32, so it still runs as a smoke test on the ubuntu-only unit-test CI; the two cases above raise their own errors and so assert real behavior on every platform. The harness's fixed 1.5s waits are now a 5s ceiling on win32 with early exit once delivery is observed, rather than a sleep that could race a loaded runner.Linux (Node v26.2.0), this branch:
watcherFallback+watchPath+watchDirFallback+security/keys— 100 passing, 1 pending, 0 failing. The Windows table above was measured at the first commit; the three commits since change only the guard's exception path and its tests, and touch no watch-arming behavior.Cross-model review
Reviewed by Codex (with repository read access) and Gemini. Codex found no blocker or significant issue and verified each claim against the code. Acted on, in the commits after the first:
pathrequired_resetForTestsleft a process listener attached to the test runnerclaimLostNativeWatchErrorcould count one error instance twiceRejected with evidence: that
require('../../utility/watcherFallback.ts')throwsMODULE_NOT_FOUND(that file already has eight such requires and the build rewrites them to.js), and that restricting the exemption to registered guarded roots would work (not implementable — the error carries no path).One reviewer suggestion was adopted and then reverted: switching to a named
watchimport binds past the seamunitTests/server/threads/watchDirFallback.test.jsuses to stubchokidar.default.watch. The property access is load-bearing and now says so.Four further delta rounds (codex graded + Gemini) covered the commits since. Fixed in response:
uncaughtExceptionlistener while classifying or marking an error — exit 7 with aTypeErrorin place of the real report, and the thread-level handlers never reached'error'routes, which call the claim outside the guardDESIGN.mdstill described the guard as coveringEPERM/ENOENTafter ENOENT was dropped from the claimed setWhat each round left open is in For the human reviewer above.
Not in scope
EntryHandleremits Windows backslash-separated entry paths — the first of the two pre-existingEntryHandler.test.jsfailures. Filed separately; the second is a cascade of the first, since the aborted test never reaches itsclose()before the fixture directory is removed.🤖 Generated with Claude Code
Review-Coverage: authored=claude; ran=codex,gemini; declined=cursor-grok,cursor-composer,domain; rounds=7 @ edbacf0
Human-Review-Need: 4 @ edbacf0