Skip to content

Remove redundant work from polling watcher#3677

Merged
andrewbranch merged 3 commits intomicrosoft:mainfrom
andrewbranch:agents/improving-tsgo-watch-performance
May 1, 2026
Merged

Remove redundant work from polling watcher#3677
andrewbranch merged 3 commits intomicrosoft:mainfrom
andrewbranch:agents/improving-tsgo-watch-performance

Conversation

@andrewbranch
Copy link
Copy Markdown
Member

@andrewbranch andrewbranch commented May 1, 2026

Two issues:

  1. Directories present in the paths seen by the compiler host were being read and children hashed, but for those, we only need to stat them for existence. We only need to compute a hash of a directory's children when it's part of a tsconfig include pattern. Directories in the paths portion of the watch state are triggered by module resolution lookups, and we only need to track their existence so we can tell if, e.g., a node_modules package directory that would have had individual file lookups inside it gets created. (We no longer store those would-be file lookups as paths to check after Delete failed lookup locations #3038; checking the directory is sufficient.)
  2. hasChanges was iterating both the previous watch state baseline as well as the current set of wildcard directories, but the state baseline already includes the results of scanning the wildcard directories, so just iterating the baseline is sufficient.

On top of that, I doubled the polling interval from 1 second to 2 seconds, in line with esbuild --watch's worst-case latency.

Together, these changes bring tsgo --watch on vscode/extensions/copilot down from 20% idle CPU to 4%.

We could squeeze a little more out of polling by checking recently-changed files more often and rarely changed files less often, but we're also exploring some non-polling options that seem more promising. It's hard to accept a high-latency solution at 4% CPU that scales linearly in latency or CPU utilization with project size, when you can plausibly have a low-latency solution at 0% idle CPU that scales quite well.

Related: #3611

andrewbranch and others added 2 commits April 30, 2026 09:31
…nterval to 2s

snapshotPaths already walks the full recursive wildcard directory tree via
fs.WalkDir and stores every visited directory in watchState with a ChildrenHash
(covering both files and subdirectories). The hasChanges baseline loop then
re-hashes every entry with ChildrenHash != 0, which already detects any
addition, deletion, or rename under any watched directory.

The old hasChanges had a second fs.WalkDir pass over the same wildcard trees
that called GetAccessibleEntries on every directory a second time. This was
completely redundant: all directories are already in watchState from
snapshotPaths, and the baseline loop covers them.

Measured on vscode/extensions/copilot (7092 watched paths, 1145 dirs,
3 recursive wildcard dirs covering 1024 subdirectories) on M5 Pro / macOS:
  Before: ~350ms/scan at 1s interval ≈ 35% idle CPU
  After:  ~145ms/scan at 1s interval ≈ 14.5% idle CPU

CPU profile before fix: WalkDir cumulative = 3.65s out of 6.38s in hasChanges
over 48 seconds (57%).

Also bump the default poll interval from 1000ms to 2000ms. The extra latency
is imperceptible for a watch workflow and halves the remaining idle CPU:
  ~145ms/scan at 2s interval ≈ 7% idle CPU

Also add TSGO_WATCH_DEBUG=1 env var support (via sys.GetEnvironmentVariable)
that logs per-scan timing to the output writer for future diagnosis.

Add a regression test (TestHasChangesNoRedundantGetAccessibleEntries) that
counts GetAccessibleEntries calls through a wrapping FS adapter and asserts
each tracked directory is hashed exactly once per poll, not twice.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
watchState contains every path the compiler accessed during a build (via
trackingvfs). Many of those paths happen to be directories (DirectoryExists,
Stat, GetAccessibleEntries calls during module resolution), but for those
explicit-path entries the watcher only needs to know whether the path itself
exists and what its mtime is — it does NOT depend on what's inside.

Previously snapshotPaths stored a ChildrenHash for every directory in the
explicit paths list, causing hasChanges to readdir each one on every poll
even when nothing depended on its listing. The wildcard-tree branch already
adds the directories that DO need listing tracking, via snapshotDirEntry.

Measured on vscode/extensions/copilot (M5 Pro, macOS), poll scan time:

  Previous commit:   ~250ms (1855 dirs hashed)
  This commit:       ~165ms (1024 dirs hashed)

The 831 eliminated hashes were directories accessed during compilation
(mostly node_modules paths) whose listings were never load-bearing for
change detection.

Strengthen the regression test to assert that an explicit-paths directory
that is NOT in any wildcard tree gets only a Stat, never a readdir.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Comment thread internal/execute/watcher.go Outdated
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

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

Pull request overview

This PR reduces redundant filesystem work in the polling-based watcher to lower idle CPU usage in tsgo --watch, primarily by avoiding directory listing/hashing work when only existence/mtime tracking is needed, and by simplifying change detection to iterate a single baseline. It also adds optional scan timing diagnostics for watch-mode debugging and adjusts the default polling interval.

Changes:

  • Avoid GetAccessibleEntries/children hashing for directories that are only present due to explicit path lookups (non-wildcard).
  • Simplify hasChanges to compare against the baseline only (no second iteration over wildcard directories).
  • Add optional per-scan timing logging (gated by TSGO_WATCH_DEBUG) and increase the default watch interval to 2s.

Reviewed changes

Copilot reviewed 4 out of 4 changed files in this pull request and generated no comments.

File Description
internal/vfs/vfswatch/vfswatch.go Removes redundant directory hashing for explicit paths, simplifies change detection, and adds optional scan timing debug logging.
internal/vfs/vfswatch/vfswatch_test.go Adds a regression test ensuring GetAccessibleEntries is only called for wildcard-tree directories.
internal/execute/watcher.go Enables vfswatch scan timing logs when TSGO_WATCH_DEBUG is set.
internal/core/watchoptions.go Changes the default watch polling interval from 1s to 2s.

@andrewbranch andrewbranch added this pull request to the merge queue May 1, 2026
Merged via the queue into microsoft:main with commit 0fa4110 May 1, 2026
21 checks passed
@andrewbranch andrewbranch deleted the agents/improving-tsgo-watch-performance branch May 1, 2026 21:40
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.

4 participants