From 81c92321d6b0c947f1a6f7d46b7f16cad9238898 Mon Sep 17 00:00:00 2001 From: Jake Bailey <5341706+jakebailey@users.noreply.github.com> Date: Fri, 19 Jun 2026 14:49:17 -0700 Subject: [PATCH 1/3] Fix watch coalescing for FSEvents Coalesce dense directory watch sets into recursive ancestor watches on fast-recursive backends such as FSEvents and Windows. This avoids opening one stream per dependency directory while still surfacing startup failures. --- internal/execute/build/orchestrator.go | 2 +- .../execute/tsctests/mock_watch_backend.go | 11 ++ internal/execute/tsctests/runner.go | 18 +- internal/execute/tsctests/sys.go | 2 + internal/execute/tsctests/tscwatch_test.go | 34 ++++ internal/execute/watcher.go | 2 +- internal/execute/watchmanager/watchbackend.go | 5 + internal/execute/watchmanager/watchmanager.go | 101 +++++++++++ ...esces-many-dependency-directory-watches.js | 163 ++++++++++++++++++ 9 files changed, 328 insertions(+), 10 deletions(-) create mode 100644 testdata/baselines/reference/tscWatch/commandLineWatch/watch-coalesces-many-dependency-directory-watches.js diff --git a/internal/execute/build/orchestrator.go b/internal/execute/build/orchestrator.go index 6afdf077022..4a6c4fd91f0 100644 --- a/internal/execute/build/orchestrator.go +++ b/internal/execute/build/orchestrator.go @@ -453,7 +453,7 @@ func (o *Orchestrator) computeDesiredWatches() map[string]bool { } } - return o.wm.ResolveDesiredDirs(desiredDirs) + return o.wm.CoalesceDesiredDirs(o.wm.ResolveDesiredDirs(desiredDirs), o.comparePathsOptions) } func (o *Orchestrator) DoCycle() { diff --git a/internal/execute/tsctests/mock_watch_backend.go b/internal/execute/tsctests/mock_watch_backend.go index eca507a0772..ae91a205af0 100644 --- a/internal/execute/tsctests/mock_watch_backend.go +++ b/internal/execute/tsctests/mock_watch_backend.go @@ -22,6 +22,8 @@ type MockWatchBackend struct { mu sync.Mutex Dirs map[string]*MockWatch DirectoryExists func(string) bool // if set, WatchDirectory fails for non-existent dirs + Fail func(dir string, recursive bool) error + FastRecursive bool } var _ watchmanager.WatchBackend = (*MockWatchBackend)(nil) @@ -60,11 +62,20 @@ func (m *MockWatchBackend) WatchDirectory(dir string, fn fswatch.WatchCallback, if m.DirectoryExists != nil && !m.DirectoryExists(dir) { return nil, fmt.Errorf("directory does not exist: %s", dir) } + if m.Fail != nil { + if err := m.Fail(dir, recursive); err != nil { + return nil, err + } + } w := &MockWatch{Path: dir, Callback: fn, Recursive: recursive, Ignore: ignore} m.Dirs[dir] = w return w, nil } +func (m *MockWatchBackend) HasFastRecursiveBackend() bool { + return m.FastRecursive +} + // SendEvents routes events through the registered watch callbacks // that match each event's path. Directory watches match if the event // path is a child (or recursive descendant) of the watched directory. diff --git a/internal/execute/tsctests/runner.go b/internal/execute/tsctests/runner.go index 38c7e9c2089..3f798c6e046 100644 --- a/internal/execute/tsctests/runner.go +++ b/internal/execute/tsctests/runner.go @@ -31,14 +31,16 @@ var noChangeOnlyEdit = []*tscEdit{ } type tscInput struct { - subScenario string - commandLineArgs []string - files FileMap - cwd string - edits []*tscEdit - env map[string]string - ignoreCase bool - windowsStyleRoot string + subScenario string + commandLineArgs []string + files FileMap + cwd string + edits []*tscEdit + env map[string]string + ignoreCase bool + windowsStyleRoot string + watchBackendFail func(dir string, recursive bool) error + watchBackendFastRecursive bool } func (test *tscInput) executeCommand(sys *TestSys, baselineBuilder *strings.Builder, commandLineArgs []string) tsc.CommandLineResult { diff --git a/internal/execute/tsctests/sys.go b/internal/execute/tsctests/sys.go index 2d75a36d4ca..32d539f864e 100644 --- a/internal/execute/tsctests/sys.go +++ b/internal/execute/tsctests/sys.go @@ -135,6 +135,8 @@ func newTestSys(tscInput *tscInput, forIncrementalCorrectness bool) *TestSys { sys.forIncrementalCorrectness = forIncrementalCorrectness sys.mockWatchBackend = NewMockWatchBackend() sys.mockWatchBackend.DirectoryExists = sys.fs.FS.DirectoryExists + sys.mockWatchBackend.Fail = tscInput.watchBackendFail + sys.mockWatchBackend.FastRecursive = tscInput.watchBackendFastRecursive sys.fsDiffer = &fsbaselineutil.FSDiffer{ FS: sys.fs.FS.(iovfs.FsWithSys), DefaultLibs: func() *collections.SyncSet[string] { return sys.fs.defaultLibs }, diff --git a/internal/execute/tsctests/tscwatch_test.go b/internal/execute/tsctests/tscwatch_test.go index 7650fcea7e9..c2f8fd7e933 100644 --- a/internal/execute/tsctests/tscwatch_test.go +++ b/internal/execute/tsctests/tscwatch_test.go @@ -1,6 +1,8 @@ package tsctests import ( + "errors" + "fmt" "strings" "testing" @@ -9,6 +11,37 @@ import ( func TestWatch(t *testing.T) { t.Parallel() + bunCoalescingTest := func() *tscInput { + files := FileMap{} + var index strings.Builder + var fileNames strings.Builder + fileNames.WriteString(`"index.ts"`) + for i := range 12 { + name := fmt.Sprintf("pkg%d", i) + value := fmt.Sprintf("value%d", i) + index.WriteString(fmt.Sprintf(`import { %[1]s } from "./node_modules/.bun/%[2]s/index"; %[1]s;`, value, name)) + index.WriteString("\n") + files["/home/src/workspaces/project/node_modules/.bun/"+name+"/index.ts"] = fmt.Sprintf("export const %s = %d;", value, i) + fileNames.WriteString(fmt.Sprintf(`, "node_modules/.bun/%s/index.ts"`, name)) + } + files["/home/src/workspaces/project/index.ts"] = index.String() + files["/home/src/workspaces/project/tsconfig.json"] = fmt.Sprintf(`{ + "compilerOptions": {}, + "files": [%s] +}`, fileNames.String()) + return &tscInput{ + subScenario: "watch coalesces many dependency directory watches", + files: files, + commandLineArgs: []string{"--watch"}, + watchBackendFail: func(dir string, recursive bool) error { + if strings.Contains(dir, "/node_modules/.bun/pkg") { + return errors.New("error starting FSEvents stream") + } + return nil + }, + watchBackendFastRecursive: true, + } + } testCases := []*tscInput{ { subScenario: "watch with no tsconfig", @@ -25,6 +58,7 @@ func TestWatch(t *testing.T) { }, commandLineArgs: []string{"--watch", "--incremental"}, }, + bunCoalescingTest(), { subScenario: "watch skips build when no files change", files: FileMap{ diff --git a/internal/execute/watcher.go b/internal/execute/watcher.go index 9b11c2a4dd4..cafe205cd3d 100644 --- a/internal/execute/watcher.go +++ b/internal/execute/watcher.go @@ -195,7 +195,7 @@ func (w *Watcher) computeDesiredWatches(seenFilePaths []string) map[string]bool } // Re-resolve in case newly added dirs don't exist - return w.wm.ResolveDesiredDirs(resolvedDirs) + return w.wm.CoalesceDesiredDirs(w.wm.ResolveDesiredDirs(resolvedDirs), opts) } func (w *Watcher) reconcileWatches(seenFilePaths []string) error { diff --git a/internal/execute/watchmanager/watchbackend.go b/internal/execute/watchmanager/watchbackend.go index 64551574cb7..63d5da40b84 100644 --- a/internal/execute/watchmanager/watchbackend.go +++ b/internal/execute/watchmanager/watchbackend.go @@ -11,6 +11,7 @@ import ( // WatchBackend abstracts fswatch.Watcher for testing type WatchBackend interface { WatchDirectory(dir string, fn fswatch.WatchCallback, recursive bool, ignore func(string) bool) (io.Closer, error) + HasFastRecursiveBackend() bool } // CommandLineTestingWithWatchBackend is an optional extension of @@ -32,6 +33,10 @@ func (b *FSWatchBackend) WatchDirectory(dir string, fn fswatch.WatchCallback, re return b.Inner.WatchDirectory(dir, fn, opts...) } +func (b *FSWatchBackend) HasFastRecursiveBackend() bool { + return b.Inner.HasFastRecursiveBackend() +} + func ShouldIgnoreWatchPath(path string) bool { p := tspath.NormalizeSlashes(path) return strings.HasSuffix(p, "/.git") || diff --git a/internal/execute/watchmanager/watchmanager.go b/internal/execute/watchmanager/watchmanager.go index b5fe9a89bab..5cdcfdcc64c 100644 --- a/internal/execute/watchmanager/watchmanager.go +++ b/internal/execute/watchmanager/watchmanager.go @@ -5,6 +5,8 @@ import ( "errors" "fmt" "io" + "maps" + "slices" "sync" "github.com/microsoft/typescript-go/internal/core" @@ -42,6 +44,8 @@ type WatchManager struct { changedOverflow bool } +const recursiveCoalesceThreshold = 10 + func NewWatchManager(warnWriter io.Writer, dirExists func(string) bool) *WatchManager { return &WatchManager{ watchedDirs: make(map[string]*watchedDir), @@ -224,6 +228,103 @@ func (wm *WatchManager) ResolveDesiredDirs(desiredDirs map[string]bool) map[stri return resolved } +func (wm *WatchManager) CoalesceDesiredDirs(desiredDirs map[string]bool, opts tspath.ComparePathsOptions) map[string]bool { + if wm.backend == nil || !wm.backend.HasFastRecursiveBackend() || len(desiredDirs) < recursiveCoalesceThreshold { + return desiredDirs + } + + pruned := removeDirsCoveredByRecursiveWatches(desiredDirs, opts) + coalesced := coalesceAncestorDirs(pruned, opts) + if len(coalesced) == len(desiredDirs) { + return desiredDirs + } + return coalesced +} + +func removeDirsCoveredByRecursiveWatches(desiredDirs map[string]bool, opts tspath.ComparePathsOptions) map[string]bool { + result := make(map[string]bool, len(desiredDirs)) + for dir, recursive := range desiredDirs { + covered := false + for parent, parentRecursive := range desiredDirs { + if parent == dir || !parentRecursive { + continue + } + if tspath.ContainsPath(parent, dir, opts) { + covered = true + break + } + } + if !covered { + result[dir] = recursive + } + } + return result +} + +func coalesceAncestorDirs(desiredDirs map[string]bool, opts tspath.ComparePathsOptions) map[string]bool { + type candidate struct { + dir string + depth int + } + counts := make(map[string]int) + for dir := range desiredDirs { + parent := tspath.GetDirectoryPath(dir) + for parent != "" && parent != dir { + if CanWatchDirectory(parent) { + counts[parent]++ + } + next := tspath.GetDirectoryPath(parent) + if next == parent { + break + } + parent = next + } + } + + candidates := make([]candidate, 0, len(counts)) + for dir, count := range counts { + if count >= recursiveCoalesceThreshold { + candidates = append(candidates, candidate{dir: dir, depth: len(tspath.GetPathComponents(dir, opts.CurrentDirectory))}) + } + } + slices.SortFunc(candidates, func(a, b candidate) int { + if a.depth != b.depth { + return b.depth - a.depth + } + return opts.GetComparer()(a.dir, b.dir) + }) + + remaining := make(map[string]bool, len(desiredDirs)) + maps.Copy(remaining, desiredDirs) + selected := make(map[string]struct{}) + for _, candidate := range candidates { + var covered []string + for dir := range remaining { + if tspath.ContainsPath(candidate.dir, dir, opts) { + covered = append(covered, dir) + } + } + if len(covered) < recursiveCoalesceThreshold { + continue + } + selected[candidate.dir] = struct{}{} + for _, dir := range covered { + delete(remaining, dir) + } + } + + if len(selected) == 0 { + return desiredDirs + } + + result := make(map[string]bool, len(remaining)+len(selected)) + maps.Copy(result, remaining) + for dir := range selected { + result[dir] = true + } + return result +} + func (wm *WatchManager) ReconcileWatches(desiredDirs map[string]bool) error { if wm.backend == nil { return nil diff --git a/testdata/baselines/reference/tscWatch/commandLineWatch/watch-coalesces-many-dependency-directory-watches.js b/testdata/baselines/reference/tscWatch/commandLineWatch/watch-coalesces-many-dependency-directory-watches.js new file mode 100644 index 00000000000..5c06ce22afb --- /dev/null +++ b/testdata/baselines/reference/tscWatch/commandLineWatch/watch-coalesces-many-dependency-directory-watches.js @@ -0,0 +1,163 @@ +currentDirectory::/home/src/workspaces/project +useCaseSensitiveFileNames::true +Input:: +//// [/home/src/workspaces/project/index.ts] *new* +import { value0 } from "./node_modules/.bun/pkg0/index"; value0; +import { value1 } from "./node_modules/.bun/pkg1/index"; value1; +import { value2 } from "./node_modules/.bun/pkg2/index"; value2; +import { value3 } from "./node_modules/.bun/pkg3/index"; value3; +import { value4 } from "./node_modules/.bun/pkg4/index"; value4; +import { value5 } from "./node_modules/.bun/pkg5/index"; value5; +import { value6 } from "./node_modules/.bun/pkg6/index"; value6; +import { value7 } from "./node_modules/.bun/pkg7/index"; value7; +import { value8 } from "./node_modules/.bun/pkg8/index"; value8; +import { value9 } from "./node_modules/.bun/pkg9/index"; value9; +import { value10 } from "./node_modules/.bun/pkg10/index"; value10; +import { value11 } from "./node_modules/.bun/pkg11/index"; value11; + +//// [/home/src/workspaces/project/node_modules/.bun/pkg0/index.ts] *new* +export const value0 = 0; +//// [/home/src/workspaces/project/node_modules/.bun/pkg1/index.ts] *new* +export const value1 = 1; +//// [/home/src/workspaces/project/node_modules/.bun/pkg10/index.ts] *new* +export const value10 = 10; +//// [/home/src/workspaces/project/node_modules/.bun/pkg11/index.ts] *new* +export const value11 = 11; +//// [/home/src/workspaces/project/node_modules/.bun/pkg2/index.ts] *new* +export const value2 = 2; +//// [/home/src/workspaces/project/node_modules/.bun/pkg3/index.ts] *new* +export const value3 = 3; +//// [/home/src/workspaces/project/node_modules/.bun/pkg4/index.ts] *new* +export const value4 = 4; +//// [/home/src/workspaces/project/node_modules/.bun/pkg5/index.ts] *new* +export const value5 = 5; +//// [/home/src/workspaces/project/node_modules/.bun/pkg6/index.ts] *new* +export const value6 = 6; +//// [/home/src/workspaces/project/node_modules/.bun/pkg7/index.ts] *new* +export const value7 = 7; +//// [/home/src/workspaces/project/node_modules/.bun/pkg8/index.ts] *new* +export const value8 = 8; +//// [/home/src/workspaces/project/node_modules/.bun/pkg9/index.ts] *new* +export const value9 = 9; +//// [/home/src/workspaces/project/tsconfig.json] *new* +{ + "compilerOptions": {}, + "files": ["index.ts", "node_modules/.bun/pkg0/index.ts", "node_modules/.bun/pkg1/index.ts", "node_modules/.bun/pkg2/index.ts", "node_modules/.bun/pkg3/index.ts", "node_modules/.bun/pkg4/index.ts", "node_modules/.bun/pkg5/index.ts", "node_modules/.bun/pkg6/index.ts", "node_modules/.bun/pkg7/index.ts", "node_modules/.bun/pkg8/index.ts", "node_modules/.bun/pkg9/index.ts", "node_modules/.bun/pkg10/index.ts", "node_modules/.bun/pkg11/index.ts"] +} + +tsgo --watch +ExitStatus:: Success +Output:: +[HH:MM:SS AM] Starting compilation in watch mode... + +[HH:MM:SS AM] Found 0 errors. Watching for file changes. + +//// [/home/src/tslibs/TS/Lib/lib.es2025.full.d.ts] *Lib* +/// +interface Boolean {} +interface Function {} +interface CallableFunction {} +interface NewableFunction {} +interface IArguments {} +interface Number { toExponential: any; } +interface Object {} +interface RegExp {} +interface String { charAt: any; } +interface Array { length: number; [n: number]: T; } +interface ReadonlyArray {} +interface SymbolConstructor { + (desc?: string | number): symbol; + for(name: string): symbol; + readonly toStringTag: symbol; +} +declare var Symbol: SymbolConstructor; +interface Symbol { + readonly [Symbol.toStringTag]: string; +} +declare const console: { log(msg: any): void; }; +//// [/home/src/workspaces/project/index.js] *new* +import { value0 } from "./node_modules/.bun/pkg0/index"; +value0; +import { value1 } from "./node_modules/.bun/pkg1/index"; +value1; +import { value2 } from "./node_modules/.bun/pkg2/index"; +value2; +import { value3 } from "./node_modules/.bun/pkg3/index"; +value3; +import { value4 } from "./node_modules/.bun/pkg4/index"; +value4; +import { value5 } from "./node_modules/.bun/pkg5/index"; +value5; +import { value6 } from "./node_modules/.bun/pkg6/index"; +value6; +import { value7 } from "./node_modules/.bun/pkg7/index"; +value7; +import { value8 } from "./node_modules/.bun/pkg8/index"; +value8; +import { value9 } from "./node_modules/.bun/pkg9/index"; +value9; +import { value10 } from "./node_modules/.bun/pkg10/index"; +value10; +import { value11 } from "./node_modules/.bun/pkg11/index"; +value11; + +//// [/home/src/workspaces/project/node_modules/.bun/pkg0/index.js] *new* +export const value0 = 0; + +//// [/home/src/workspaces/project/node_modules/.bun/pkg1/index.js] *new* +export const value1 = 1; + +//// [/home/src/workspaces/project/node_modules/.bun/pkg10/index.js] *new* +export const value10 = 10; + +//// [/home/src/workspaces/project/node_modules/.bun/pkg11/index.js] *new* +export const value11 = 11; + +//// [/home/src/workspaces/project/node_modules/.bun/pkg2/index.js] *new* +export const value2 = 2; + +//// [/home/src/workspaces/project/node_modules/.bun/pkg3/index.js] *new* +export const value3 = 3; + +//// [/home/src/workspaces/project/node_modules/.bun/pkg4/index.js] *new* +export const value4 = 4; + +//// [/home/src/workspaces/project/node_modules/.bun/pkg5/index.js] *new* +export const value5 = 5; + +//// [/home/src/workspaces/project/node_modules/.bun/pkg6/index.js] *new* +export const value6 = 6; + +//// [/home/src/workspaces/project/node_modules/.bun/pkg7/index.js] *new* +export const value7 = 7; + +//// [/home/src/workspaces/project/node_modules/.bun/pkg8/index.js] *new* +export const value8 = 8; + +//// [/home/src/workspaces/project/node_modules/.bun/pkg9/index.js] *new* +export const value9 = 9; + + +Watch Registrations:: +Directory watches:: + /home/src/tslibs/TS/Lib + /home/src/workspaces/project + /home/src/workspaces/project/node_modules + /home/src/workspaces/project/node_modules/.bun (recursive) +tsconfig.json:: +SemanticDiagnostics:: +*refresh* /home/src/tslibs/TS/Lib/lib.es2025.full.d.ts +*refresh* /home/src/workspaces/project/node_modules/.bun/pkg0/index.ts +*refresh* /home/src/workspaces/project/node_modules/.bun/pkg1/index.ts +*refresh* /home/src/workspaces/project/node_modules/.bun/pkg2/index.ts +*refresh* /home/src/workspaces/project/node_modules/.bun/pkg3/index.ts +*refresh* /home/src/workspaces/project/node_modules/.bun/pkg4/index.ts +*refresh* /home/src/workspaces/project/node_modules/.bun/pkg5/index.ts +*refresh* /home/src/workspaces/project/node_modules/.bun/pkg6/index.ts +*refresh* /home/src/workspaces/project/node_modules/.bun/pkg7/index.ts +*refresh* /home/src/workspaces/project/node_modules/.bun/pkg8/index.ts +*refresh* /home/src/workspaces/project/node_modules/.bun/pkg9/index.ts +*refresh* /home/src/workspaces/project/node_modules/.bun/pkg10/index.ts +*refresh* /home/src/workspaces/project/node_modules/.bun/pkg11/index.ts +*refresh* /home/src/workspaces/project/index.ts +Signatures:: From 479af3765283c6c577f49ffa0947b37950569a7f Mon Sep 17 00:00:00 2001 From: Jake Bailey <5341706+jakebailey@users.noreply.github.com> Date: Fri, 19 Jun 2026 15:01:03 -0700 Subject: [PATCH 2/3] Move watch coalescing into fswatch Consolidate dense directory subscriptions inside fswatch for fast recursive backends. Parent streams now serve later child subscriptions with per-subscriber filtering, so all fswatch callers benefit without watch-manager-specific planning. --- internal/execute/build/orchestrator.go | 2 +- .../execute/tsctests/mock_watch_backend.go | 11 -- internal/execute/tsctests/runner.go | 18 +- internal/execute/tsctests/sys.go | 2 - internal/execute/tsctests/tscwatch_test.go | 34 ---- internal/execute/watcher.go | 2 +- internal/execute/watchmanager/watchbackend.go | 5 - internal/execute/watchmanager/watchmanager.go | 101 ----------- internal/fswatch/watcher.go | 106 ++++++++++-- internal/fswatch/watcher_test.go | 102 +++++++++++ ...esces-many-dependency-directory-watches.js | 163 ------------------ 11 files changed, 202 insertions(+), 344 deletions(-) delete mode 100644 testdata/baselines/reference/tscWatch/commandLineWatch/watch-coalesces-many-dependency-directory-watches.js diff --git a/internal/execute/build/orchestrator.go b/internal/execute/build/orchestrator.go index 4a6c4fd91f0..6afdf077022 100644 --- a/internal/execute/build/orchestrator.go +++ b/internal/execute/build/orchestrator.go @@ -453,7 +453,7 @@ func (o *Orchestrator) computeDesiredWatches() map[string]bool { } } - return o.wm.CoalesceDesiredDirs(o.wm.ResolveDesiredDirs(desiredDirs), o.comparePathsOptions) + return o.wm.ResolveDesiredDirs(desiredDirs) } func (o *Orchestrator) DoCycle() { diff --git a/internal/execute/tsctests/mock_watch_backend.go b/internal/execute/tsctests/mock_watch_backend.go index ae91a205af0..eca507a0772 100644 --- a/internal/execute/tsctests/mock_watch_backend.go +++ b/internal/execute/tsctests/mock_watch_backend.go @@ -22,8 +22,6 @@ type MockWatchBackend struct { mu sync.Mutex Dirs map[string]*MockWatch DirectoryExists func(string) bool // if set, WatchDirectory fails for non-existent dirs - Fail func(dir string, recursive bool) error - FastRecursive bool } var _ watchmanager.WatchBackend = (*MockWatchBackend)(nil) @@ -62,20 +60,11 @@ func (m *MockWatchBackend) WatchDirectory(dir string, fn fswatch.WatchCallback, if m.DirectoryExists != nil && !m.DirectoryExists(dir) { return nil, fmt.Errorf("directory does not exist: %s", dir) } - if m.Fail != nil { - if err := m.Fail(dir, recursive); err != nil { - return nil, err - } - } w := &MockWatch{Path: dir, Callback: fn, Recursive: recursive, Ignore: ignore} m.Dirs[dir] = w return w, nil } -func (m *MockWatchBackend) HasFastRecursiveBackend() bool { - return m.FastRecursive -} - // SendEvents routes events through the registered watch callbacks // that match each event's path. Directory watches match if the event // path is a child (or recursive descendant) of the watched directory. diff --git a/internal/execute/tsctests/runner.go b/internal/execute/tsctests/runner.go index 3f798c6e046..38c7e9c2089 100644 --- a/internal/execute/tsctests/runner.go +++ b/internal/execute/tsctests/runner.go @@ -31,16 +31,14 @@ var noChangeOnlyEdit = []*tscEdit{ } type tscInput struct { - subScenario string - commandLineArgs []string - files FileMap - cwd string - edits []*tscEdit - env map[string]string - ignoreCase bool - windowsStyleRoot string - watchBackendFail func(dir string, recursive bool) error - watchBackendFastRecursive bool + subScenario string + commandLineArgs []string + files FileMap + cwd string + edits []*tscEdit + env map[string]string + ignoreCase bool + windowsStyleRoot string } func (test *tscInput) executeCommand(sys *TestSys, baselineBuilder *strings.Builder, commandLineArgs []string) tsc.CommandLineResult { diff --git a/internal/execute/tsctests/sys.go b/internal/execute/tsctests/sys.go index 32d539f864e..2d75a36d4ca 100644 --- a/internal/execute/tsctests/sys.go +++ b/internal/execute/tsctests/sys.go @@ -135,8 +135,6 @@ func newTestSys(tscInput *tscInput, forIncrementalCorrectness bool) *TestSys { sys.forIncrementalCorrectness = forIncrementalCorrectness sys.mockWatchBackend = NewMockWatchBackend() sys.mockWatchBackend.DirectoryExists = sys.fs.FS.DirectoryExists - sys.mockWatchBackend.Fail = tscInput.watchBackendFail - sys.mockWatchBackend.FastRecursive = tscInput.watchBackendFastRecursive sys.fsDiffer = &fsbaselineutil.FSDiffer{ FS: sys.fs.FS.(iovfs.FsWithSys), DefaultLibs: func() *collections.SyncSet[string] { return sys.fs.defaultLibs }, diff --git a/internal/execute/tsctests/tscwatch_test.go b/internal/execute/tsctests/tscwatch_test.go index c2f8fd7e933..7650fcea7e9 100644 --- a/internal/execute/tsctests/tscwatch_test.go +++ b/internal/execute/tsctests/tscwatch_test.go @@ -1,8 +1,6 @@ package tsctests import ( - "errors" - "fmt" "strings" "testing" @@ -11,37 +9,6 @@ import ( func TestWatch(t *testing.T) { t.Parallel() - bunCoalescingTest := func() *tscInput { - files := FileMap{} - var index strings.Builder - var fileNames strings.Builder - fileNames.WriteString(`"index.ts"`) - for i := range 12 { - name := fmt.Sprintf("pkg%d", i) - value := fmt.Sprintf("value%d", i) - index.WriteString(fmt.Sprintf(`import { %[1]s } from "./node_modules/.bun/%[2]s/index"; %[1]s;`, value, name)) - index.WriteString("\n") - files["/home/src/workspaces/project/node_modules/.bun/"+name+"/index.ts"] = fmt.Sprintf("export const %s = %d;", value, i) - fileNames.WriteString(fmt.Sprintf(`, "node_modules/.bun/%s/index.ts"`, name)) - } - files["/home/src/workspaces/project/index.ts"] = index.String() - files["/home/src/workspaces/project/tsconfig.json"] = fmt.Sprintf(`{ - "compilerOptions": {}, - "files": [%s] -}`, fileNames.String()) - return &tscInput{ - subScenario: "watch coalesces many dependency directory watches", - files: files, - commandLineArgs: []string{"--watch"}, - watchBackendFail: func(dir string, recursive bool) error { - if strings.Contains(dir, "/node_modules/.bun/pkg") { - return errors.New("error starting FSEvents stream") - } - return nil - }, - watchBackendFastRecursive: true, - } - } testCases := []*tscInput{ { subScenario: "watch with no tsconfig", @@ -58,7 +25,6 @@ func TestWatch(t *testing.T) { }, commandLineArgs: []string{"--watch", "--incremental"}, }, - bunCoalescingTest(), { subScenario: "watch skips build when no files change", files: FileMap{ diff --git a/internal/execute/watcher.go b/internal/execute/watcher.go index cafe205cd3d..9b11c2a4dd4 100644 --- a/internal/execute/watcher.go +++ b/internal/execute/watcher.go @@ -195,7 +195,7 @@ func (w *Watcher) computeDesiredWatches(seenFilePaths []string) map[string]bool } // Re-resolve in case newly added dirs don't exist - return w.wm.CoalesceDesiredDirs(w.wm.ResolveDesiredDirs(resolvedDirs), opts) + return w.wm.ResolveDesiredDirs(resolvedDirs) } func (w *Watcher) reconcileWatches(seenFilePaths []string) error { diff --git a/internal/execute/watchmanager/watchbackend.go b/internal/execute/watchmanager/watchbackend.go index 63d5da40b84..64551574cb7 100644 --- a/internal/execute/watchmanager/watchbackend.go +++ b/internal/execute/watchmanager/watchbackend.go @@ -11,7 +11,6 @@ import ( // WatchBackend abstracts fswatch.Watcher for testing type WatchBackend interface { WatchDirectory(dir string, fn fswatch.WatchCallback, recursive bool, ignore func(string) bool) (io.Closer, error) - HasFastRecursiveBackend() bool } // CommandLineTestingWithWatchBackend is an optional extension of @@ -33,10 +32,6 @@ func (b *FSWatchBackend) WatchDirectory(dir string, fn fswatch.WatchCallback, re return b.Inner.WatchDirectory(dir, fn, opts...) } -func (b *FSWatchBackend) HasFastRecursiveBackend() bool { - return b.Inner.HasFastRecursiveBackend() -} - func ShouldIgnoreWatchPath(path string) bool { p := tspath.NormalizeSlashes(path) return strings.HasSuffix(p, "/.git") || diff --git a/internal/execute/watchmanager/watchmanager.go b/internal/execute/watchmanager/watchmanager.go index 5cdcfdcc64c..b5fe9a89bab 100644 --- a/internal/execute/watchmanager/watchmanager.go +++ b/internal/execute/watchmanager/watchmanager.go @@ -5,8 +5,6 @@ import ( "errors" "fmt" "io" - "maps" - "slices" "sync" "github.com/microsoft/typescript-go/internal/core" @@ -44,8 +42,6 @@ type WatchManager struct { changedOverflow bool } -const recursiveCoalesceThreshold = 10 - func NewWatchManager(warnWriter io.Writer, dirExists func(string) bool) *WatchManager { return &WatchManager{ watchedDirs: make(map[string]*watchedDir), @@ -228,103 +224,6 @@ func (wm *WatchManager) ResolveDesiredDirs(desiredDirs map[string]bool) map[stri return resolved } -func (wm *WatchManager) CoalesceDesiredDirs(desiredDirs map[string]bool, opts tspath.ComparePathsOptions) map[string]bool { - if wm.backend == nil || !wm.backend.HasFastRecursiveBackend() || len(desiredDirs) < recursiveCoalesceThreshold { - return desiredDirs - } - - pruned := removeDirsCoveredByRecursiveWatches(desiredDirs, opts) - coalesced := coalesceAncestorDirs(pruned, opts) - if len(coalesced) == len(desiredDirs) { - return desiredDirs - } - return coalesced -} - -func removeDirsCoveredByRecursiveWatches(desiredDirs map[string]bool, opts tspath.ComparePathsOptions) map[string]bool { - result := make(map[string]bool, len(desiredDirs)) - for dir, recursive := range desiredDirs { - covered := false - for parent, parentRecursive := range desiredDirs { - if parent == dir || !parentRecursive { - continue - } - if tspath.ContainsPath(parent, dir, opts) { - covered = true - break - } - } - if !covered { - result[dir] = recursive - } - } - return result -} - -func coalesceAncestorDirs(desiredDirs map[string]bool, opts tspath.ComparePathsOptions) map[string]bool { - type candidate struct { - dir string - depth int - } - counts := make(map[string]int) - for dir := range desiredDirs { - parent := tspath.GetDirectoryPath(dir) - for parent != "" && parent != dir { - if CanWatchDirectory(parent) { - counts[parent]++ - } - next := tspath.GetDirectoryPath(parent) - if next == parent { - break - } - parent = next - } - } - - candidates := make([]candidate, 0, len(counts)) - for dir, count := range counts { - if count >= recursiveCoalesceThreshold { - candidates = append(candidates, candidate{dir: dir, depth: len(tspath.GetPathComponents(dir, opts.CurrentDirectory))}) - } - } - slices.SortFunc(candidates, func(a, b candidate) int { - if a.depth != b.depth { - return b.depth - a.depth - } - return opts.GetComparer()(a.dir, b.dir) - }) - - remaining := make(map[string]bool, len(desiredDirs)) - maps.Copy(remaining, desiredDirs) - selected := make(map[string]struct{}) - for _, candidate := range candidates { - var covered []string - for dir := range remaining { - if tspath.ContainsPath(candidate.dir, dir, opts) { - covered = append(covered, dir) - } - } - if len(covered) < recursiveCoalesceThreshold { - continue - } - selected[candidate.dir] = struct{}{} - for _, dir := range covered { - delete(remaining, dir) - } - } - - if len(selected) == 0 { - return desiredDirs - } - - result := make(map[string]bool, len(remaining)+len(selected)) - maps.Copy(result, remaining) - for dir := range selected { - result[dir] = true - } - return result -} - func (wm *WatchManager) ReconcileWatches(desiredDirs map[string]bool) error { if wm.backend == nil { return nil diff --git a/internal/fswatch/watcher.go b/internal/fswatch/watcher.go index e17d5012904..7b394a26af3 100644 --- a/internal/fswatch/watcher.go +++ b/internal/fswatch/watcher.go @@ -214,6 +214,8 @@ type watcher struct { debounce *debounce // lazily created in getOrCreateDirWatch } +const recursiveConsolidateThreshold = 10 + func (w *watcher) Name() string { return w.name } func (w *watcher) String() string { return w.name } func (w *watcher) Available() bool { return w.factory != nil } @@ -259,6 +261,54 @@ func (w *watcher) getImpl() (watcherImpl, error) { return impl, nil } +func (w *watcher) keyForDirWatch(dir string, recursive bool) string { + if recursive { + return dir + "\x00recursive" + } + return dir +} + +func (w *watcher) findCoveringRecursiveWatchLocked(dir string) *dirWatch { + var best *dirWatch + for _, dw := range w.dirWatches { + if !dw.recursive || !isInDirectoryOrSelf(dw.dir, dir) { + continue + } + if best == nil || len(dw.dir) > len(best.dir) { + best = dw + } + } + return best +} + +func (w *watcher) findConsolidationDirLocked(dir string) string { + if !w.HasFastRecursiveBackend() { + return "" + } + parent := filepath.Dir(dir) + for parent != dir && parent != "." { + if filepath.Dir(parent) == parent { + break + } + count := 1 + for _, dw := range w.dirWatches { + if isInDirectoryOrSelf(parent, dw.dir) { + count++ + } + } + if count >= recursiveConsolidateThreshold { + return parent + } + next := filepath.Dir(parent) + if next == parent { + break + } + dir = parent + parent = next + } + return "" +} + func (w *watcher) getOrCreateDirWatch(dir string, recursive bool) *dirWatch { w.mu.Lock() defer w.mu.Unlock() @@ -268,10 +318,21 @@ func (w *watcher) getOrCreateDirWatch(dir string, recursive bool) *dirWatch { if w.debounce == nil { w.debounce = newDebounce() } - key := dir - if recursive { - key = dir + "\x00recursive" + + if w.HasFastRecursiveBackend() { + if dw := w.findCoveringRecursiveWatchLocked(dir); dw != nil { + return dw + } + if consolidationDir := w.findConsolidationDirLocked(dir); consolidationDir != "" { + dir = consolidationDir + recursive = true + if dw := w.findCoveringRecursiveWatchLocked(dir); dw != nil { + return dw + } + } } + + key := w.keyForDirWatch(dir, recursive) if dw, ok := w.dirWatches[key]; ok { return dw } @@ -284,10 +345,7 @@ func (w *watcher) getOrCreateDirWatch(dir string, recursive bool) *dirWatch { func (w *watcher) removeDirWatch(dw *dirWatch) { w.mu.Lock() defer w.mu.Unlock() - key := dw.dir - if dw.recursive { - key = dw.dir + "\x00recursive" - } + key := w.keyForDirWatch(dw.dir, dw.recursive) if existing, ok := w.dirWatches[key]; ok && existing == dw { delete(w.dirWatches, key) dw.destroyDebounce() @@ -313,7 +371,7 @@ func (w *watcher) WatchDirectory(dir string, fn WatchCallback, opts ...WatchOpti } dw := w.getOrCreateDirWatch(dir, sopts.recursive) - id, _ := dw.watch(fn, sopts.ignore) + id, _ := dw.watch(dir, sopts.recursive, fn, sopts.ignore) impl, err := w.getImpl() if err != nil { @@ -504,9 +562,11 @@ func (b *watcherBase) handleWatcherError(werr *dirWatchError) { // ----- dirWatch: per-directory watch state ------------------------- type callback struct { - id uint64 - fn WatchCallback - ignore func(path string) bool + id uint64 + dir string + recursive bool + fn WatchCallback + ignore func(path string) bool } // dirWatchError associates an error with a specific directory watch. @@ -584,18 +644,21 @@ func (dw *dirWatch) triggerCallbacks() { } events, err := dw.events.drain() cbs := slices.Clone(dw.callbacks) - recursive := dw.recursive dw.mu.Unlock() for _, cb := range cbs { cbEvents := events - if cb.ignore != nil || !recursive { + if cb.ignore != nil || !cb.recursive || cb.dir != dw.dir { filtered := make([]Event, 0, len(events)) for _, e := range events { if cb.ignore != nil && cb.ignore(e.Path) { continue } - if !recursive && !isDirectChild(dw.dir, e.Path) { + if cb.recursive { + if !isInDirectoryOrSelf(cb.dir, e.Path) { + continue + } + } else if !isDirectChild(cb.dir, e.Path) { continue } filtered = append(filtered, e) @@ -608,6 +671,17 @@ func (dw *dirWatch) triggerCallbacks() { } } +func isInDirectoryOrSelf(dir, path string) bool { + if path == dir { + return true + } + if !strings.HasPrefix(path, dir) { + return false + } + rest := path[len(dir):] + return len(rest) > 0 && (rest[0] == '/' || rest[0] == filepath.Separator) +} + // isDirectChild reports whether path is an immediate child of dir. // Both paths must be absolute. Returns false for path == dir. func isDirectChild(dir, path string) bool { @@ -625,12 +699,12 @@ func isDirectChild(dir, path string) bool { return len(rest) > 0 && !strings.ContainsRune(rest, '/') && !strings.ContainsRune(rest, filepath.Separator) } -func (dw *dirWatch) watch(fn WatchCallback, ignore func(path string) bool) (uint64, bool) { +func (dw *dirWatch) watch(dir string, recursive bool, fn WatchCallback, ignore func(path string) bool) (uint64, bool) { dw.mu.Lock() defer dw.mu.Unlock() dw.nextCBID++ id := dw.nextCBID - dw.callbacks = append(dw.callbacks, callback{id: id, fn: fn, ignore: ignore}) + dw.callbacks = append(dw.callbacks, callback{id: id, dir: dir, recursive: recursive, fn: fn, ignore: ignore}) return id, true } diff --git a/internal/fswatch/watcher_test.go b/internal/fswatch/watcher_test.go index 7fe53c65e74..64faff47cf3 100644 --- a/internal/fswatch/watcher_test.go +++ b/internal/fswatch/watcher_test.go @@ -1178,6 +1178,108 @@ func TestSubscribeMultipleDifferentDirs(t *testing.T) { }) } +type countingWatcherImpl struct { + watcherBase + subscribed []*dirWatch + closed []*dirWatch +} + +func newCountingWatcherImpl() *countingWatcherImpl { + impl := &countingWatcherImpl{} + impl.watcherBase.init(impl) + return impl +} + +func (b *countingWatcherImpl) start() error { + b.notifyStarted() + return nil +} + +func (b *countingWatcherImpl) subscribe(w *dirWatch) error { + b.subscribed = append(b.subscribed, w) + return nil +} + +func (b *countingWatcherImpl) closeWatch(w *dirWatch) error { + b.closed = append(b.closed, w) + return nil +} + +func TestFastRecursiveWatcherConsolidatesSiblingDirectories(t *testing.T) { + t.Parallel() + + root := t.TempDir() + parent := filepath.Join(root, "node_modules", ".bun") + if err := os.MkdirAll(parent, 0o755); err != nil { + t.Fatal(err) + } + + var impl *countingWatcherImpl + watcherImpl := &watcher{ + name: "fsevents", + factory: func() watcherImpl { + impl = newCountingWatcherImpl() + return impl + }, + } + + var subs []Watch + for i := range recursiveConsolidateThreshold + 2 { + dir := filepath.Join(parent, fmt.Sprintf("pkg%d", i)) + if err := os.MkdirAll(dir, 0o755); err != nil { + t.Fatal(err) + } + sub, err := watcherImpl.WatchDirectory(dir, func([]Event, error) {}) + if err != nil { + t.Fatal(err) + } + subs = append(subs, sub) + } + t.Cleanup(func() { + for _, sub := range subs { + _ = sub.Close() + } + }) + + if got := len(impl.subscribed); got != recursiveConsolidateThreshold { + t.Fatalf("expected %d subscriptions after consolidation, got %d", recursiveConsolidateThreshold, got) + } + consolidated := impl.subscribed[len(impl.subscribed)-1] + if consolidated.dir != parent || !consolidated.recursive { + t.Fatalf("expected consolidated recursive watch on %s, got dir=%s recursive=%v", parent, consolidated.dir, consolidated.recursive) + } + + watcherImpl.mu.Lock() + _, hasPkgWatch := watcherImpl.dirWatches[watcherImpl.keyForDirWatch(filepath.Join(parent, "pkg11"), false)] + watcherImpl.mu.Unlock() + if hasPkgWatch { + t.Fatal("expected later package watch to reuse consolidated parent instead of creating its own stream") + } +} + +func TestConsolidatedChildWatchFiltersAgainstRequestedDir(t *testing.T) { + t.Parallel() + + parent := filepath.Join(t.TempDir(), "parent") + child := filepath.Join(parent, "child") + sibling := filepath.Join(parent, "sibling") + dw := newDirectWatcher(t, parent) + + var got []Event + dw.watch(child, false, func(events []Event, err error) { + if err != nil { + t.Fatal(err) + } + got = append(got, events...) + }, nil) + dw.events.update(filepath.Join(child, "file.ts")) + dw.events.update(filepath.Join(child, "nested", "file.ts")) + dw.events.update(filepath.Join(sibling, "file.ts")) + dw.triggerCallbacks() + + assertEventSequence(t, got, []wantEvent{{EventUpdate, filepath.Join(child, "file.ts")}}) +} + // ----- errors ------------------------------------------------------------ func TestSubscribeMissingDirError(t *testing.T) { diff --git a/testdata/baselines/reference/tscWatch/commandLineWatch/watch-coalesces-many-dependency-directory-watches.js b/testdata/baselines/reference/tscWatch/commandLineWatch/watch-coalesces-many-dependency-directory-watches.js deleted file mode 100644 index 5c06ce22afb..00000000000 --- a/testdata/baselines/reference/tscWatch/commandLineWatch/watch-coalesces-many-dependency-directory-watches.js +++ /dev/null @@ -1,163 +0,0 @@ -currentDirectory::/home/src/workspaces/project -useCaseSensitiveFileNames::true -Input:: -//// [/home/src/workspaces/project/index.ts] *new* -import { value0 } from "./node_modules/.bun/pkg0/index"; value0; -import { value1 } from "./node_modules/.bun/pkg1/index"; value1; -import { value2 } from "./node_modules/.bun/pkg2/index"; value2; -import { value3 } from "./node_modules/.bun/pkg3/index"; value3; -import { value4 } from "./node_modules/.bun/pkg4/index"; value4; -import { value5 } from "./node_modules/.bun/pkg5/index"; value5; -import { value6 } from "./node_modules/.bun/pkg6/index"; value6; -import { value7 } from "./node_modules/.bun/pkg7/index"; value7; -import { value8 } from "./node_modules/.bun/pkg8/index"; value8; -import { value9 } from "./node_modules/.bun/pkg9/index"; value9; -import { value10 } from "./node_modules/.bun/pkg10/index"; value10; -import { value11 } from "./node_modules/.bun/pkg11/index"; value11; - -//// [/home/src/workspaces/project/node_modules/.bun/pkg0/index.ts] *new* -export const value0 = 0; -//// [/home/src/workspaces/project/node_modules/.bun/pkg1/index.ts] *new* -export const value1 = 1; -//// [/home/src/workspaces/project/node_modules/.bun/pkg10/index.ts] *new* -export const value10 = 10; -//// [/home/src/workspaces/project/node_modules/.bun/pkg11/index.ts] *new* -export const value11 = 11; -//// [/home/src/workspaces/project/node_modules/.bun/pkg2/index.ts] *new* -export const value2 = 2; -//// [/home/src/workspaces/project/node_modules/.bun/pkg3/index.ts] *new* -export const value3 = 3; -//// [/home/src/workspaces/project/node_modules/.bun/pkg4/index.ts] *new* -export const value4 = 4; -//// [/home/src/workspaces/project/node_modules/.bun/pkg5/index.ts] *new* -export const value5 = 5; -//// [/home/src/workspaces/project/node_modules/.bun/pkg6/index.ts] *new* -export const value6 = 6; -//// [/home/src/workspaces/project/node_modules/.bun/pkg7/index.ts] *new* -export const value7 = 7; -//// [/home/src/workspaces/project/node_modules/.bun/pkg8/index.ts] *new* -export const value8 = 8; -//// [/home/src/workspaces/project/node_modules/.bun/pkg9/index.ts] *new* -export const value9 = 9; -//// [/home/src/workspaces/project/tsconfig.json] *new* -{ - "compilerOptions": {}, - "files": ["index.ts", "node_modules/.bun/pkg0/index.ts", "node_modules/.bun/pkg1/index.ts", "node_modules/.bun/pkg2/index.ts", "node_modules/.bun/pkg3/index.ts", "node_modules/.bun/pkg4/index.ts", "node_modules/.bun/pkg5/index.ts", "node_modules/.bun/pkg6/index.ts", "node_modules/.bun/pkg7/index.ts", "node_modules/.bun/pkg8/index.ts", "node_modules/.bun/pkg9/index.ts", "node_modules/.bun/pkg10/index.ts", "node_modules/.bun/pkg11/index.ts"] -} - -tsgo --watch -ExitStatus:: Success -Output:: -[HH:MM:SS AM] Starting compilation in watch mode... - -[HH:MM:SS AM] Found 0 errors. Watching for file changes. - -//// [/home/src/tslibs/TS/Lib/lib.es2025.full.d.ts] *Lib* -/// -interface Boolean {} -interface Function {} -interface CallableFunction {} -interface NewableFunction {} -interface IArguments {} -interface Number { toExponential: any; } -interface Object {} -interface RegExp {} -interface String { charAt: any; } -interface Array { length: number; [n: number]: T; } -interface ReadonlyArray {} -interface SymbolConstructor { - (desc?: string | number): symbol; - for(name: string): symbol; - readonly toStringTag: symbol; -} -declare var Symbol: SymbolConstructor; -interface Symbol { - readonly [Symbol.toStringTag]: string; -} -declare const console: { log(msg: any): void; }; -//// [/home/src/workspaces/project/index.js] *new* -import { value0 } from "./node_modules/.bun/pkg0/index"; -value0; -import { value1 } from "./node_modules/.bun/pkg1/index"; -value1; -import { value2 } from "./node_modules/.bun/pkg2/index"; -value2; -import { value3 } from "./node_modules/.bun/pkg3/index"; -value3; -import { value4 } from "./node_modules/.bun/pkg4/index"; -value4; -import { value5 } from "./node_modules/.bun/pkg5/index"; -value5; -import { value6 } from "./node_modules/.bun/pkg6/index"; -value6; -import { value7 } from "./node_modules/.bun/pkg7/index"; -value7; -import { value8 } from "./node_modules/.bun/pkg8/index"; -value8; -import { value9 } from "./node_modules/.bun/pkg9/index"; -value9; -import { value10 } from "./node_modules/.bun/pkg10/index"; -value10; -import { value11 } from "./node_modules/.bun/pkg11/index"; -value11; - -//// [/home/src/workspaces/project/node_modules/.bun/pkg0/index.js] *new* -export const value0 = 0; - -//// [/home/src/workspaces/project/node_modules/.bun/pkg1/index.js] *new* -export const value1 = 1; - -//// [/home/src/workspaces/project/node_modules/.bun/pkg10/index.js] *new* -export const value10 = 10; - -//// [/home/src/workspaces/project/node_modules/.bun/pkg11/index.js] *new* -export const value11 = 11; - -//// [/home/src/workspaces/project/node_modules/.bun/pkg2/index.js] *new* -export const value2 = 2; - -//// [/home/src/workspaces/project/node_modules/.bun/pkg3/index.js] *new* -export const value3 = 3; - -//// [/home/src/workspaces/project/node_modules/.bun/pkg4/index.js] *new* -export const value4 = 4; - -//// [/home/src/workspaces/project/node_modules/.bun/pkg5/index.js] *new* -export const value5 = 5; - -//// [/home/src/workspaces/project/node_modules/.bun/pkg6/index.js] *new* -export const value6 = 6; - -//// [/home/src/workspaces/project/node_modules/.bun/pkg7/index.js] *new* -export const value7 = 7; - -//// [/home/src/workspaces/project/node_modules/.bun/pkg8/index.js] *new* -export const value8 = 8; - -//// [/home/src/workspaces/project/node_modules/.bun/pkg9/index.js] *new* -export const value9 = 9; - - -Watch Registrations:: -Directory watches:: - /home/src/tslibs/TS/Lib - /home/src/workspaces/project - /home/src/workspaces/project/node_modules - /home/src/workspaces/project/node_modules/.bun (recursive) -tsconfig.json:: -SemanticDiagnostics:: -*refresh* /home/src/tslibs/TS/Lib/lib.es2025.full.d.ts -*refresh* /home/src/workspaces/project/node_modules/.bun/pkg0/index.ts -*refresh* /home/src/workspaces/project/node_modules/.bun/pkg1/index.ts -*refresh* /home/src/workspaces/project/node_modules/.bun/pkg2/index.ts -*refresh* /home/src/workspaces/project/node_modules/.bun/pkg3/index.ts -*refresh* /home/src/workspaces/project/node_modules/.bun/pkg4/index.ts -*refresh* /home/src/workspaces/project/node_modules/.bun/pkg5/index.ts -*refresh* /home/src/workspaces/project/node_modules/.bun/pkg6/index.ts -*refresh* /home/src/workspaces/project/node_modules/.bun/pkg7/index.ts -*refresh* /home/src/workspaces/project/node_modules/.bun/pkg8/index.ts -*refresh* /home/src/workspaces/project/node_modules/.bun/pkg9/index.ts -*refresh* /home/src/workspaces/project/node_modules/.bun/pkg10/index.ts -*refresh* /home/src/workspaces/project/node_modules/.bun/pkg11/index.ts -*refresh* /home/src/workspaces/project/index.ts -Signatures:: From ce359c197661b56b7614a472fc57ad03143f1eca Mon Sep 17 00:00:00 2001 From: Jake Bailey <5341706+jakebailey@users.noreply.github.com> Date: Fri, 19 Jun 2026 15:10:57 -0700 Subject: [PATCH 3/3] Add watch test for Bun dependencies Add a tsc watch integration baseline covering many dependency files under a Bun-style node_modules/.bun layout. The lower-level fswatch tests assert stream coalescing; this keeps the command-line watch shape covered end-to-end. --- internal/execute/tsctests/tscwatch_test.go | 26 +++ ...watch-handles-many-bun-dependency-files.js | 175 ++++++++++++++++++ 2 files changed, 201 insertions(+) create mode 100644 testdata/baselines/reference/tscWatch/commandLineWatch/watch-handles-many-bun-dependency-files.js diff --git a/internal/execute/tsctests/tscwatch_test.go b/internal/execute/tsctests/tscwatch_test.go index 7650fcea7e9..fdf7ec0302e 100644 --- a/internal/execute/tsctests/tscwatch_test.go +++ b/internal/execute/tsctests/tscwatch_test.go @@ -1,6 +1,7 @@ package tsctests import ( + "fmt" "strings" "testing" @@ -9,6 +10,30 @@ import ( func TestWatch(t *testing.T) { t.Parallel() + bunDependencyTest := func() *tscInput { + files := FileMap{} + var index strings.Builder + var fileNames strings.Builder + fileNames.WriteString(`"index.ts"`) + for i := range 12 { + name := fmt.Sprintf("pkg%d", i) + value := fmt.Sprintf("value%d", i) + index.WriteString(fmt.Sprintf(`import { %[1]s } from "./node_modules/.bun/%[2]s/index"; %[1]s;`, value, name)) + index.WriteString("\n") + files["/home/src/workspaces/project/node_modules/.bun/"+name+"/index.ts"] = fmt.Sprintf("export const %s = %d;", value, i) + fileNames.WriteString(fmt.Sprintf(`, "node_modules/.bun/%s/index.ts"`, name)) + } + files["/home/src/workspaces/project/index.ts"] = index.String() + files["/home/src/workspaces/project/tsconfig.json"] = fmt.Sprintf(`{ + "compilerOptions": {}, + "files": [%s] +}`, fileNames.String()) + return &tscInput{ + subScenario: "watch handles many bun dependency files", + files: files, + commandLineArgs: []string{"--watch"}, + } + } testCases := []*tscInput{ { subScenario: "watch with no tsconfig", @@ -25,6 +50,7 @@ func TestWatch(t *testing.T) { }, commandLineArgs: []string{"--watch", "--incremental"}, }, + bunDependencyTest(), { subScenario: "watch skips build when no files change", files: FileMap{ diff --git a/testdata/baselines/reference/tscWatch/commandLineWatch/watch-handles-many-bun-dependency-files.js b/testdata/baselines/reference/tscWatch/commandLineWatch/watch-handles-many-bun-dependency-files.js new file mode 100644 index 00000000000..dbd33c9fbaf --- /dev/null +++ b/testdata/baselines/reference/tscWatch/commandLineWatch/watch-handles-many-bun-dependency-files.js @@ -0,0 +1,175 @@ +currentDirectory::/home/src/workspaces/project +useCaseSensitiveFileNames::true +Input:: +//// [/home/src/workspaces/project/index.ts] *new* +import { value0 } from "./node_modules/.bun/pkg0/index"; value0; +import { value1 } from "./node_modules/.bun/pkg1/index"; value1; +import { value2 } from "./node_modules/.bun/pkg2/index"; value2; +import { value3 } from "./node_modules/.bun/pkg3/index"; value3; +import { value4 } from "./node_modules/.bun/pkg4/index"; value4; +import { value5 } from "./node_modules/.bun/pkg5/index"; value5; +import { value6 } from "./node_modules/.bun/pkg6/index"; value6; +import { value7 } from "./node_modules/.bun/pkg7/index"; value7; +import { value8 } from "./node_modules/.bun/pkg8/index"; value8; +import { value9 } from "./node_modules/.bun/pkg9/index"; value9; +import { value10 } from "./node_modules/.bun/pkg10/index"; value10; +import { value11 } from "./node_modules/.bun/pkg11/index"; value11; + +//// [/home/src/workspaces/project/node_modules/.bun/pkg0/index.ts] *new* +export const value0 = 0; +//// [/home/src/workspaces/project/node_modules/.bun/pkg1/index.ts] *new* +export const value1 = 1; +//// [/home/src/workspaces/project/node_modules/.bun/pkg10/index.ts] *new* +export const value10 = 10; +//// [/home/src/workspaces/project/node_modules/.bun/pkg11/index.ts] *new* +export const value11 = 11; +//// [/home/src/workspaces/project/node_modules/.bun/pkg2/index.ts] *new* +export const value2 = 2; +//// [/home/src/workspaces/project/node_modules/.bun/pkg3/index.ts] *new* +export const value3 = 3; +//// [/home/src/workspaces/project/node_modules/.bun/pkg4/index.ts] *new* +export const value4 = 4; +//// [/home/src/workspaces/project/node_modules/.bun/pkg5/index.ts] *new* +export const value5 = 5; +//// [/home/src/workspaces/project/node_modules/.bun/pkg6/index.ts] *new* +export const value6 = 6; +//// [/home/src/workspaces/project/node_modules/.bun/pkg7/index.ts] *new* +export const value7 = 7; +//// [/home/src/workspaces/project/node_modules/.bun/pkg8/index.ts] *new* +export const value8 = 8; +//// [/home/src/workspaces/project/node_modules/.bun/pkg9/index.ts] *new* +export const value9 = 9; +//// [/home/src/workspaces/project/tsconfig.json] *new* +{ + "compilerOptions": {}, + "files": ["index.ts", "node_modules/.bun/pkg0/index.ts", "node_modules/.bun/pkg1/index.ts", "node_modules/.bun/pkg2/index.ts", "node_modules/.bun/pkg3/index.ts", "node_modules/.bun/pkg4/index.ts", "node_modules/.bun/pkg5/index.ts", "node_modules/.bun/pkg6/index.ts", "node_modules/.bun/pkg7/index.ts", "node_modules/.bun/pkg8/index.ts", "node_modules/.bun/pkg9/index.ts", "node_modules/.bun/pkg10/index.ts", "node_modules/.bun/pkg11/index.ts"] +} + +tsgo --watch +ExitStatus:: Success +Output:: +[HH:MM:SS AM] Starting compilation in watch mode... + +[HH:MM:SS AM] Found 0 errors. Watching for file changes. + +//// [/home/src/tslibs/TS/Lib/lib.es2025.full.d.ts] *Lib* +/// +interface Boolean {} +interface Function {} +interface CallableFunction {} +interface NewableFunction {} +interface IArguments {} +interface Number { toExponential: any; } +interface Object {} +interface RegExp {} +interface String { charAt: any; } +interface Array { length: number; [n: number]: T; } +interface ReadonlyArray {} +interface SymbolConstructor { + (desc?: string | number): symbol; + for(name: string): symbol; + readonly toStringTag: symbol; +} +declare var Symbol: SymbolConstructor; +interface Symbol { + readonly [Symbol.toStringTag]: string; +} +declare const console: { log(msg: any): void; }; +//// [/home/src/workspaces/project/index.js] *new* +import { value0 } from "./node_modules/.bun/pkg0/index"; +value0; +import { value1 } from "./node_modules/.bun/pkg1/index"; +value1; +import { value2 } from "./node_modules/.bun/pkg2/index"; +value2; +import { value3 } from "./node_modules/.bun/pkg3/index"; +value3; +import { value4 } from "./node_modules/.bun/pkg4/index"; +value4; +import { value5 } from "./node_modules/.bun/pkg5/index"; +value5; +import { value6 } from "./node_modules/.bun/pkg6/index"; +value6; +import { value7 } from "./node_modules/.bun/pkg7/index"; +value7; +import { value8 } from "./node_modules/.bun/pkg8/index"; +value8; +import { value9 } from "./node_modules/.bun/pkg9/index"; +value9; +import { value10 } from "./node_modules/.bun/pkg10/index"; +value10; +import { value11 } from "./node_modules/.bun/pkg11/index"; +value11; + +//// [/home/src/workspaces/project/node_modules/.bun/pkg0/index.js] *new* +export const value0 = 0; + +//// [/home/src/workspaces/project/node_modules/.bun/pkg1/index.js] *new* +export const value1 = 1; + +//// [/home/src/workspaces/project/node_modules/.bun/pkg10/index.js] *new* +export const value10 = 10; + +//// [/home/src/workspaces/project/node_modules/.bun/pkg11/index.js] *new* +export const value11 = 11; + +//// [/home/src/workspaces/project/node_modules/.bun/pkg2/index.js] *new* +export const value2 = 2; + +//// [/home/src/workspaces/project/node_modules/.bun/pkg3/index.js] *new* +export const value3 = 3; + +//// [/home/src/workspaces/project/node_modules/.bun/pkg4/index.js] *new* +export const value4 = 4; + +//// [/home/src/workspaces/project/node_modules/.bun/pkg5/index.js] *new* +export const value5 = 5; + +//// [/home/src/workspaces/project/node_modules/.bun/pkg6/index.js] *new* +export const value6 = 6; + +//// [/home/src/workspaces/project/node_modules/.bun/pkg7/index.js] *new* +export const value7 = 7; + +//// [/home/src/workspaces/project/node_modules/.bun/pkg8/index.js] *new* +export const value8 = 8; + +//// [/home/src/workspaces/project/node_modules/.bun/pkg9/index.js] *new* +export const value9 = 9; + + +Watch Registrations:: +Directory watches:: + /home/src/tslibs/TS/Lib + /home/src/workspaces/project + /home/src/workspaces/project/node_modules + /home/src/workspaces/project/node_modules/.bun + /home/src/workspaces/project/node_modules/.bun/pkg0 + /home/src/workspaces/project/node_modules/.bun/pkg1 + /home/src/workspaces/project/node_modules/.bun/pkg10 + /home/src/workspaces/project/node_modules/.bun/pkg11 + /home/src/workspaces/project/node_modules/.bun/pkg2 + /home/src/workspaces/project/node_modules/.bun/pkg3 + /home/src/workspaces/project/node_modules/.bun/pkg4 + /home/src/workspaces/project/node_modules/.bun/pkg5 + /home/src/workspaces/project/node_modules/.bun/pkg6 + /home/src/workspaces/project/node_modules/.bun/pkg7 + /home/src/workspaces/project/node_modules/.bun/pkg8 + /home/src/workspaces/project/node_modules/.bun/pkg9 +tsconfig.json:: +SemanticDiagnostics:: +*refresh* /home/src/tslibs/TS/Lib/lib.es2025.full.d.ts +*refresh* /home/src/workspaces/project/node_modules/.bun/pkg0/index.ts +*refresh* /home/src/workspaces/project/node_modules/.bun/pkg1/index.ts +*refresh* /home/src/workspaces/project/node_modules/.bun/pkg2/index.ts +*refresh* /home/src/workspaces/project/node_modules/.bun/pkg3/index.ts +*refresh* /home/src/workspaces/project/node_modules/.bun/pkg4/index.ts +*refresh* /home/src/workspaces/project/node_modules/.bun/pkg5/index.ts +*refresh* /home/src/workspaces/project/node_modules/.bun/pkg6/index.ts +*refresh* /home/src/workspaces/project/node_modules/.bun/pkg7/index.ts +*refresh* /home/src/workspaces/project/node_modules/.bun/pkg8/index.ts +*refresh* /home/src/workspaces/project/node_modules/.bun/pkg9/index.ts +*refresh* /home/src/workspaces/project/node_modules/.bun/pkg10/index.ts +*refresh* /home/src/workspaces/project/node_modules/.bun/pkg11/index.ts +*refresh* /home/src/workspaces/project/index.ts +Signatures::