Skip to content

fix(files): refresh the open file when it changes on disk - #7896

Open
Francois3d wants to merge 5 commits into
pingdotgg:mainfrom
Francois3d:fix/stale-file-viewer
Open

fix(files): refresh the open file when it changes on disk#7896
Francois3d wants to merge 5 commits into
pingdotgg:mainfrom
Francois3d:fix/stale-file-viewer

Conversation

@Francois3d

@Francois3d Francois3d commented Aug 22, 2026

Copy link
Copy Markdown

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 has staleTime: 30_000 and idleTtl: 5 * 60_000, but no refresh interval and no revalidation trigger. Atom.swr only 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. So staleTime means "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 sed on 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.

  • New subscribeProjectFileChanges subscription (packages/contracts/src/rpc.ts), payload ProjectReadFileInput.
  • WorkspaceFileSystem.watchFile follows the existing FileSystem.watch pattern in apps/server/src/serverSettings.ts and apps/server/src/keybindings.ts: debounced, filtered, scoped to the subscription. Same path-escape check as readFile.
  • createEnvironmentQueryAtomFamily gains an optional invalidate stream, wired through the same dependency mechanism the existing connection-generation atom already uses.

Two details worth calling out:

staleTime is 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

  • refreshIntervalMs is one line and already supported, but it is polling: an RPC per open file per interval, on every client including mobile, forever.
  • Focus/visibility revalidation via the existing useLiveRefresh hook 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.

useT3ProjectFileScripts reads t3.json through 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.tswatchFile reports 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, confirm projects.readFile returns 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.watch registration is not observable.

Verification

Live, against a minimal fixture (a one-line package.json in 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:

Step Disk Viewer
Open package.json zebra zebra ok
plain sed on disk, window focused course course ok
atomic write + mv replace atomic atomic ok

Before/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 Fixes keyword one second after that merge, while still carrying needs-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 current main. 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.readFile result. 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.watchFile and streaming RPC subscribeProjectFileChanges (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 through readFile. Escaping paths and escaping symlinks are rejected.

Client query atoms gain an optional invalidate stream (createInvalidatableQueryAtom / createStreamRevisionAtom). readFile subscribes 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 subscribeProjectFileChanges WS RPC to refresh open files on disk change

  • Introduces a streaming websocket RPC subscribeProjectFileChanges that emits ProjectFileChangedEvent when a workspace file changes, debounced at 100ms.
  • WorkspaceFileSystem.watchFile resolves 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.
  • Client projects.readFile query atom is configured to invalidate on the subscribeProjectFileChanges stream, triggering immediate refetch even inside the stale window.
  • Adds createStreamRevisionAtom and createInvalidatableQueryAtom helpers to support stream-driven query invalidation across environment-scoped queries.
  • Risk: readFile in WorkspaceFileSystem.ts now resolves via realPathWithinRoot to assert physical containment; symlinked paths that previously resolved but escape the workspace root will now be rejected.

Macroscope summarized f426631.

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>
@coderabbitai

coderabbitai Bot commented Aug 22, 2026

Copy link
Copy Markdown

Important

Review skipped

Auto reviews are disabled on this repository. Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 871d1015-fa26-4fec-9937-79b2b776687f

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review

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.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@github-actions github-actions Bot added vouch:unvouched PR author is not yet trusted in the VOUCHED list. size:L 100-499 changed lines (additions + deletions). labels Aug 22, 2026
Comment thread apps/server/src/workspace/WorkspaceFileSystem.ts Outdated
@macroscopeapp

macroscopeapp Bot commented Aug 22, 2026

Copy link
Copy Markdown
Contributor

Approvability

Verdict: 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:

  • 1 blocking correctness issue found at or above your repo's Minimum Blocking Severity

@Francois3d

Copy link
Copy Markdown
Author

Before / after

Both 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 sed on disk — no click, no blur, no refresh button, no agent turn.

Before — main at 11f0513. Disk says course, the viewer still shows horse, indefinitely.

Before: the viewer still shows the old contents after the file changed on disk

After — this branch. The viewer repaints on its own.

After: the viewer shows the new contents without any interaction

How these were produced

The file tree and preview render to canvas, so the check is a pixel comparison of the preview pane rather than a DOM text assertion:

Run Pane hash before the edit Pane hash 8s after Repainted
main f6225a18c069 f6225a18c069 no
this branch f6225a18c069 e4fca85f4bf1 yes

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>
Comment thread apps/server/src/workspace/WorkspaceFileSystem.ts Outdated
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>
Comment thread apps/server/src/workspace/WorkspaceFileSystem.ts Outdated
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>

@cursor cursor Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes using high effort and found 1 potential issue.

Fix All in Cursor

❌ 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.

Comment thread apps/server/src/workspace/WorkspaceFileSystem.ts Outdated
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);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 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.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sorry, I'm unable to act on this request because you do not have permissions within this repository.

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

Labels

size:L 100-499 changed lines (additions + deletions). vouch:unvouched PR author is not yet trusted in the VOUCHED list.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant