fix(files): refresh the open file when it changes on disk - #7896
fix(files): refresh the open file when it changes on disk#7896Francois3d wants to merge 5 commits into
Conversation
An open file tab kept showing whatever it read when it was opened. The SWR query atom behind projects.readFile had no refresh interval and no revalidation trigger, so once mounted nothing ever recomputed it: its staleTime meant "30s old at the next read", not "refetch every 30s". An agent editing files while you watch left the viewer stale indefinitely, and only a window reload cleared it. The server now watches the open file and pushes a change signal over a new subscribeProjectFileChanges subscription, following the existing FileSystem.watch pattern in serverSettings.ts and keybindings.ts. The client treats that signal as a query dependency, so the read re-runs outright rather than being answered from cache — staleTime only gates SWR's own background revalidation, so a change landing inside the stale window still reaches the UI. No polling, and no focus gating, which would have missed the common edits-while-focused case. The watch is on the containing directory, not the file: an atomic rename-over-temp swaps the inode and a file-level watch would follow the discarded one. The atom is shared, so web, desktop and mobile all stop serving stale contents together. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
Important Review skippedAuto reviews are disabled on this repository. Please check the settings in the CodeRabbit UI or the ⚙️ Run configurationConfiguration used: Repository UI Review profile: CHILL Plan: Pro Plus Run ID: You can disable this status message by setting the Use the checkbox below for a quick retry:
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
ApprovabilityVerdict: Skipped Macroscope did not run approvability analysis for this PR. Macroscope could not determine whether this PR modifies its approvability configuration, so the PR was not approved automatically. A PR that may change the rules that govern approval is never approved automatically. Not approved because:
|
Before / afterBoth runs are identical except for the commit under test: same fixture, same isolated environment, same scripted steps, browser window focused throughout. The only action between the two screenshots in each run is a plain Before — After — this branch. The viewer repaints on its own. How these were producedThe file tree and preview render to canvas, so the check is a pixel comparison of the preview pane rather than a DOM text assertion:
The starting hash is byte-identical across both runs, so the two are the same state diverging only on the fix. 8s is well past the 100ms server-side debounce. No motion or timing is involved in the change, so there is no video to add. |
watchFile only did the lexical containment check, so a symlink inside the workspace pointing outward — `link/secret.txt` where `link` targets an external directory — started an fs.watch on that external directory and emitted change signals about it. No contents leaked, since the event carries only a path and readFile still refuses the read, but it is a containment gap and an existence side channel. readFile already resolved both ends with realpath and compared them physically. That check is now a shared helper both paths go through, so they cannot drift apart again. Reported by Macroscope on pingdotgg#7896. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Containing the leaf meant realpath had to resolve the file itself, so a watch could not attach to a path that was momentarily absent — mid atomic-replace, or not yet created. The client turns that failure into an empty invalidate stream, so such a tab would silently lose live refresh until something remounted it. Contain the directory instead. That is what fs.watch is pointed at, so it is the thing that has to be inside the workspace, and it closes the symlink escape just as well. A symlinked leaf stays safe because the re-read still goes through readFile, which contains the file it opens. Reported by Cursor Bugbot on pingdotgg#7896. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Resolving only the parent meant a watch on an in-workspace file symlink was pointed at the alias's directory and filtered on the alias's name, so an edit made through the target's own path was missed. Prefer the canonical leaf and fall back to the parent only when the leaf does not resolve, which keeps the not-yet-created case working. Either way the directory that gets watched is proven inside the workspace, and an escaping symlink still fails rather than falling back. The added test discriminates on inotify platforms. It does not on macOS, where FSEvents reports the alias entry as touched when its target is written; the test says so rather than implying more than it proves. Reported by Cursor Bugbot on pingdotgg#7896. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes using high effort and found 1 potential issue.
❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.
Reviewed by Cursor Bugbot for commit e056ae8. Configure here.
A symlinked file can be changed from either end, and the two land in different directories. An edit through the target's own path fires beside the target; an atomic rename-over-temp save on the alias — the usual editor and git pattern — fires beside the alias. Watching only one end leaves the tab stale for saves made at the other. Watch both, deduplicated so the ordinary case collapses back to a single watcher, and debounced together so one save stays one event. Both directories are proven inside the workspace, and an escaping symlink still fails rather than quietly falling back to the alias-only watch. Neither symlink test discriminates on macOS: FSEvents coalesces to directory granularity and delivers a matching event either way. They guard the behaviour on inotify, and both say so. Reported by Cursor Bugbot on pingdotgg#7896. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
| if (canonical !== null) { | ||
| // Contained, not merely resolved: an escaping symlink fails here | ||
| // rather than quietly falling back to the alias-only watch. | ||
| const canonicalPath = yield* realPathWithinRoot(input, canonical); |
There was a problem hiding this comment.
🟡 Medium workspace/WorkspaceFileSystem.ts:366
After the watched alias is atomically replaced with a symlink to another in-workspace file, watchFile emits at most one refresh for the replacement and then misses subsequent writes to the new canonical target, leaving subscribers stale. The canonical target and watched directories are resolved only during stream construction, so rebuild the canonical watcher set after alias-directory events.
🤖 Copy this AI Prompt to have your agent fix this:
In file @apps/server/src/workspace/WorkspaceFileSystem.ts around line 366:
After the watched alias is atomically replaced with a symlink to another in-workspace file, `watchFile` emits at most one refresh for the replacement and then misses subsequent writes to the new canonical target, leaving subscribers stale. The canonical target and `watched` directories are resolved only during stream construction, so rebuild the canonical watcher set after alias-directory events.
There was a problem hiding this comment.
Real, and I am choosing not to fix it. Recording the reasoning so a maintainer can overrule me.
The watch set is resolved once when the subscription starts, so if the alias is retargeted to a different in-workspace file mid-subscription, later writes to the new target are missed. Accurate.
Coverage is not nothing: the alias's own directory is still watched, so the retarget itself emits and the client re-reads and shows the new target's contents. What is lost is subsequent writes to the new target, until the subscription restarts.
The fix is to re-resolve the watch set when the alias changes, which means tearing down and rebuilding watchers off the alias-directory event — on every save, since that is the same event an ordinary save produces. This repo takes watcher and render churn seriously, and paying a teardown/rebuild per save to cover retargeting a symlink while viewing it is a bad trade against how rare that is. It also risks dropping events during the rebuild window, which would regress the case this PR exists to fix.
If you would rather have it, the shape I would use is re-resolving only when an alias-directory event actually changes the canonical target, so the steady state stays a plain watch. Happy to add that on request — I did not want to smuggle it in on a judgement call about rarity.
There was a problem hiding this comment.
Sorry, I'm unable to act on this request because you do not have permissions within this repository.



The problem
An open file tab keeps showing whatever it read when it was opened. If the file changes on disk — an agent edits it, a script rewrites it, you run a command in T3's own terminal — the viewer stays stale until you reload the window.
The cause is the SWR query atom behind
projects.readFile(packages/client-runtime/src/state/projectCommands.ts). It hasstaleTime: 30_000andidleTtl: 5 * 60_000, but no refresh interval and no revalidation trigger.Atom.swronly evaluates staleness when the atom node recomputes, and once mounted nothing recomputes it — no timer, no focus signal, no dependency that moves when disk moves. SostaleTimemeans "30s old at the next read", not "refetch every 30s", and while the panel stays open the value is pinned indefinitely.Everything else in the path is fine: the server read is uncached, and the editor document and highlight caches are content-addressed.
Closing and reopening the tab does not help — the node is parked by
idleTtl, not disposed, so a reopen inside ~5 minutes replays the cached value with no RPC. That is why only a reload works.This is not a terminal bug. It reproduces with a plain
sedon disk while the window is focused, which is also the common agent-edits-while-you-watch case.The fix
The server watches the open file and pushes a change signal; the client treats that signal as a query dependency, so the read re-runs.
subscribeProjectFileChangessubscription (packages/contracts/src/rpc.ts), payloadProjectReadFileInput.WorkspaceFileSystem.watchFilefollows the existingFileSystem.watchpattern inapps/server/src/serverSettings.tsandapps/server/src/keybindings.ts: debounced, filtered, scoped to the subscription. Same path-escape check asreadFile.createEnvironmentQueryAtomFamilygains an optionalinvalidatestream, wired through the same dependency mechanism the existing connection-generation atom already uses.Two details worth calling out:
staleTimeis not bypassed by accident. It only gates SWR's own background revalidation. A revision bump recomputes the inner atom, which re-runs the RPC outright — so a change landing five seconds after the last read still reaches the UI. That is deliberate, and it is what makes this work without polling.The watch is on the containing directory, not the file. Atomic rename-over-temp saves (git, most editors) replace the inode, and a file-level watch would follow the discarded one.
The event is a signal, not a payload — subscribers re-read through
projects.readFile, so size limits, binary detection and error mapping stay in one place. A watcher that fails to start does not take the query down with it; the query keeps working, it just stops self-refreshing.Unsaved optimistic edits still shadow the query, so nothing clobbers in-progress work.
Why not the cheaper options
refreshIntervalMsis one line and already supported, but it is polling: an RPC per open file per interval, on every client including mobile, forever.useLiveRefreshhook adds no background traffic, but does not fix the agent-edits-while-focused case, which is the common one.Surfaces
The atom is shared, so web, desktop and mobile all stop serving stale contents together. Mobile previously papered over this with pull-to-refresh and a header Refresh action that the web panel lacks.
useT3ProjectFileScriptsreadst3.jsonthrough the same atom, so project scripts now pick up on-disk edits too.Tests
There was no staleness coverage to extend, so this adds:
WorkspaceFileSystem.test.ts—watchFilereports a plain on-disk write, reports an atomic rename-replace, and rejects paths outside the workspace root.server.test.ts— end-to-end over a real WebSocket: subscribe, edit the file on disk, receive the event, confirmprojects.readFilereturns the new contents.runtime.test.ts— a revision bump refetches inside the stale window and only then; queries without an invalidation stream still honour the stale window; revision counting ignores waiting states and reconnects.No sleeps or polling in any of them. The two watcher tests rewrite until the watcher reports rather than racing a fixed startup delay, because
fs.watchregistration is not observable.Verification
Live, against a minimal fixture (a one-line
package.jsonin a throwaway git repo), on an isolated--home-dir, with the browser window focused throughout — no click, no blur, no refresh button, no agent turn:package.jsonzebrazebraoksedon disk, window focusedcoursecourseokmvreplaceatomicatomicokBefore/after screenshots are in the first comment below, along with the pixel hashes that back them up.
Related
Likely the same defect as #7377, which is currently closed. It was auto-closed by #7490's
Fixeskeyword one second after that merge, while still carryingneeds-triage. #7490 wires the explorer's refresh button to also refresh the selected file — user-initiated only, by its own description — and does not touch the stale atom, so #7377's repro still reproduces on currentmain. Deliberately not using a closing keyword here; reopening #7377 is your call.Written by Claude Opus 5 in Claude Code.
Note
Medium Risk
Adds live directory watches and a new streaming RPC over workspace paths, including symlink/escape checks. Query invalidation also changes how all clients refetch file contents.
Overview
Open file tabs no longer stay stuck on the first
projects.readFileresult. The server now watches the file and pushes a change signal so the shared query atom refetches even inside the SWR stale window.Adds
WorkspaceFileSystem.watchFileand streaming RPCsubscribeProjectFileChanges(read scope). The watch is on the containing directory (and the symlink target when they differ) so atomic rename-over-temp saves are not missed; events are debounced and are a signal only—clients re-read throughreadFile. Escaping paths and escaping symlinks are rejected.Client query atoms gain an optional
invalidatestream (createInvalidatableQueryAtom/createStreamRevisionAtom).readFilesubscribes to the new RPC; a failed watcher does not take the query down. Unsaved optimistic edits still shadow the live contents.Reviewed by Cursor Bugbot for commit f426631. Bugbot is set up for automated code reviews on this repo. Configure here.
Note
Add
subscribeProjectFileChangesWS RPC to refresh open files on disk changesubscribeProjectFileChangesthat emitsProjectFileChangedEventwhen a workspace file changes, debounced at 100ms.WorkspaceFileSystem.watchFileresolves the target within the workspace, watches both lexical and canonical directories as needed, filters by filename, and handles atomic replaces and symlinked paths while rejecting escapes outside the root.projects.readFilequery atom is configured to invalidate on thesubscribeProjectFileChangesstream, triggering immediate refetch even inside the stale window.createStreamRevisionAtomandcreateInvalidatableQueryAtomhelpers to support stream-driven query invalidation across environment-scoped queries.readFilein WorkspaceFileSystem.ts now resolves viarealPathWithinRootto assert physical containment; symlinked paths that previously resolved but escape the workspace root will now be rejected.Macroscope summarized f426631.