Skip to content

Commit 0fa4110

Browse files
Remove redundant work from polling watcher (#3677)
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent 25963e4 commit 0fa4110

4 files changed

Lines changed: 126 additions & 53 deletions

File tree

internal/core/watchoptions.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -45,7 +45,7 @@ const (
4545
)
4646

4747
func (w *WatchOptions) WatchInterval() time.Duration {
48-
watchInterval := 1000 * time.Millisecond
48+
watchInterval := 2000 * time.Millisecond
4949
if w != nil && w.Interval != nil {
5050
watchInterval = time.Duration(*w.Interval) * time.Millisecond
5151
}

internal/execute/watcher.go

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -115,6 +115,10 @@ func (w *Watcher) start() {
115115
w.configFilePaths = append([]string{w.configFileName}, w.config.ExtendedSourceFiles()...)
116116
}
117117

118+
if w.sys.GetEnvironmentVariable("TS_WATCH_DEBUG") != "" {
119+
w.fileWatcher.SetDebugLog(w.sys.Writer())
120+
}
121+
118122
w.reportWatchStatus(ast.NewCompilerDiagnostic(diagnostics.Starting_compilation_in_watch_mode))
119123
w.doBuild()
120124
w.mu.Unlock()

internal/vfs/vfswatch/vfswatch.go

Lines changed: 57 additions & 52 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,8 @@
33
package vfswatch
44

55
import (
6+
"fmt"
7+
"io"
68
"slices"
79
"sync"
810
"time"
@@ -27,6 +29,7 @@ type FileWatcher struct {
2729
watchState map[string]WatchEntry
2830
wildcardDirectories map[string]bool
2931
mu sync.Mutex
32+
debugLog io.Writer // nil = silent; non-nil = write timing lines here
3033
}
3134

3235
func NewFileWatcher(fs vfs.FS, pollInterval time.Duration, testing bool, callback func()) *FileWatcher {
@@ -38,6 +41,14 @@ func NewFileWatcher(fs vfs.FS, pollInterval time.Duration, testing bool, callbac
3841
}
3942
}
4043

44+
// SetDebugLog enables per-scan timing output written to w.
45+
// Pass nil to disable. Safe to call at any time.
46+
func (fw *FileWatcher) SetDebugLog(w io.Writer) {
47+
fw.mu.Lock()
48+
defer fw.mu.Unlock()
49+
fw.debugLog = w
50+
}
51+
4152
func (fw *FileWatcher) SetPollInterval(d time.Duration) {
4253
fw.mu.Lock()
4354
defer fw.mu.Unlock()
@@ -70,15 +81,14 @@ func (fw *FileWatcher) WaitForSettled(now func() time.Time) {
7081
return
7182
}
7283
fw.mu.Lock()
73-
wildcardDirs := fw.wildcardDirectories
7484
pollInterval := fw.pollInterval
7585
fw.mu.Unlock()
7686
current := fw.currentState()
7787
settledAt := now()
7888
tick := min(pollInterval, debounceWait)
7989
for now().Sub(settledAt) < debounceWait {
8090
time.Sleep(tick)
81-
if fw.hasChanges(current, wildcardDirs) {
91+
if fw.hasChanges(current) {
8292
current = fw.currentState()
8393
settledAt = now()
8494
}
@@ -118,12 +128,7 @@ func snapshotPaths(fs vfs.FS, paths []string, wildcardDirs map[string]bool) map[
118128
state := make(map[string]WatchEntry, len(paths))
119129
for _, fn := range paths {
120130
if s := fs.Stat(fn); s != nil {
121-
entry := WatchEntry{ModTime: s.ModTime(), Exists: true}
122-
if s.IsDir() {
123-
entries := fs.GetAccessibleEntries(fn)
124-
entry.ChildrenHash = hashEntries(entries)
125-
}
126-
state[fn] = entry
131+
state[fn] = WatchEntry{ModTime: s.ModTime(), Exists: true}
127132
} else {
128133
state[fn] = WatchEntry{Exists: false}
129134
}
@@ -176,21 +181,26 @@ func hashEntries(entries vfs.Entries) uint64 {
176181
return h.Sum64()
177182
}
178183

179-
func dirChanged(fs vfs.FS, baseline map[string]WatchEntry, dir string) bool {
180-
entry, ok := baseline[dir]
181-
if !ok {
182-
return true
183-
}
184-
if entry.ChildrenHash != 0 {
185-
entries := fs.GetAccessibleEntries(dir)
186-
if hashEntries(entries) != entry.ChildrenHash {
187-
return true
188-
}
189-
}
190-
return false
191-
}
192-
193-
func (fw *FileWatcher) hasChanges(baseline map[string]WatchEntry, wildcardDirs map[string]bool) bool {
184+
// hasChanges compares the current filesystem state against baseline.
185+
//
186+
// Tracked entries fall into two categories:
187+
//
188+
// - Explicit paths (files the compiler depends on, plus directory paths
189+
// accessed via DirectoryExists/Stat/etc. during compilation). For these
190+
// we only need to know whether the path exists and, if it does, whether
191+
// its mtime has changed. We never depend on *what's inside* a directory
192+
// in this category — any specific file we care about is tracked
193+
// independently in this same map.
194+
//
195+
// - Wildcard tree directories. snapshotPaths walks every directory under
196+
// each recursive wildcard root and stores it with a ChildrenHash that
197+
// covers the directory's listing. Re-hashing here detects any new,
198+
// deleted, or renamed file or subdirectory in those trees.
199+
//
200+
// Iterating baseline once therefore covers both: a single fs.Stat per entry,
201+
// plus a fs.GetAccessibleEntries only for entries with ChildrenHash != 0
202+
// (i.e. wildcard tree members).
203+
func (fw *FileWatcher) hasChanges(baseline map[string]WatchEntry) bool {
194204
for path, old := range baseline {
195205
s := fw.fs.Stat(path)
196206
if !old.Exists {
@@ -209,52 +219,47 @@ func (fw *FileWatcher) hasChanges(baseline map[string]WatchEntry, wildcardDirs m
209219
}
210220
}
211221
}
212-
for dir, recursive := range wildcardDirs {
213-
if !recursive {
214-
if dirChanged(fw.fs, baseline, dir) {
215-
return true
216-
}
217-
continue
218-
}
219-
found := false
220-
_ = fw.fs.WalkDir(dir, func(path string, d vfs.DirEntry, err error) error {
221-
if err != nil || !d.IsDir() {
222-
return nil
223-
}
224-
if dirChanged(fw.fs, baseline, path) {
225-
found = true
226-
return vfs.SkipAll
227-
}
228-
return nil
229-
})
230-
if found {
231-
return true
232-
}
233-
}
234222
return false
235223
}
236224

237225
// HasChangesFromWatchState compares the current filesystem against the
238-
// stored watch state. Safe for concurrent use: watchState and
239-
// wildcardDirectories are snapshotted under lock; the maps themselves
240-
// are never mutated after creation (UpdateWatchState replaces them).
226+
// stored watch state. Safe for concurrent use: watchState is snapshotted
227+
// under lock; the map itself is never mutated after creation
228+
// (UpdateWatchState replaces it).
241229
func (fw *FileWatcher) HasChangesFromWatchState() bool {
242230
fw.mu.Lock()
243231
ws := fw.watchState
244-
wildcardDirs := fw.wildcardDirectories
245232
fw.mu.Unlock()
246-
return fw.hasChanges(ws, wildcardDirs)
233+
return fw.hasChanges(ws)
247234
}
248235

249236
func (fw *FileWatcher) Run(now func() time.Time) {
250237
for {
251238
fw.mu.Lock()
252239
interval := fw.pollInterval
253240
ws := fw.watchState
254-
wildcardDirs := fw.wildcardDirectories
241+
log := fw.debugLog
255242
fw.mu.Unlock()
256243
time.Sleep(interval)
257-
if ws == nil || fw.hasChanges(ws, wildcardDirs) {
244+
start := now()
245+
changed := ws == nil || fw.hasChanges(ws)
246+
if log != nil {
247+
elapsed := now().Sub(start)
248+
files, dirs, missing := 0, 0, 0
249+
for _, e := range ws {
250+
switch {
251+
case !e.Exists:
252+
missing++
253+
case e.ChildrenHash != 0:
254+
dirs++
255+
default:
256+
files++
257+
}
258+
}
259+
fmt.Fprintf(log, "[vfswatch] scan: %d paths (%d files, %d dirs, %d missing), %.1fms, changed=%v\n",
260+
len(ws), files, dirs, missing, float64(elapsed.Microseconds())/1000.0, changed)
261+
}
262+
if changed {
258263
fw.WaitForSettled(now)
259264
fw.callback()
260265
}
Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
package vfswatch_test
2+
3+
import (
4+
"sync/atomic"
5+
"testing"
6+
"time"
7+
8+
"github.com/microsoft/typescript-go/internal/vfs"
9+
"github.com/microsoft/typescript-go/internal/vfs/vfstest"
10+
"github.com/microsoft/typescript-go/internal/vfs/vfswatch"
11+
)
12+
13+
// countingFS wraps a vfs.FS and counts calls to GetAccessibleEntries.
14+
type countingFS struct {
15+
vfs.FS
16+
n atomic.Int64
17+
}
18+
19+
func (c *countingFS) GetAccessibleEntries(path string) vfs.Entries {
20+
c.n.Add(1)
21+
return c.FS.GetAccessibleEntries(path)
22+
}
23+
24+
// TestHasChangesNoRedundantGetAccessibleEntries verifies that
25+
// HasChangesFromWatchState calls GetAccessibleEntries only for directories
26+
// that are part of a recursive wildcard tree, and exactly once each — never
27+
// for directories that merely happened to be in the explicit paths list.
28+
//
29+
// Setup: /src is a recursive wildcard root containing /src and /src/sub.
30+
// The explicit paths list also contains /node_modules (a directory accessed
31+
// during compilation but with no wildcard scope) and the tsconfig file.
32+
// A single HasChangesFromWatchState call should call GetAccessibleEntries
33+
// exactly twice: once for /src and once for /src/sub. /node_modules is a
34+
// directory but is *not* a wildcard tree member, so it gets only a Stat —
35+
// the watcher does not depend on its listing.
36+
func TestHasChangesNoRedundantGetAccessibleEntries(t *testing.T) {
37+
t.Parallel()
38+
39+
inner := vfstest.FromMap(map[string]string{
40+
"/src/a.ts": "const a = 1;",
41+
"/src/b.ts": "const b = 2;",
42+
"/src/sub/c.ts": "const c = 3;",
43+
"/node_modules/x.js": "",
44+
"/tsconfig.json": "{}",
45+
}, true)
46+
cfs := &countingFS{FS: inner}
47+
48+
fw := vfswatch.NewFileWatcher(cfs, 10*time.Millisecond, true, func() {})
49+
fw.UpdateWatchState(
50+
[]string{"/src/a.ts", "/src/b.ts", "/src/sub/c.ts", "/node_modules", "/tsconfig.json"},
51+
map[string]bool{"/src": true},
52+
)
53+
54+
cfs.n.Store(0) // reset counter after baseline snapshot
55+
56+
fw.HasChangesFromWatchState()
57+
58+
// Only /src and /src/sub (the wildcard tree) are tracked with ChildrenHash.
59+
// /node_modules is a directory in the explicit paths list but should NOT be
60+
// hashed: its listing is not something the watcher depends on.
61+
if got := cfs.n.Load(); got != 2 {
62+
t.Errorf("GetAccessibleEntries called %d times, want 2 (once per wildcard-tree dir)", got)
63+
}
64+
}

0 commit comments

Comments
 (0)