Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .golangci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -96,7 +96,7 @@ linters:
- path: internal/fourslash/tests/gen/
linters:
- misspell
- path: 'internal/(repo|testutil|testrunner|vfs|pprof|execute/tsctests|bundled)|cmd/tsgo'
- path: 'internal/(repo|testutil|testrunner|vfs|pprof|execute/tsctests|bundled|fswatch)|cmd/tsgo'
text: should likely be used instead
- path: '(.+)_test\.go$'
text: should likely be used instead
Expand Down
164 changes: 155 additions & 9 deletions internal/execute/watcher.go
Original file line number Diff line number Diff line change
@@ -1,7 +1,11 @@
package execute

import (
"errors"
"fmt"
"io"
"reflect"
"strings"
"sync"
"time"

Expand All @@ -12,6 +16,7 @@ import (
"github.com/microsoft/typescript-go/internal/diagnostics"
"github.com/microsoft/typescript-go/internal/execute/incremental"
"github.com/microsoft/typescript-go/internal/execute/tsc"
"github.com/microsoft/typescript-go/internal/fswatch"
"github.com/microsoft/typescript-go/internal/tsoptions"
"github.com/microsoft/typescript-go/internal/tspath"
"github.com/microsoft/typescript-go/internal/vfs/cachedvfs"
Expand Down Expand Up @@ -71,6 +76,10 @@ type Watcher struct {

sourceFileCache *collections.SyncMap[tspath.Path, *cachedSourceFile]
fileWatcher *vfswatch.FileWatcher
watches []fswatch.Watch // targeted directory/file watches
watchTerminated chan struct{} // closed when a watch terminates
doCycleCh chan struct{} // buffered signal to run DoCycle off the callback goroutine
debugLog io.Writer // nil = silent; set via TS_WATCH_DEBUG
}

var _ tsc.Watcher = (*Watcher)(nil)
Expand All @@ -92,16 +101,13 @@ func createWatcher(
reportWatchStatus: tsc.CreateWatchStatusReporter(sys, configParseResult.Locale(), configParseResult.CompilerOptions(), testing),
testing: testing,
sourceFileCache: &collections.SyncMap[tspath.Path, *cachedSourceFile]{},
watchTerminated: make(chan struct{}),
doCycleCh: make(chan struct{}, 1),
}
if configParseResult.ConfigFile != nil {
w.configFileName = configParseResult.ConfigFile.SourceFile.FileName()
}
w.fileWatcher = vfswatch.NewFileWatcher(
sys.FS(),
w.config.ParsedConfig.WatchOptions.WatchInterval(),
testing != nil,
w.DoCycle,
)
w.fileWatcher = vfswatch.NewFileWatcher(sys.FS())
return w
}

Expand All @@ -116,15 +122,153 @@ func (w *Watcher) start() {
}

if w.sys.GetEnvironmentVariable("TS_WATCH_DEBUG") != "" {
w.fileWatcher.SetDebugLog(w.sys.Writer())
w.debugLog = w.sys.Writer()
}

w.reportWatchStatus(ast.NewCompilerDiagnostic(diagnostics.Starting_compilation_in_watch_mode))
w.doBuild()
w.mu.Unlock()

if w.testing == nil {
w.fileWatcher.Run(w.sys.Now)
if err := w.subscribe(); err != nil {
fmt.Fprintf(w.sys.Writer(), "Error: Failed to start file watcher: %v\n", err)
return
}
// Process DoCycle signals on a dedicated goroutine so fswatch
// callbacks return immediately and don't block event delivery.
go func() {
for range w.doCycleCh {
w.DoCycle()
}
}()
// Block until a watch terminates (e.g. watched directory deleted),
// then clean up.
<-w.watchTerminated
Comment thread
johnfav03 marked this conversation as resolved.
w.closeWatches()
close(w.doCycleCh)
}
}

func (w *Watcher) subscribe() error {
watcher := fswatch.Default()
if w.debugLog != nil {
fmt.Fprintf(w.debugLog, "[watch] using %s backend\n", watcher.Name())
}

// Watch wildcard directories from tsconfig (recursive or non-recursive
// based on the include patterns). This detects new/deleted source files.
if w.config.ConfigFile != nil {
for dir, recursive := range w.config.WildcardDirectories() {
realDir := w.sys.FS().Realpath(dir)
opts := []fswatch.WatchOption{fswatch.WithIgnore(shouldIgnoreWatchPath)}
if recursive {
opts = append(opts, fswatch.WithRecursive())
}
if w.debugLog != nil {
fmt.Fprintf(w.debugLog, "[watch] watching directory %s (recursive=%v)\n", realDir, recursive)
}
watch, err := watcher.WatchDirectory(realDir, w.onWatchEvents, opts...)
if err != nil {
w.closeWatches()
return fmt.Errorf("watching %s: %w", realDir, err)
}
w.watches = append(w.watches, watch)
}
}

// Watch config files (tsconfig.json and extended configs) for changes.
for _, path := range w.configFilePaths {
realPath := w.sys.FS().Realpath(path)
if w.debugLog != nil {
fmt.Fprintf(w.debugLog, "[watch] watching file %s\n", realPath)
}
watch, err := watcher.WatchFile(realPath, w.onWatchEvents)
if err != nil {
w.closeWatches()
return fmt.Errorf("watching %s: %w", realPath, err)
}
w.watches = append(w.watches, watch)
}

if len(w.watches) == 0 {
// No config file — watch the current directory non-recursively.
dir := w.sys.FS().Realpath(w.sys.GetCurrentDirectory())
if w.debugLog != nil {
fmt.Fprintf(w.debugLog, "[watch] no tsconfig, watching %s\n", dir)
}
watch, err := watcher.WatchDirectory(dir, w.onWatchEvents,
fswatch.WithIgnore(shouldIgnoreWatchPath),
)
if err != nil {
return err
}
w.watches = append(w.watches, watch)
}

return nil
}

func (w *Watcher) closeWatches() {
for _, watch := range w.watches {
watch.Close()
}
w.watches = nil
}

func shouldIgnoreWatchPath(path string) bool {
p := tspath.NormalizeSlashes(path)
return strings.HasSuffix(p, "/.git") ||
strings.Contains(p, "/.git/") ||
strings.Contains(p, "/node_modules/.") ||
strings.Contains(p, "/.#")
}

func (w *Watcher) onWatchEvents(events []fswatch.Event, err error) {
if err != nil {
if errors.Is(err, fswatch.ErrOverflow) {
if w.debugLog != nil {
fmt.Fprintf(w.debugLog, "[watch] event overflow, triggering rebuild\n")
}
w.signalDoCycle()
return
}
if errors.Is(err, fswatch.ErrWatchTerminated) {
fmt.Fprintf(w.sys.Writer(), "Warning: File watcher terminated: %v\n", err)
close(w.watchTerminated)
return
}
fmt.Fprintf(w.sys.Writer(), "Warning: File watch error: %v\n", err)
return
}

if len(events) > 0 {
if w.debugLog != nil {
fmt.Fprintf(w.debugLog, "[watch] %d event(s): ", len(events))
for i, e := range events {
if i > 0 {
fmt.Fprint(w.debugLog, ", ")
}
if i >= 5 {
fmt.Fprintf(w.debugLog, "... and %d more", len(events)-i)
break
}
fmt.Fprintf(w.debugLog, "%s %s", e.Kind, e.Path)
}
fmt.Fprintln(w.debugLog)
}
w.signalDoCycle()
}
}

// signalDoCycle sends a non-blocking signal to the DoCycle goroutine.
// If a signal is already pending, additional signals are coalesced.
func (w *Watcher) signalDoCycle() {
select {
case w.doCycleCh <- struct{}{}:
// Signal sent; the DoCycle goroutine will pick it up.
default:
// A signal is already pending; this event will be covered
// by the next DoCycle's filesystem scan.
}
}

Expand All @@ -135,6 +279,9 @@ func (w *Watcher) DoCycle() {
return
}
if !w.fileWatcher.WatchStateUninitialized() && !w.configModified && !w.fileWatcher.HasChangesFromWatchState() {
if w.debugLog != nil {
fmt.Fprintf(w.debugLog, "[watch] DoCycle: no tracked files changed, skipping rebuild\n")
}
if w.testing != nil {
w.testing.OnProgram(w.program)
}
Expand Down Expand Up @@ -177,7 +324,6 @@ func (w *Watcher) doBuild() {
result := w.compileAndEmit()
cached.DisableAndClearCache()
w.fileWatcher.UpdateWatchState(tfs.SeenFiles.ToSlice(), wildcardDirs)
w.fileWatcher.SetPollInterval(w.config.ParsedConfig.WatchOptions.WatchInterval())
w.configModified = false

programFiles := w.program.GetProgram().FilesByPath()
Expand Down
Loading
Loading