Skip to content

fix(relay): refuse recursive fs.watch on home/filesystem roots#7948

Open
alpiyev99 wants to merge 1 commit into
stablyai:mainfrom
alpiyev99:fix/refuse-broad-root-watch
Open

fix(relay): refuse recursive fs.watch on home/filesystem roots#7948
alpiyev99 wants to merge 1 commit into
stablyai:mainfrom
alpiyev99:fix/refuse-broad-root-watch

Conversation

@alpiyev99

Copy link
Copy Markdown

Summary

On a remote SSH host, adding/opening projects could freeze every workspace on the connection: the file explorer and Source Control hang, remote terminals stall, and it never recovers on its own. Root cause: the relay ends up recursively watching the account home directory, and @parcel/watcher brute-force crawls the entire tree.

This guards the relay against recursive watches on $HOME, filesystem roots, and ancestors of $HOME.

Root cause (traced live on a real host)

The worktree base-directory watcher (buildWorktreeBaseDirectoryWatchTargetsmaybeAddBaseTarget) watches each repo's workspaceRoot so it can notice worktrees created/removed outside Orca. When a user's remote repos all live directly under their home (e.g. ~/ERP, ~/Local-ai, ~/HyDE, …), workspaceRoot resolves to $HOME itself.

The relay then calls @parcel/watcher.subscribe($HOME, …). parcel is recursive and its ignore list (WATCHER_IGNORE_DIRS: node_modules, .git, dist, build, .next, .cache, target, .venv, __pycache__) does not include ~/.local. On a real dev box $HOME contains rootless container storage (~/.local/share/docker/containerd/**, ~/.local/share/containers/**) and ML model dirs — millions of files. parcel's initial crawl:

  • pins one libuv worker at ~100% CPU for 60–90+ minutes,
  • grows RSS into the multi-GB range,
  • starves every other fs RPC on the single-threaded relay,

so fs.readDir / fs.stat for all workspaces on that connection exceed the 30s request timeout and the UI freezes. It only surfaced once native deps installed successfully — before that the relay logged File watcher not available and never loaded parcel.

Captured directly from relay.log with this change deployed:

[relay] watch REFUSED (broad root): /home/next      <-- guard catches the base-dir watch
[relay] watch start: /home/next/ERP/.git            <-- normal per-worktree watches proceed
[relay] watch start: /home/next/Local-ai/.git
...

With the guard in place the relay settled at ~227 MB and idle CPU; without it, the same action pinned a core and grew to 1.4 GB.

Change

src/relay/fs-handler.ts:

  • isBroadWatchRoot(rootPath) — true for /, $HOME, and any ancestor of $HOME.
  • watch() refuses those roots before importing/subscribing parcel and returns (callers already tolerate a missing watcher — same behavior as the no-parcel path). A recursive watch on the whole home directory has no legitimate use; only the base dir's immediate entries matter, which the existing base-directory poller covers.
  • Every accepted watch root is logged (watch start: <root>) so a broad-root requester stays traceable.

src/relay/fs-handler.test.ts: fs.watch on homedir(), ~, and / resolves without subscribing, and a follow-up fs.unwatch is a no-op (not a crash).

Notes / follow-up

This is a relay-side backstop that catches any caller. The cleaner root fix is main-side: worktree-base-directory should not request a recursive watch when workspaceRoot is the home dir / a filesystem root — it should rely on worktree-base-directory-poller (a shallow one-level readdir) instead. Happy to follow up with that if preferred. One caveat of the relay-side guard alone: the main process sees the watch as "succeeded", so it won't itself switch to polling for that root — acceptable (Orca still picks up worktree changes on the next refresh), but the main-side fix would preserve live base-dir detection.

Tested: pnpm run typecheck clean; relay tests pass (43); verified live on a Linux SSH host as above.

A watch rooted at the account home makes @parcel/watcher brute-force
crawl the entire tree (container storage, ML model dirs — millions of
entries). Observed live: one libuv worker pinned for 90+ min, 1.4 GB
RSS, every fs request starved, all workspaces on the connection frozen.
This surfaced only after native deps began installing successfully (the
old relay logged "File watcher not available" and never loaded parcel).

Refuse home, filesystem roots, and ancestors of home; callers already
tolerate a missing watcher (same as the no-parcel path). Log every
accepted watch root so a broad-root requester is traceable in relay.log.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@coderabbitai

coderabbitai Bot commented Jul 9, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

📝 Walkthrough

Walkthrough

Adds a helper isBroadWatchRoot in fs-handler.ts that identifies overly-broad filesystem watch roots—empty path, root /, the user's home directory, or any ancestor of home. FsHandler.watch now checks incoming requests against this helper and refuses (returning undefined, logging a refusal message) before performing watch setup for broad roots; non-broad requests proceed as before with an added start log. Corresponding tests were added verifying watch requests for home directory, ~, and / resolve to undefined, and that invoking unwatch for those same roots without prior state is a no-op.

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Description check ⚠️ Warning The PR covers summary, testing, and follow-up, but omits required Screenshots, AI Review Report, and Security Audit sections. Add the missing template sections, including Screenshots (or 'No visual change'), AI Review Report, and Security Audit.
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title matches the main change: refusing recursive fs.watch on broad home/filesystem roots.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

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.

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

Actionable comments posted: 1

🧹 Nitpick comments (1)
src/relay/fs-handler.ts (1)

44-60: 🩺 Stability & Availability | 🔵 Trivial | ⚡ Quick win

Resolve rootPath before the broad-root check.
expandTilde() leaves .. components untouched, so inputs like /home/user/../../ can bypass this string comparison. Normalize both sides with path.resolve() before checking.


ℹ️ Review info
⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro

Run ID: 2aedf987-e072-4f0d-994c-fc1eacdfb094

📥 Commits

Reviewing files that changed from the base of the PR and between 8adfef4 and 784fdae.

📒 Files selected for processing (2)
  • src/relay/fs-handler.test.ts
  • src/relay/fs-handler.ts

Comment on lines +160 to +176
it('fs.watch refuses broad roots (home, /, ancestors of home) without subscribing', async () => {
// Why: a home-rooted recursive watch makes @parcel/watcher crawl the whole
// account tree (container storage, model dirs) and starves the relay.
await expect(
dispatcher.callRequest('fs.watch', { rootPath: homedir() })
).resolves.toBeUndefined()
await expect(dispatcher.callRequest('fs.watch', { rootPath: '~' })).resolves.toBeUndefined()
await expect(dispatcher.callRequest('fs.watch', { rootPath: '/' })).resolves.toBeUndefined()
// No watch state registered — unwatch for those roots is a no-op, not a crash.
dispatcher._notificationHandlers.get('fs.unwatch')?.(
{ rootPath: homedir() },
{
clientId: 0,
isStale: () => false
}
)
})

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.

📐 Maintainability & Code Quality | 🟠 Major | ⚡ Quick win

Test doesn't verify the refusal actually prevented subscription.

watch returns undefined in both the broad-root refusal path and the normal path, so toBeUndefined() passes even if isBroadWatchRoot is broken and @parcel/watcher is subscribed. The test should assert mockSubscribe was not called to actually verify the refusal behavior. Additionally, the ancestor-of-home branch of isBroadWatchRoot (home.startsWith(${norm}/)) is not exercised — adding a case for path.dirname(homedir()) would cover it.

🧪 Proposed fix: assert mockSubscribe not called + add ancestor test
   it('fs.watch refuses broad roots (home, /, ancestors of home) without subscribing', async () => {
     // Why: a home-rooted recursive watch makes `@parcel/watcher` crawl the whole
     // account tree (container storage, model dirs) and starves the relay.
     await expect(
       dispatcher.callRequest('fs.watch', { rootPath: homedir() })
     ).resolves.toBeUndefined()
     await expect(dispatcher.callRequest('fs.watch', { rootPath: '~' })).resolves.toBeUndefined()
     await expect(dispatcher.callRequest('fs.watch', { rootPath: '/' })).resolves.toBeUndefined()
+    // Ancestor of home (e.g. /home when home is /home/user) is just as broad.
+    await expect(
+      dispatcher.callRequest('fs.watch', { rootPath: path.dirname(homedir()) })
+    ).resolves.toBeUndefined()
+    // Verify no subscription was actually created for any refused root.
+    expect(mockSubscribe).not.toHaveBeenCalled()
     // No watch state registered — unwatch for those roots is a no-op, not a crash.
     dispatcher._notificationHandlers.get('fs.unwatch')?.(
       { rootPath: homedir() },
       {
         clientId: 0,
         isStale: () => false
       }
     )
   })

Verify that mockSubscribe is the correct mock for @parcel/watcher's subscribe:

#!/bin/bash
# Description: Verify mockSubscribe mocks `@parcel/watcher` subscribe
rg -n 'mockSubscribe|parcel.*watcher|vi\.mock' src/relay/fs-handler.test.ts -B 2 -A 5

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants