Repository navigation
fix(widgets): re-arm a dev session on a changed folder id and bound persistent stat errors - #614
Merged
Merged
Conversation
…ersistent stat errors A dev folder that is still a directory but reports another device or file id (made again by a build, or a FUSE or network drive that does not keep ids) is now watched again and built, instead of stopping the session as folder-gone. Only a path that is gone or no longer a directory stops it. A folder that keeps failing to be looked at for a reason other than not found (EPERM, EBUSY) now stops the session as watch-failed after 30 seconds of continuous failures, and the node's log names the error and says what it ran keeps running. Refs #546, #610
…ough a link A folder found under a new file id is watched anew only while its path still resolves to the canonical path watching started from and is not itself a link; a link or junction swapped in at the folder or above it now stops the session as folder-gone instead of building a tree nobody chose. Re-arms are logged with the old and new ids and capped at 30 in 60 s, a re-arm queues the catch-up build a new watcher needs, a look that re-arms no longer builds twice, and the unreadable bound uses a monotonic clock. A folder that is there but cannot be read is refused as ROOT_UNREADABLE and resumes as watch-failed. The watch-failed copy says why, and the macOS tests stat the canonical root the engine watches.
# Conflicts: # apps/runtime/test/widget-dev-sessions.spec.ts # packages/core/test/widget-dev-engine.spec.ts
A watched folder that is missing now counts as gone only after 2 s; a folder back at the same real path within that time is watched anew, so a build in another process that deletes and rewrites its output folder keeps the session live. Nothing is built while the folder is missing. Only back-to-back id changes count toward the re-arm cap, the real path is checked just before and after the files are copied, real paths are compared without case on Windows and macOS, and a folder whose real path cannot be read is refused as ROOT_UNREADABLE.
… recreate during a build The dev engine now resolves its root to the real path once, when it starts, so a package folder that is itself a symlink or junction builds instead of failing every build with FILES_LINK_REFUSED. rootGone() honours the missing-folder grace while watching, so a build or rebuild that overlaps a folder being made again fails on the missing files without stopping the session, and the folder is built once it is back. A real path that differs only in case counts as the same place only when no folder on the path is a link.
Contributor
Author
|
Review attestation: ready to merge at A push to this PR makes this attestation stale; the new head needs its own review. |
mrgoonie
enabled auto-merge (squash)
October 8, 2026 02:33
mrgoonie
added a commit
that referenced
this pull request
Oct 8, 2026
…e folder-only change events (#644) * fix(widgets): hold builds while a dev folder is made again, and ignore folder-only change events A build that would start while a watched dev folder is missing within the grace, or one that fails because the folder went or came back while it ran, is now held instead of reported as failed. The folder is built once when it is back, and a rebuild asked for meanwhile answers with that build; if the folder does not return within the grace, the session stops as folder-gone as before and a waiting rebuild settles with why nothing was built. The watcher no longer builds on a change event that names a folder. Windows reports one when a build first lists a folder made a moment ago (its last-access time is set), which made an extra unchanged build follow each re-arm. Files added, removed or saved are still reported under their own names. Refs #638, #611, #614 * fix(core): report no unchanged build for writes a new watcher reports late After a folder made again is watched anew, macOS FSEvents can report the writes that made it. For a short window after the re-arm build is reported, a change build is reported only when it is news; a real save still builds and is reported. The child-process test now waits for the folder to be gone before recreating it.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Fixes #611. Follows #610 (#546). Refs #496. Consistent with #612 (#538): a swapped link never widens what a session may use. Official docs: digitopvn/clarkcant-web#137.
What changes
A folder that is still there under a new id is watched again, not stopped.
packages/core/src/widget-dev-engine.tstells a replaced folder apart from a gone one.
devRootStatereturns a fourth state,replaced: the path is still adirectory, but its device or file id differs from the one recorded when watching started. On
replacedthe engine:dev:inoto stderr;changebuild through the same debounce as a saved change, reported throughonBuild;DEV_ENGINE_WATCH_CATCH_UP_MS), so a save made before the newwatcher is live (FSEvents starts asynchronously) is still built. It reports only news.
A folder a build deletes and makes again keeps the session live. The old watcher reports the deletion straight
away, before the build tool has made the folder again, so a missing folder (
ENOENT,ENOTDIR) counts as gone onlyafter
DEV_ENGINE_ROOT_MISSING_GRACE_MS(2 s,rootMissingGraceMs).replacedand is built.gonestops at once: something other than a folder, or a path reached through a link.rootGone()honours the same grace (both share onemissingSince). The runtime asks it beforefollowing every build, so a build already running when the folder went, or a rebuild asked for during the grace,
fails on the missing files and leaves the session live. The folder is built again once it is back. Without
watching (
watch: false),rootGone()stays strict.This covers
rm -rf out && buildandrmdir /s /q out && xcopy src outwhen the folder is back within 2 s, plus FUSEand network drives that give a folder that still exists a new id. A build that takes longer than 2 s to make the folder
again still stops the session as
folder-gone, and the docs say so.A link or junction swap is not a replaced folder.
realpathSync.native), and watches and buildsthat path. A folder given through a link or junction is not taken for a swap: for example,
clark widget devon asymlinked
~/Projectsor a junctioned workspace.engine.rootis that real path.replacedlook counts asgonewhenlstatshows the path is a symlink or junction, or when its realpath nolonger equals the recorded one. That covers the dev folder itself and any parent swapped for a link.
link (
lstaton each folder up to the root). So a folder made again asOutforoutre-arms. A parent swappedfor a link to a sibling named in another case, on a case-sensitive volume, does not.
build()checks the realpath just before reading the folder and again after copying the files. It fails withFILES_LINK_REFUSEDif the path now leads elsewhere, which narrows the window for a swap to the copy itself.Closing that window fully would need reads relative to a held folder handle.
Re-arms are bounded.
DEV_ENGINE_REARM_MAX(30)replacedlooks in a row stop watching throughonWatchError, with amessage naming the last old and new ids. The runtime turns that into
watch-failed.does.
No duplicate build. A debounce or catch-up callback whose
rootStillThere()look re-armed the folder returns early,because the re-arm already scheduled its own build.
A persistent non-ENOENT stat error is bounded. After
DEV_ENGINE_ROOT_UNREADABLE_MS(30 s) of continuous failures,measured with
performance.now(), the engine stops watching and callsonWatchErrorwith a message that names theerror code. The runtime turns that into
stopReason: "watch-failed".Runtime (
apps/runtime/src/application/widget-dev-sessions.ts):checkRootrefuses a folder with403 ROOT_UNREADABLE, "… cannot be read on this node (EPERM)", instead of "doesnot exist", when its
stator itsrealpathfails with anything other thanENOENT/ENOTDIR.watch-failed, notfolder-goneorroot-refused. The resume log line names therefusal message.
rootUnreadableMspass-through option for tests.Person-visible copy.
shell.dev.stopReason.watch-failed(EN and VI) now says why watching failed: the systemstopped reporting changes, or the folder could not be read for 30 seconds. The status line already starts with
"No longer watching the folder · build N keeps running". Clark's
develop_widgettext says the same.Contracts: the
watch-failedandfolder-goneJSDoc inpackages/contracts/src/widget-dev-session.tsisupdated. The enum is unchanged.
Docs:
docs/open-interfaces.mdanddocs/open-interfaces.vi.mddocument:403 ROOT_UNREADABLE.The stray blank line in the refusal list is gone, and the odd line wraps in both files are fixed.
Official docs:
403 ROOT_UNREADABLEis added to the widget dev session refusals indocs/api.htmlandvi/docs/api.htmlin digitopvn/clarkcant-web#137, which is open and not merged.Review items
rootGone()ignoring the grace: it now honours it while watching. This was the cause of the localpnpm verifyfailures of the cross-process runtime test.
m2 (back-to-back re-arm cap), n1 (case-insensitive realpath compare on Windows and macOS), n2 (blank line in the
refusal list, EN and VI), n3 (
ROOT_UNREADABLEwhenrealpathfails withEPERM).unchangedbuild after a re-arm on Windows. It is probably last-access notifications from thenew watcher, it costs one digest, and it is not a cheap fix.
node:fsmock coversstatSynconly.Tests
All tests use real
fs.watchwatchers on real temp folders. The link tests use real junctions on Windows and symlinkselsewhere.
builds generation 3.
onRootGone, generation 2from the new files, still watching, and a later save builds generation 3.
change:unchangedbuild, andlater saves still build.
onRootGoneonce, nothing built,rootGone()true, the latest generation is still the chosen package.rebuild()→ fails withFILES_LINK_REFUSED, andnothing from the other tree is built.
DEV_ENGINE_REARM_MAXlooks in a row, with a message.Outforout→ re-armed and built, not gone.watch: falseand no cache (theclark widget devshape) → generation 1,
engine.rootis the real path, and a save builds generation 2.rebuild()→ the build fails,rootGone()is false and it is stillwatched; once the grace is over,
onRootGonefires once androotGone()is true.EPERMstops watching within the bound; shorter failures restart the bound.live, and generation 2 is installed.
while no folder is at the path → the rebuild answers
livewith a failedlastBuild, and generation 2 is installedonce the folder is back.
folder-gone, and generation 1 of the chosen packagestays active.
EPERMfolder → a start is refused403 ROOT_UNREADABLE"cannot be read on this node (EPERM)", and aresume stops as
watch-failed.EPERMwatch tests set the failing path torealpathSync.native(root), the canonical path the enginestats (the macOS fix).
watch-failedline says why it failed and that build N keeps running.Results on Windows 11, Node 24, after merging
main(which now includes #612, #622, #624 and #631):f2e3d9ee; engine reverted, specs kept)rootGone(), and the runtime rebuild-during-recreate teste1067a85)widget-dev-engine.spec.ts+widget-dev-sessions.spec.ts+widget-dev-status.spec.ts, 3 runspnpm typecheckpnpm invariantspnpm verify, 2 runsOverlap
main, andmainis merged here.main's comments and import lines.rmSyncin either spec passesmaxRetries.removeTestDirectory, is kept.pruneis async, and both call sites inwidget-dev-sessions.tsareawait prune(sessionId).