vendor tsgo
This commit is contained in:
596
tools/tsgo/internal/lsp/lspwatcher/lspwatcher.go
Normal file
596
tools/tsgo/internal/lsp/lspwatcher/lspwatcher.go
Normal file
@@ -0,0 +1,596 @@
|
||||
// Package lspwatcher implements an in-process file watcher used as a
|
||||
// drop-in replacement for LSP-based file watching when the client does not
|
||||
// support dynamic registration of file watchers.
|
||||
package lspwatcher
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/microsoft/typescript-go/internal/fswatch"
|
||||
"github.com/microsoft/typescript-go/internal/ls/lsconv"
|
||||
"github.com/microsoft/typescript-go/internal/lsp/lsproto"
|
||||
"github.com/microsoft/typescript-go/internal/project/logging"
|
||||
"github.com/microsoft/typescript-go/internal/tspath"
|
||||
"github.com/microsoft/typescript-go/internal/vfs"
|
||||
)
|
||||
|
||||
// throttleWindow mirrors VS Code's parcel watcher integration: give the
|
||||
// first batch a short grace window so adjacent filesystem bursts coalesce.
|
||||
const throttleWindow = 75 * time.Millisecond
|
||||
|
||||
type watcherBackend interface {
|
||||
WatchDirectory(dir string, fn fswatch.WatchCallback, opts ...fswatch.WatchOption) (io.Closer, error)
|
||||
}
|
||||
|
||||
type defaultWatcherBackend struct {
|
||||
watcher fswatch.Watcher
|
||||
}
|
||||
|
||||
func (d defaultWatcherBackend) WatchDirectory(dir string, fn fswatch.WatchCallback, opts ...fswatch.WatchOption) (io.Closer, error) {
|
||||
return d.watcher.WatchDirectory(dir, fn, opts...)
|
||||
}
|
||||
|
||||
// Watcher manages a set of file system subscriptions identified by
|
||||
// WatcherID strings (matching the LSP server's project.WatcherID type).
|
||||
// Events are delivered to onChanges in batches as `*lsproto.FileEvent`,
|
||||
// shaped exactly like a `workspace/didChangeWatchedFiles` notification.
|
||||
type Watcher struct {
|
||||
fs vfs.FS
|
||||
backend watcherBackend
|
||||
onChanges func(changes []*lsproto.FileEvent)
|
||||
logger logging.Logger
|
||||
|
||||
mu sync.Mutex
|
||||
// watches holds the watches associated with each LSP WatcherID. A single id
|
||||
// may map to more than one watch because each FileSystemWatcher in the
|
||||
// registration becomes its own watch (different roots and kinds).
|
||||
watches map[string][]*watch
|
||||
closed bool
|
||||
|
||||
// Pending batch state, protected by mu.
|
||||
pending map[string]*lsproto.FileEvent
|
||||
flushTimer *time.Timer
|
||||
}
|
||||
|
||||
// watch represents one FileSystemWatcher from the LSP registration.
|
||||
//
|
||||
// The directory the session asks to watch may not exist yet (common in
|
||||
// granular mode, where each probed-but-missing package directory becomes a
|
||||
// watch) or may be deleted while watched. To honor the watch across those
|
||||
// transitions, a watch maintains either:
|
||||
//
|
||||
// - a "target" subscription rooted directly at the requested directory, once
|
||||
// it exists, or
|
||||
// - an "ancestor" subscription on the nearest existing ancestor
|
||||
// (non-recursive), used to detect the requested directory — or an
|
||||
// intermediate path component — being created, after which the watch
|
||||
// descends toward and eventually promotes to the target.
|
||||
//
|
||||
// When the target materializes, synthetic create events are emitted for it
|
||||
// (and, depending on whether the watch is recursive, its immediate children or
|
||||
// its whole subtree) so the session re-resolves files that appeared in the gap
|
||||
// before the real subscription was installed.
|
||||
//
|
||||
// All path fields are tspath-style (forward-slash) absolute paths.
|
||||
type watch struct {
|
||||
watcher *Watcher
|
||||
requestedDirectory string // directory requested by the LSP layer (possibly a symlink)
|
||||
kind lsproto.WatchKind
|
||||
recursive bool // whether the target subscription should be recursive
|
||||
|
||||
mu sync.Mutex
|
||||
subscription io.Closer // current subscription (target or ancestor); nil if none
|
||||
watchedDirectory string // canonicalized directory 'subscription' is rooted at
|
||||
watchingTarget bool // whether 'subscription' is rooted at the target directory
|
||||
closed bool
|
||||
}
|
||||
|
||||
// New constructs a Watcher backed by internal/fswatch's platform-default
|
||||
// watcher implementation.
|
||||
func New(fs vfs.FS, onChanges func(changes []*lsproto.FileEvent), logger logging.Logger) *Watcher {
|
||||
return NewWithFSWatcher(fs, fswatch.Default(), onChanges, logger)
|
||||
}
|
||||
|
||||
// NewWithFSWatcher constructs a Watcher backed by the provided fswatch.Watcher.
|
||||
// Use this to select a specific backend (e.g. fswatch.Kqueue()) instead of the
|
||||
// platform default.
|
||||
func NewWithFSWatcher(fs vfs.FS, watcher fswatch.Watcher, onChanges func(changes []*lsproto.FileEvent), logger logging.Logger) *Watcher {
|
||||
return newWithBackend(fs, defaultWatcherBackend{watcher: watcher}, onChanges, logger)
|
||||
}
|
||||
|
||||
func newWithBackend(fs vfs.FS, backend watcherBackend, onChanges func(changes []*lsproto.FileEvent), logger logging.Logger) *Watcher {
|
||||
return &Watcher{
|
||||
fs: fs,
|
||||
backend: backend,
|
||||
onChanges: onChanges,
|
||||
logger: logger,
|
||||
watches: make(map[string][]*watch),
|
||||
}
|
||||
}
|
||||
|
||||
// WatchFiles subscribes to each FileSystemWatcher under the given id.
|
||||
//
|
||||
// A watcher whose directory does not exist yet is not an error: an ancestor
|
||||
// watch is installed on the nearest existing ancestor and the subscription is
|
||||
// reported as successful, so the session's notion of "this watcher is alive"
|
||||
// stays true for the subscription's whole lifetime. Only a genuine backend
|
||||
// failure (e.g. resource exhaustion while watching an existing directory)
|
||||
// causes WatchFiles to roll back the whole id and return an error, so the
|
||||
// session's pending/retry path re-registers it on the next reevaluation.
|
||||
func (w *Watcher) WatchFiles(id string, fileSystemWatchers []*lsproto.FileSystemWatcher) error {
|
||||
w.mu.Lock()
|
||||
if w.closed {
|
||||
w.mu.Unlock()
|
||||
return errors.New("lspwatcher: closed")
|
||||
}
|
||||
if _, exists := w.watches[id]; exists {
|
||||
w.mu.Unlock()
|
||||
return fmt.Errorf("lspwatcher: watcher %q already exists", id)
|
||||
}
|
||||
// Mark the id as existing before installing any watches so a concurrent
|
||||
// WatchFiles for the same id is rejected above.
|
||||
w.watches[id] = nil
|
||||
w.mu.Unlock()
|
||||
|
||||
var failed bool
|
||||
for _, fileSystemWatcher := range fileSystemWatchers {
|
||||
directory, ok := watchRoot(fileSystemWatcher)
|
||||
if !ok || directory == "" {
|
||||
w.logger.Logf("lspwatcher: skipping watcher %q: unrecognized pattern %q", id, watchPatternString(fileSystemWatcher))
|
||||
continue
|
||||
}
|
||||
newWatch := &watch{
|
||||
watcher: w,
|
||||
requestedDirectory: directory,
|
||||
kind: effectiveKind(fileSystemWatcher),
|
||||
recursive: isRecursiveGlob(fileSystemWatcher),
|
||||
}
|
||||
if err := newWatch.reconcile(false /*emitSynthetic*/); err != nil {
|
||||
w.logger.Logf("lspwatcher: failed to register watcher %q for %q: %v", id, directory, err)
|
||||
newWatch.close()
|
||||
failed = true
|
||||
break
|
||||
}
|
||||
w.mu.Lock()
|
||||
w.watches[id] = append(w.watches[id], newWatch)
|
||||
w.mu.Unlock()
|
||||
}
|
||||
|
||||
if failed {
|
||||
// Roll back the whole id so the session's retry (MarkPending) can
|
||||
// cleanly re-register it. The session treats an id as a single unit.
|
||||
_ = w.UnwatchFiles(id)
|
||||
return fmt.Errorf("lspwatcher: failed to register one or more watchers for %q", id)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// UnwatchFiles tears down all subscriptions associated with id.
|
||||
func (w *Watcher) UnwatchFiles(id string) error {
|
||||
w.mu.Lock()
|
||||
watches, ok := w.watches[id]
|
||||
if !ok {
|
||||
w.mu.Unlock()
|
||||
return fmt.Errorf("lspwatcher: no watcher with id %q", id)
|
||||
}
|
||||
delete(w.watches, id)
|
||||
w.mu.Unlock()
|
||||
for _, watch := range watches {
|
||||
watch.close()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Close removes every subscription. Safe to call multiple times.
|
||||
func (w *Watcher) Close() {
|
||||
w.mu.Lock()
|
||||
if w.closed {
|
||||
w.mu.Unlock()
|
||||
return
|
||||
}
|
||||
w.closed = true
|
||||
watchesByID := w.watches
|
||||
w.watches = nil
|
||||
if w.flushTimer != nil {
|
||||
w.flushTimer.Stop()
|
||||
w.flushTimer = nil
|
||||
}
|
||||
w.pending = nil
|
||||
w.mu.Unlock()
|
||||
for _, watches := range watchesByID {
|
||||
for _, watch := range watches {
|
||||
watch.close()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// close tears down the watch's current subscription and prevents any in-flight
|
||||
// reconcile from reinstalling one.
|
||||
func (w *watch) close() {
|
||||
w.mu.Lock()
|
||||
w.closed = true
|
||||
subscription := w.subscription
|
||||
w.subscription = nil
|
||||
w.watchedDirectory = ""
|
||||
w.mu.Unlock()
|
||||
if subscription != nil {
|
||||
_ = subscription.Close()
|
||||
}
|
||||
}
|
||||
|
||||
// reconcile installs or advances this watch toward the target directory based
|
||||
// on the current filesystem state. It is called at registration, whenever a
|
||||
// ancestor watch observes activity, and after a target watch is terminated by
|
||||
// deletion.
|
||||
//
|
||||
// emitSynthetic controls whether promoting to the target emits synthetic
|
||||
// create events: false for the initial install when the target already exists
|
||||
// (the session already knows about those files), true for any missing→present
|
||||
// recovery.
|
||||
//
|
||||
// It returns a non-nil error only on a genuine backend failure to install a
|
||||
// watch; a missing target directory is handled by installing an ancestor watch
|
||||
// and returns nil.
|
||||
func (w *watch) reconcile(emitSyntheticCreates bool) error {
|
||||
w.mu.Lock()
|
||||
defer w.mu.Unlock()
|
||||
watcher := w.watcher
|
||||
for {
|
||||
if w.closed {
|
||||
return nil
|
||||
}
|
||||
if watcher.fs.DirectoryExists(w.requestedDirectory) {
|
||||
if w.watchingTarget && w.subscription != nil {
|
||||
return nil // already watching the target
|
||||
}
|
||||
targetDirectory := w.requestedDirectory
|
||||
var options []fswatch.WatchOption
|
||||
if w.recursive {
|
||||
options = append(options, fswatch.WithRecursive())
|
||||
}
|
||||
subscription, err := watcher.backend.WatchDirectory(targetDirectory, w.targetCallback(targetDirectory), options...)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
previous := w.subscription
|
||||
w.subscription = subscription
|
||||
w.watchedDirectory = targetDirectory
|
||||
w.watchingTarget = true
|
||||
if previous != nil {
|
||||
_ = previous.Close()
|
||||
}
|
||||
if emitSyntheticCreates {
|
||||
watcher.emitSyntheticCreates(targetDirectory, w.kind, w.recursive)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
ancestor, ok := nearestExistingAncestor(watcher.fs, w.requestedDirectory)
|
||||
if !ok {
|
||||
// Nothing exists to watch (even the root is gone); drop any subscription.
|
||||
if w.subscription != nil {
|
||||
previous := w.subscription
|
||||
w.subscription = nil
|
||||
w.watchedDirectory = ""
|
||||
w.watchingTarget = false
|
||||
_ = previous.Close()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
ancestorDirectory := ancestor
|
||||
if !w.watchingTarget && w.subscription != nil && w.watchedDirectory == ancestorDirectory {
|
||||
return nil // already watching the correct ancestor
|
||||
}
|
||||
subscription, err := watcher.backend.WatchDirectory(ancestorDirectory, w.ancestorCallback())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
previous := w.subscription
|
||||
w.subscription = subscription
|
||||
w.watchedDirectory = ancestorDirectory
|
||||
w.watchingTarget = false
|
||||
if previous != nil {
|
||||
_ = previous.Close()
|
||||
}
|
||||
// The target may have appeared between the DirectoryExists check above
|
||||
// and installing this ancestor subscription (e.g. an atomic tree
|
||||
// creation), so loop to descend further or promote immediately. Any
|
||||
// promotion from here on is a missing→present transition, so synthesize
|
||||
// creates.
|
||||
emitSyntheticCreates = true
|
||||
}
|
||||
}
|
||||
|
||||
// targetCallback returns the fswatch callback for a target watch rooted at
|
||||
// watchedReal. It forwards events to the session and, on ErrWatchTerminated
|
||||
// (the watched directory was deleted), falls back to watching the nearest
|
||||
// existing ancestor so the watch re-attaches when the directory is recreated.
|
||||
func (w *watch) targetCallback(watchedDirectory string) fswatch.WatchCallback {
|
||||
watcher := w.watcher
|
||||
return func(events []fswatch.Event, err error) {
|
||||
terminated := false
|
||||
if err != nil {
|
||||
switch {
|
||||
case errors.Is(err, fswatch.ErrOverflow):
|
||||
watcher.logger.Logf("lspwatcher: watch overflow in %q (some events may have been dropped): %v", watchedDirectory, err)
|
||||
case errors.Is(err, fswatch.ErrWatchTerminated):
|
||||
terminated = true
|
||||
watcher.logger.Logf("lspwatcher: watch terminated in %q (directory removed): %v", watchedDirectory, err)
|
||||
default:
|
||||
watcher.logger.Logf("lspwatcher: watch error in %q: %v", watchedDirectory, err)
|
||||
}
|
||||
}
|
||||
if len(events) > 0 {
|
||||
watcher.forwardEvents(w.kind, events)
|
||||
}
|
||||
if terminated {
|
||||
// The delete event for the directory was forwarded above; now
|
||||
// re-attach to the nearest existing ancestor.
|
||||
w.handleTerminated()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// handleTerminated clears the dead target watch (the backend has already
|
||||
// removed it) and re-evaluates, falling back to an ancestor watch on the nearest
|
||||
// existing ancestor so the watch re-attaches when the directory reappears.
|
||||
// Clearing the state first is essential: reconcile would otherwise see
|
||||
// watchingTarget && subscription != nil and conclude the target is already
|
||||
// watched, even though the subscription is dead — losing recovery if the
|
||||
// directory is recreated before reconcile runs.
|
||||
func (w *watch) handleTerminated() {
|
||||
w.mu.Lock()
|
||||
if w.closed {
|
||||
w.mu.Unlock()
|
||||
return
|
||||
}
|
||||
previous := w.subscription
|
||||
w.subscription = nil
|
||||
w.watchedDirectory = ""
|
||||
w.watchingTarget = false
|
||||
w.mu.Unlock()
|
||||
if previous != nil {
|
||||
_ = previous.Close()
|
||||
}
|
||||
_ = w.reconcile(true /*emitSyntheticCreates*/)
|
||||
}
|
||||
|
||||
// ancestorCallback returns the fswatch callback for an ancestor watch. Ancestor
|
||||
// watches exist only to detect the target — or an intermediate path component —
|
||||
// being created; their events are about ancestor directories the session
|
||||
// doesn't track, so they are ignored and the watch is simply re-evaluated.
|
||||
func (w *watch) ancestorCallback() fswatch.WatchCallback {
|
||||
return func(events []fswatch.Event, err error) {
|
||||
_ = w.reconcile(true /*emitSyntheticCreates*/)
|
||||
}
|
||||
}
|
||||
|
||||
// nearestExistingAncestor returns the deepest existing directory that is dir or
|
||||
// an ancestor of dir, walking upward. ok is false only if nothing in the chain
|
||||
// (including the root) exists.
|
||||
func nearestExistingAncestor(fs vfs.FS, dir string) (string, bool) {
|
||||
for {
|
||||
if fs.DirectoryExists(dir) {
|
||||
return dir, true
|
||||
}
|
||||
parent := tspath.GetDirectoryPath(dir)
|
||||
if parent == dir {
|
||||
return "", false
|
||||
}
|
||||
dir = parent
|
||||
}
|
||||
}
|
||||
|
||||
// forwardEvents translates fswatch events into LSP file events and enqueues
|
||||
// them for the next debounced flush.
|
||||
func (w *Watcher) forwardEvents(kind lsproto.WatchKind, events []fswatch.Event) {
|
||||
w.mu.Lock()
|
||||
if w.closed {
|
||||
w.mu.Unlock()
|
||||
return
|
||||
}
|
||||
if w.pending == nil {
|
||||
w.pending = make(map[string]*lsproto.FileEvent, len(events))
|
||||
}
|
||||
for _, event := range events {
|
||||
var changeType lsproto.FileChangeType
|
||||
switch event.Kind {
|
||||
case fswatch.EventUpdate:
|
||||
// fswatch intentionally doesn't distinguish create vs update.
|
||||
// For LSP consumers this is fine: callers infer create/update
|
||||
// from their own cache and both should invalidate stale state.
|
||||
if kind&(lsproto.WatchKindCreate|lsproto.WatchKindChange) == 0 {
|
||||
continue
|
||||
}
|
||||
changeType = lsproto.FileChangeTypeChanged
|
||||
case fswatch.EventDelete:
|
||||
if kind&lsproto.WatchKindDelete == 0 {
|
||||
continue
|
||||
}
|
||||
changeType = lsproto.FileChangeTypeDeleted
|
||||
default:
|
||||
continue
|
||||
}
|
||||
|
||||
path := tspath.NormalizeSlashes(event.Path)
|
||||
uri := lsconv.FileNameToDocumentURI(path)
|
||||
w.pending[string(uri)] = &lsproto.FileEvent{
|
||||
Uri: uri,
|
||||
Type: changeType,
|
||||
}
|
||||
}
|
||||
w.scheduleFlushLocked()
|
||||
w.mu.Unlock()
|
||||
}
|
||||
|
||||
// emitSyntheticCreates enqueues synthetic create events after a target watch is
|
||||
// (re)installed following a missing→present transition, so the session
|
||||
// re-resolves files that appeared before the real watch existed. The target
|
||||
// directory itself is always included; for a non-recursive watch its immediate
|
||||
// children are added, and for a recursive watch its whole subtree is walked.
|
||||
// Nothing is emitted if the watch doesn't request create notifications.
|
||||
func (w *Watcher) emitSyntheticCreates(directory string, kind lsproto.WatchKind, recursive bool) {
|
||||
if kind&lsproto.WatchKindCreate == 0 {
|
||||
return
|
||||
}
|
||||
paths := []string{directory}
|
||||
if recursive {
|
||||
_ = w.fs.WalkDir(directory, func(path string, entry vfs.DirEntry, err error) error {
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
normalizedPath := tspath.NormalizeSlashes(path)
|
||||
if normalizedPath == directory {
|
||||
return nil
|
||||
}
|
||||
paths = append(paths, normalizedPath)
|
||||
return nil
|
||||
})
|
||||
} else {
|
||||
entries := w.fs.GetAccessibleEntries(directory)
|
||||
for _, name := range entries.Files {
|
||||
paths = append(paths, tspath.CombinePaths(directory, name))
|
||||
}
|
||||
for _, name := range entries.Directories {
|
||||
paths = append(paths, tspath.CombinePaths(directory, name))
|
||||
}
|
||||
}
|
||||
w.enqueueSyntheticCreates(paths)
|
||||
}
|
||||
|
||||
// enqueueSyntheticCreates adds synthetic create events for paths, without
|
||||
// clobbering a more specific event already pending for the same path (e.g. a
|
||||
// real delete).
|
||||
func (w *Watcher) enqueueSyntheticCreates(paths []string) {
|
||||
w.mu.Lock()
|
||||
if w.closed {
|
||||
w.mu.Unlock()
|
||||
return
|
||||
}
|
||||
if w.pending == nil {
|
||||
w.pending = make(map[string]*lsproto.FileEvent, len(paths))
|
||||
}
|
||||
for _, path := range paths {
|
||||
uri := lsconv.FileNameToDocumentURI(path)
|
||||
if _, ok := w.pending[string(uri)]; ok {
|
||||
continue
|
||||
}
|
||||
w.pending[string(uri)] = &lsproto.FileEvent{
|
||||
Uri: uri,
|
||||
Type: lsproto.FileChangeTypeCreated,
|
||||
}
|
||||
}
|
||||
w.scheduleFlushLocked()
|
||||
w.mu.Unlock()
|
||||
}
|
||||
|
||||
// scheduleFlushLocked arms the debounce flush timer if it isn't already armed.
|
||||
// Callers must hold w.mu.
|
||||
func (w *Watcher) scheduleFlushLocked() {
|
||||
if w.flushTimer == nil {
|
||||
w.flushTimer = time.AfterFunc(throttleWindow, w.flush)
|
||||
}
|
||||
}
|
||||
|
||||
func (w *Watcher) flush() {
|
||||
w.mu.Lock()
|
||||
if w.closed {
|
||||
w.mu.Unlock()
|
||||
return
|
||||
}
|
||||
pending := w.pending
|
||||
w.pending = nil
|
||||
w.flushTimer = nil
|
||||
w.mu.Unlock()
|
||||
|
||||
if len(pending) == 0 {
|
||||
return
|
||||
}
|
||||
changes := make([]*lsproto.FileEvent, 0, len(pending))
|
||||
for _, event := range pending {
|
||||
changes = append(changes, event)
|
||||
}
|
||||
w.onChanges(changes)
|
||||
}
|
||||
|
||||
// watchRoot extracts the directory the fswatch subscription should be
|
||||
// rooted at from a FileSystemWatcher. The patterns the project layer
|
||||
// produces are of the form `<dir>/**/*` (recursive) or `<dir>/*`
|
||||
// (non-recursive, used by granular watch mode), either as a Pattern
|
||||
// with a fully-qualified directory or as a RelativePattern with a
|
||||
// file:// BaseUri, so the heuristic of "everything before the first
|
||||
// glob meta character" is reliable. Use [isRecursiveGlob] to determine
|
||||
// whether the subscription should be recursive.
|
||||
//
|
||||
// Returned roots are tspath-normalized (forward-slash) absolute paths.
|
||||
func watchRoot(fileSystemWatcher *lsproto.FileSystemWatcher) (string, bool) {
|
||||
if fileSystemWatcher.GlobPattern.Pattern != nil {
|
||||
return rootFromGlob(*fileSystemWatcher.GlobPattern.Pattern), true
|
||||
}
|
||||
if relativePattern := fileSystemWatcher.GlobPattern.RelativePattern; relativePattern != nil {
|
||||
var base string
|
||||
if relativePattern.BaseUri.URI != nil {
|
||||
base = lsproto.DocumentUri(*relativePattern.BaseUri.URI).FileName()
|
||||
} else {
|
||||
return "", false
|
||||
}
|
||||
pattern := tspath.CombinePaths(base, relativePattern.Pattern)
|
||||
return rootFromGlob(pattern), true
|
||||
}
|
||||
return "", false
|
||||
}
|
||||
|
||||
func rootFromGlob(pattern string) string {
|
||||
pattern = tspath.NormalizeSlashes(pattern)
|
||||
metaIndex := -1
|
||||
for i := range len(pattern) {
|
||||
switch pattern[i] {
|
||||
case '*', '?', '[', '{':
|
||||
metaIndex = i
|
||||
}
|
||||
if metaIndex != -1 {
|
||||
break
|
||||
}
|
||||
}
|
||||
if metaIndex == -1 {
|
||||
return tspath.NormalizePath(strings.TrimRight(pattern, "/"))
|
||||
}
|
||||
directory := strings.TrimRight(pattern[:metaIndex], "/")
|
||||
if directory == "" {
|
||||
return ""
|
||||
}
|
||||
return tspath.NormalizePath(directory)
|
||||
}
|
||||
|
||||
func watchPatternString(fileSystemWatcher *lsproto.FileSystemWatcher) string {
|
||||
if fileSystemWatcher.GlobPattern.Pattern != nil {
|
||||
return *fileSystemWatcher.GlobPattern.Pattern
|
||||
}
|
||||
if relativePattern := fileSystemWatcher.GlobPattern.RelativePattern; relativePattern != nil {
|
||||
var base string
|
||||
if relativePattern.BaseUri.URI != nil {
|
||||
base = string(*relativePattern.BaseUri.URI)
|
||||
}
|
||||
return base + "/" + relativePattern.Pattern
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// isRecursiveGlob reports whether a FileSystemWatcher's pattern requests
|
||||
// recursive watching (contains a `**` segment). Granular watch mode emits
|
||||
// non-recursive `<dir>/*` patterns, which watch only the immediate directory.
|
||||
func isRecursiveGlob(fileSystemWatcher *lsproto.FileSystemWatcher) bool {
|
||||
return strings.Contains(watchPatternString(fileSystemWatcher), "**")
|
||||
}
|
||||
|
||||
func effectiveKind(fileSystemWatcher *lsproto.FileSystemWatcher) lsproto.WatchKind {
|
||||
if fileSystemWatcher.Kind != nil {
|
||||
return *fileSystemWatcher.Kind
|
||||
}
|
||||
return lsproto.WatchKindCreate | lsproto.WatchKindChange | lsproto.WatchKindDelete
|
||||
}
|
||||
805
tools/tsgo/internal/lsp/lspwatcher/lspwatcher_test.go
Normal file
805
tools/tsgo/internal/lsp/lspwatcher/lspwatcher_test.go
Normal file
@@ -0,0 +1,805 @@
|
||||
package lspwatcher
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/microsoft/typescript-go/internal/bundled"
|
||||
"github.com/microsoft/typescript-go/internal/fswatch"
|
||||
"github.com/microsoft/typescript-go/internal/lsp/lsproto"
|
||||
"github.com/microsoft/typescript-go/internal/project/logging"
|
||||
"github.com/microsoft/typescript-go/internal/tspath"
|
||||
"github.com/microsoft/typescript-go/internal/vfs/osvfs"
|
||||
)
|
||||
|
||||
func waitFor(t *testing.T, cond func() bool, msg string) {
|
||||
t.Helper()
|
||||
deadline := time.Now().Add(5 * time.Second)
|
||||
for time.Now().Before(deadline) {
|
||||
if cond() {
|
||||
return
|
||||
}
|
||||
time.Sleep(20 * time.Millisecond)
|
||||
}
|
||||
t.Fatalf("timed out waiting for %s", msg)
|
||||
}
|
||||
|
||||
func TestWatcher_CreateChangeDelete(t *testing.T) {
|
||||
t.Parallel()
|
||||
dir := t.TempDir()
|
||||
|
||||
var (
|
||||
mu sync.Mutex
|
||||
batches [][]*lsproto.FileEvent
|
||||
)
|
||||
w := New(bundled.WrapFS(osvfs.FS()), func(changes []*lsproto.FileEvent) {
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
batches = append(batches, changes)
|
||||
}, logging.NewLogger(os.Stderr))
|
||||
t.Cleanup(w.Close)
|
||||
|
||||
pattern := tspath.NormalizeSlashes(dir) + "/**/*"
|
||||
kind := lsproto.WatchKindCreate | lsproto.WatchKindChange | lsproto.WatchKindDelete
|
||||
if err := w.WatchFiles("test", []*lsproto.FileSystemWatcher{{
|
||||
GlobPattern: lsproto.PatternOrRelativePattern{Pattern: &pattern},
|
||||
Kind: &kind,
|
||||
}}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
time.Sleep(200 * time.Millisecond)
|
||||
|
||||
file := filepath.Join(dir, "a.ts")
|
||||
if err := os.WriteFile(file, []byte("export {}"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
collected := func() []*lsproto.FileEvent {
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
var all []*lsproto.FileEvent
|
||||
for _, b := range batches {
|
||||
all = append(all, b...)
|
||||
}
|
||||
return all
|
||||
}
|
||||
|
||||
waitFor(t, func() bool {
|
||||
for _, e := range collected() {
|
||||
if e.Type == lsproto.FileChangeTypeChanged {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}, "update event")
|
||||
|
||||
if err := os.Remove(file); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
waitFor(t, func() bool {
|
||||
for _, e := range collected() {
|
||||
if e.Type == lsproto.FileChangeTypeDeleted {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}, "delete event")
|
||||
|
||||
if err := w.UnwatchFiles("test"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWatcher_KindFilter(t *testing.T) {
|
||||
t.Parallel()
|
||||
dir := t.TempDir()
|
||||
dirNorm := tspath.NormalizeSlashes(dir)
|
||||
|
||||
var (
|
||||
mu sync.Mutex
|
||||
got []*lsproto.FileEvent
|
||||
)
|
||||
backend := newFakeBackend()
|
||||
w := newWithBackend(bundled.WrapFS(osvfs.FS()), backend, func(changes []*lsproto.FileEvent) {
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
got = append(got, changes...)
|
||||
}, logging.NewLogger(os.Stderr))
|
||||
t.Cleanup(w.Close)
|
||||
|
||||
pattern := dirNorm + "/**/*"
|
||||
kind := lsproto.WatchKindDelete
|
||||
if err := w.WatchFiles("test", []*lsproto.FileSystemWatcher{{
|
||||
GlobPattern: lsproto.PatternOrRelativePattern{Pattern: &pattern},
|
||||
Kind: &kind,
|
||||
}}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
backend.emitAll([]fswatch.Event{
|
||||
{Kind: fswatch.EventUpdate, Path: filepath.FromSlash(filepath.Join(dirNorm, "x.ts"))},
|
||||
{Kind: fswatch.EventDelete, Path: filepath.FromSlash(filepath.Join(dirNorm, "x.ts"))},
|
||||
}, nil)
|
||||
|
||||
waitFor(t, func() bool {
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
for _, e := range got {
|
||||
if e.Type == lsproto.FileChangeTypeDeleted {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}, "delete event")
|
||||
|
||||
mu.Lock()
|
||||
for _, e := range got {
|
||||
if e.Type != lsproto.FileChangeTypeDeleted {
|
||||
t.Errorf("unexpected non-delete event: %+v", e)
|
||||
}
|
||||
}
|
||||
mu.Unlock()
|
||||
}
|
||||
|
||||
func TestRootFromGlob(t *testing.T) {
|
||||
t.Parallel()
|
||||
cases := []struct {
|
||||
pattern string
|
||||
want string
|
||||
}{
|
||||
{"/abs/path/**/*", "/abs/path"},
|
||||
{"/abs/path/", "/abs/path"},
|
||||
{"/abs/path/?.ts", "/abs/path"},
|
||||
{"/abs/path/{a,b}/*", "/abs/path"},
|
||||
}
|
||||
for _, c := range cases {
|
||||
if got := rootFromGlob(c.pattern); got != c.want {
|
||||
t.Errorf("rootFromGlob(%q) = %q, want %q", c.pattern, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
type fakeBackend struct {
|
||||
mu sync.Mutex
|
||||
byDir map[string]fswatch.WatchCallback
|
||||
closed map[string]int
|
||||
optCount map[string]int
|
||||
failDirs map[string]error
|
||||
}
|
||||
|
||||
func newFakeBackend() *fakeBackend {
|
||||
return &fakeBackend{
|
||||
byDir: make(map[string]fswatch.WatchCallback),
|
||||
closed: make(map[string]int),
|
||||
optCount: make(map[string]int),
|
||||
failDirs: make(map[string]error),
|
||||
}
|
||||
}
|
||||
|
||||
func (f *fakeBackend) WatchDirectory(dir string, fn fswatch.WatchCallback, opts ...fswatch.WatchOption) (io.Closer, error) {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
if err := f.failDirs[dir]; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
f.byDir[dir] = fn
|
||||
f.optCount[dir] = len(opts)
|
||||
return fakeWatch{closeFn: func() error {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
delete(f.byDir, dir)
|
||||
f.closed[dir]++
|
||||
return nil
|
||||
}}, nil
|
||||
}
|
||||
|
||||
// watchedDirs returns the directories currently subscribed, for assertions.
|
||||
func (f *fakeBackend) watchedDirs() []string {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
dirs := make([]string, 0, len(f.byDir))
|
||||
for d := range f.byDir {
|
||||
dirs = append(dirs, d)
|
||||
}
|
||||
return dirs
|
||||
}
|
||||
|
||||
func (f *fakeBackend) isWatching(dir string) bool {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
_, ok := f.byDir[dir]
|
||||
return ok
|
||||
}
|
||||
|
||||
func (f *fakeBackend) emit(dir string, events []fswatch.Event, err error) {
|
||||
f.mu.Lock()
|
||||
cb := f.byDir[dir]
|
||||
f.mu.Unlock()
|
||||
if cb != nil {
|
||||
cb(events, err)
|
||||
}
|
||||
}
|
||||
|
||||
func (f *fakeBackend) emitAll(events []fswatch.Event, err error) {
|
||||
f.mu.Lock()
|
||||
cbs := make([]fswatch.WatchCallback, 0, len(f.byDir))
|
||||
for _, cb := range f.byDir {
|
||||
cbs = append(cbs, cb)
|
||||
}
|
||||
f.mu.Unlock()
|
||||
for _, cb := range cbs {
|
||||
cb(events, err)
|
||||
}
|
||||
}
|
||||
|
||||
type fakeWatch struct{ closeFn func() error }
|
||||
|
||||
func (w fakeWatch) Close() error { return w.closeFn() }
|
||||
|
||||
func TestWatcher_BookkeepingAndOverflow(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
dir := t.TempDir()
|
||||
dirNorm := tspath.NormalizeSlashes(dir)
|
||||
pattern := dirNorm + "/**/*"
|
||||
|
||||
fs := bundled.WrapFS(osvfs.FS())
|
||||
backend := newFakeBackend()
|
||||
var (
|
||||
mu sync.Mutex
|
||||
got []*lsproto.FileEvent
|
||||
)
|
||||
w := newWithBackend(fs, backend, func(changes []*lsproto.FileEvent) {
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
got = append(got, changes...)
|
||||
}, logging.NewLogger(os.Stderr))
|
||||
|
||||
if err := w.WatchFiles("id", []*lsproto.FileSystemWatcher{{
|
||||
GlobPattern: lsproto.PatternOrRelativePattern{Pattern: &pattern},
|
||||
}}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := w.WatchFiles("id", []*lsproto.FileSystemWatcher{{
|
||||
GlobPattern: lsproto.PatternOrRelativePattern{Pattern: &pattern},
|
||||
}}); err == nil {
|
||||
t.Fatal("expected duplicate-id error")
|
||||
}
|
||||
|
||||
backend.emitAll([]fswatch.Event{
|
||||
{Kind: fswatch.EventUpdate, Path: filepath.FromSlash(filepath.Join(dirNorm, "a.ts"))},
|
||||
}, fswatch.ErrOverflow)
|
||||
waitFor(t, func() bool {
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
return len(got) > 0
|
||||
}, "events after overflow")
|
||||
|
||||
if err := w.UnwatchFiles("missing"); err == nil {
|
||||
t.Fatal("expected unknown-id error")
|
||||
}
|
||||
if err := w.UnwatchFiles("id"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := w.WatchFiles("id2", nil); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
w.Close()
|
||||
if err := w.WatchFiles("id3", nil); err == nil {
|
||||
t.Fatal("expected closed error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestWatcher_NonRecursiveGlobIsNotRecursive(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
dir := t.TempDir()
|
||||
dirNorm := tspath.NormalizeSlashes(dir)
|
||||
if err := os.MkdirAll(filepath.Join(dir, "sub"), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
subNorm := tspath.NormalizeSlashes(filepath.Join(dir, "sub"))
|
||||
|
||||
fs := bundled.WrapFS(osvfs.FS())
|
||||
backend := newFakeBackend()
|
||||
w := newWithBackend(fs, backend, func([]*lsproto.FileEvent) {}, logging.NewLogger(os.Stderr))
|
||||
t.Cleanup(w.Close)
|
||||
|
||||
recursive := dirNorm + "/**/*"
|
||||
nonRecursive := subNorm + "/*"
|
||||
if err := w.WatchFiles("id", []*lsproto.FileSystemWatcher{
|
||||
{GlobPattern: lsproto.PatternOrRelativePattern{Pattern: &recursive}},
|
||||
{GlobPattern: lsproto.PatternOrRelativePattern{Pattern: &nonRecursive}},
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
backend.mu.Lock()
|
||||
defer backend.mu.Unlock()
|
||||
if got := backend.optCount[dirNorm]; got != 1 {
|
||||
t.Errorf("recursive glob %q: expected 1 watch option (WithRecursive), got %d", recursive, got)
|
||||
}
|
||||
if got := backend.optCount[subNorm]; got != 0 {
|
||||
t.Errorf("non-recursive glob %q: expected 0 watch options, got %d", nonRecursive, got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWatcher_RealBackend_MissingThenCreate(t *testing.T) {
|
||||
t.Parallel()
|
||||
base := t.TempDir()
|
||||
fs := bundled.WrapFS(osvfs.FS())
|
||||
|
||||
var (
|
||||
mu sync.Mutex
|
||||
batches [][]*lsproto.FileEvent
|
||||
)
|
||||
w := New(fs, func(changes []*lsproto.FileEvent) {
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
batches = append(batches, changes)
|
||||
}, logging.NewLogger(os.Stderr))
|
||||
t.Cleanup(w.Close)
|
||||
|
||||
// Watch a directory that does not exist yet.
|
||||
target := tspath.NormalizeSlashes(filepath.Join(base, "pkg"))
|
||||
pattern := target + "/*"
|
||||
kind := lsproto.WatchKindCreate | lsproto.WatchKindChange | lsproto.WatchKindDelete
|
||||
if err := w.WatchFiles("test", []*lsproto.FileSystemWatcher{{
|
||||
GlobPattern: lsproto.PatternOrRelativePattern{Pattern: &pattern},
|
||||
Kind: &kind,
|
||||
}}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// Give the ancestor watch time to install.
|
||||
time.Sleep(200 * time.Millisecond)
|
||||
|
||||
// Create the target directory and a file inside it. The real backend's
|
||||
// ancestor watch should fire, promote to the target, and
|
||||
// the file should ultimately surface.
|
||||
if err := os.MkdirAll(filepath.Join(base, "pkg"), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(base, "pkg", "index.ts"), []byte("export {}"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
collected := func() []*lsproto.FileEvent {
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
var all []*lsproto.FileEvent
|
||||
for _, b := range batches {
|
||||
all = append(all, b...)
|
||||
}
|
||||
return all
|
||||
}
|
||||
|
||||
waitFor(t, func() bool {
|
||||
for _, e := range collected() {
|
||||
if strings.HasSuffix(string(e.Uri), "/pkg/index.ts") {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}, "event for file created in a previously-missing directory")
|
||||
}
|
||||
|
||||
func TestWatcher_MissingDirectoryTracksAncestor(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
fs := bundled.WrapFS(osvfs.FS())
|
||||
backend := newFakeBackend()
|
||||
w := newWithBackend(fs, backend, func([]*lsproto.FileEvent) {}, logging.NewLogger(os.Stderr))
|
||||
t.Cleanup(w.Close)
|
||||
|
||||
base := t.TempDir()
|
||||
baseNorm := tspath.NormalizeSlashes(base)
|
||||
target := tspath.NormalizeSlashes(filepath.Join(base, "pkg"))
|
||||
pattern := target + "/*"
|
||||
|
||||
if err := w.WatchFiles("id", []*lsproto.FileSystemWatcher{{
|
||||
GlobPattern: lsproto.PatternOrRelativePattern{Pattern: &pattern},
|
||||
}}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// A missing target directory installs an ancestor watch on the nearest
|
||||
// existing ancestor (the base dir), not on the target.
|
||||
if !backend.isWatching(baseNorm) {
|
||||
t.Fatalf("expected ancestor watch on ancestor %q, watched: %v", baseNorm, backend.watchedDirs())
|
||||
}
|
||||
if dirs := backend.watchedDirs(); len(dirs) != 1 {
|
||||
t.Fatalf("expected exactly one (ancestor) watch, got %v", dirs)
|
||||
}
|
||||
|
||||
if err := w.UnwatchFiles("id"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if dirs := backend.watchedDirs(); len(dirs) != 0 {
|
||||
t.Fatalf("expected all watches closed after unwatch, got %v", dirs)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWatcher_MissingDirectoryPromotesOnCreate(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
fs := bundled.WrapFS(osvfs.FS())
|
||||
backend := newFakeBackend()
|
||||
var (
|
||||
mu sync.Mutex
|
||||
got []*lsproto.FileEvent
|
||||
)
|
||||
w := newWithBackend(fs, backend, func(changes []*lsproto.FileEvent) {
|
||||
mu.Lock()
|
||||
got = append(got, changes...)
|
||||
mu.Unlock()
|
||||
}, logging.NewLogger(os.Stderr))
|
||||
t.Cleanup(w.Close)
|
||||
|
||||
base := t.TempDir()
|
||||
baseNorm := tspath.NormalizeSlashes(base)
|
||||
target := tspath.NormalizeSlashes(filepath.Join(base, "pkg"))
|
||||
pattern := target + "/*"
|
||||
kind := lsproto.WatchKindCreate | lsproto.WatchKindChange | lsproto.WatchKindDelete
|
||||
|
||||
if err := w.WatchFiles("id", []*lsproto.FileSystemWatcher{{
|
||||
GlobPattern: lsproto.PatternOrRelativePattern{Pattern: &pattern},
|
||||
Kind: &kind,
|
||||
}}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// Create the target directory with a file, then notify the ancestor watch.
|
||||
if err := os.MkdirAll(filepath.Join(base, "pkg"), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(base, "pkg", "index.ts"), []byte("export {}"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
backend.emit(baseNorm, []fswatch.Event{
|
||||
{Kind: fswatch.EventUpdate, Path: filepath.Join(base, "pkg")},
|
||||
}, nil)
|
||||
|
||||
waitFor(t, func() bool { return backend.isWatching(target) }, "promotion to target watch")
|
||||
|
||||
// Synthetic creates must cover the target dir and its immediate child so
|
||||
// the session re-resolves files created before the watch was installed.
|
||||
waitFor(t, func() bool {
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
var sawDir, sawChild bool
|
||||
for _, e := range got {
|
||||
if e.Type != lsproto.FileChangeTypeCreated {
|
||||
continue
|
||||
}
|
||||
s := string(e.Uri)
|
||||
if strings.HasSuffix(s, "/pkg") {
|
||||
sawDir = true
|
||||
}
|
||||
if strings.HasSuffix(s, "/pkg/index.ts") {
|
||||
sawChild = true
|
||||
}
|
||||
}
|
||||
return sawDir && sawChild
|
||||
}, "synthetic create events for target and child")
|
||||
}
|
||||
|
||||
func TestWatcher_MultiLevelDescend(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
fs := bundled.WrapFS(osvfs.FS())
|
||||
backend := newFakeBackend()
|
||||
w := newWithBackend(fs, backend, func([]*lsproto.FileEvent) {}, logging.NewLogger(os.Stderr))
|
||||
t.Cleanup(w.Close)
|
||||
|
||||
base := t.TempDir()
|
||||
baseNorm := tspath.NormalizeSlashes(base)
|
||||
target := tspath.NormalizeSlashes(filepath.Join(base, "a", "b", "c"))
|
||||
pattern := target + "/*"
|
||||
|
||||
if err := w.WatchFiles("id", []*lsproto.FileSystemWatcher{{
|
||||
GlobPattern: lsproto.PatternOrRelativePattern{Pattern: &pattern},
|
||||
}}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !backend.isWatching(baseNorm) {
|
||||
t.Fatalf("expected initial ancestor watch on %q, got %v", baseNorm, backend.watchedDirs())
|
||||
}
|
||||
|
||||
// Reveal one path component at a time; the ancestor watch should descend.
|
||||
mkdirAndPath := func(rel string) string {
|
||||
p := filepath.Join(base, rel)
|
||||
if err := os.MkdirAll(p, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return tspath.NormalizeSlashes(p)
|
||||
}
|
||||
|
||||
aDir := mkdirAndPath("a")
|
||||
backend.emit(baseNorm, []fswatch.Event{{Kind: fswatch.EventUpdate, Path: filepath.Join(base, "a")}}, nil)
|
||||
waitFor(t, func() bool { return backend.isWatching(aDir) }, "descend to a")
|
||||
|
||||
abDir := mkdirAndPath(filepath.Join("a", "b"))
|
||||
backend.emit(aDir, []fswatch.Event{{Kind: fswatch.EventUpdate, Path: filepath.Join(base, "a", "b")}}, nil)
|
||||
waitFor(t, func() bool { return backend.isWatching(abDir) }, "descend to a/b")
|
||||
|
||||
abcDir := mkdirAndPath(filepath.Join("a", "b", "c"))
|
||||
backend.emit(abDir, []fswatch.Event{{Kind: fswatch.EventUpdate, Path: filepath.Join(base, "a", "b", "c")}}, nil)
|
||||
waitFor(t, func() bool { return backend.isWatching(abcDir) }, "promote to target a/b/c")
|
||||
}
|
||||
|
||||
func TestWatcher_AtomicTreeCreateRace(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
fs := bundled.WrapFS(osvfs.FS())
|
||||
backend := newFakeBackend()
|
||||
w := newWithBackend(fs, backend, func([]*lsproto.FileEvent) {}, logging.NewLogger(os.Stderr))
|
||||
t.Cleanup(w.Close)
|
||||
|
||||
base := t.TempDir()
|
||||
baseNorm := tspath.NormalizeSlashes(base)
|
||||
target := tspath.NormalizeSlashes(filepath.Join(base, "a", "b", "c"))
|
||||
pattern := target + "/*"
|
||||
|
||||
if err := w.WatchFiles("id", []*lsproto.FileSystemWatcher{{
|
||||
GlobPattern: lsproto.PatternOrRelativePattern{Pattern: &pattern},
|
||||
}}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// The whole tree appears at once (e.g. an extraction/symlink). A single
|
||||
// notification on the base ancestor watch must descend all the way and
|
||||
// promote to the target in one reconcile pass.
|
||||
if err := os.MkdirAll(filepath.Join(base, "a", "b", "c"), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
backend.emit(baseNorm, []fswatch.Event{{Kind: fswatch.EventUpdate, Path: filepath.Join(base, "a")}}, nil)
|
||||
|
||||
waitFor(t, func() bool { return backend.isWatching(target) }, "promote to target in one pass")
|
||||
}
|
||||
|
||||
func TestWatcher_SyntheticCreateDepth(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
for _, recursive := range []bool{false, true} {
|
||||
t.Run(map[bool]string{false: "non-recursive", true: "recursive"}[recursive], func(t *testing.T) {
|
||||
t.Parallel()
|
||||
fs := bundled.WrapFS(osvfs.FS())
|
||||
backend := newFakeBackend()
|
||||
var (
|
||||
mu sync.Mutex
|
||||
got []*lsproto.FileEvent
|
||||
)
|
||||
w := newWithBackend(fs, backend, func(changes []*lsproto.FileEvent) {
|
||||
mu.Lock()
|
||||
got = append(got, changes...)
|
||||
mu.Unlock()
|
||||
}, logging.NewLogger(os.Stderr))
|
||||
t.Cleanup(w.Close)
|
||||
|
||||
base := t.TempDir()
|
||||
baseNorm := tspath.NormalizeSlashes(base)
|
||||
target := tspath.NormalizeSlashes(filepath.Join(base, "pkg"))
|
||||
kind := lsproto.WatchKindCreate | lsproto.WatchKindChange | lsproto.WatchKindDelete
|
||||
var pattern string
|
||||
if recursive {
|
||||
pattern = target + "/**/*"
|
||||
} else {
|
||||
pattern = target + "/*"
|
||||
}
|
||||
|
||||
if err := w.WatchFiles("id", []*lsproto.FileSystemWatcher{{
|
||||
GlobPattern: lsproto.PatternOrRelativePattern{Pattern: &pattern},
|
||||
Kind: &kind,
|
||||
}}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// Materialize the target with a nested file under a subdirectory.
|
||||
if err := os.MkdirAll(filepath.Join(base, "pkg", "sub"), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(base, "pkg", "top.ts"), []byte("export {}"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(base, "pkg", "sub", "deep.ts"), []byte("export {}"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
backend.emit(baseNorm, []fswatch.Event{{Kind: fswatch.EventUpdate, Path: filepath.Join(base, "pkg")}}, nil)
|
||||
|
||||
waitFor(t, func() bool { return backend.isWatching(target) }, "promotion")
|
||||
|
||||
created := func() map[string]bool {
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
m := map[string]bool{}
|
||||
for _, e := range got {
|
||||
if e.Type == lsproto.FileChangeTypeCreated {
|
||||
m[string(e.Uri)] = true
|
||||
}
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
||||
// Both modes must synthesize the immediate child.
|
||||
waitFor(t, func() bool {
|
||||
for s := range created() {
|
||||
if strings.HasSuffix(s, "/pkg/top.ts") {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}, "synthetic create for immediate child")
|
||||
|
||||
// Only the recursive watch should synthesize the deep descendant.
|
||||
deadline := time.Now().Add(1 * time.Second)
|
||||
sawDeep := func() bool {
|
||||
for s := range created() {
|
||||
if strings.HasSuffix(s, "/pkg/sub/deep.ts") {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
if recursive {
|
||||
for time.Now().Before(deadline) && !sawDeep() {
|
||||
time.Sleep(20 * time.Millisecond)
|
||||
}
|
||||
if !sawDeep() {
|
||||
t.Errorf("recursive watch should synthesize deep descendant; got %v", created())
|
||||
}
|
||||
} else {
|
||||
time.Sleep(300 * time.Millisecond) // allow any erroneous deep event to arrive
|
||||
if sawDeep() {
|
||||
t.Errorf("non-recursive watch must not synthesize deep descendant; got %v", created())
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestWatcher_TerminatedFallsBackAndRecovers(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
fs := bundled.WrapFS(osvfs.FS())
|
||||
backend := newFakeBackend()
|
||||
var (
|
||||
mu sync.Mutex
|
||||
got []*lsproto.FileEvent
|
||||
)
|
||||
w := newWithBackend(fs, backend, func(changes []*lsproto.FileEvent) {
|
||||
mu.Lock()
|
||||
got = append(got, changes...)
|
||||
mu.Unlock()
|
||||
}, logging.NewLogger(os.Stderr))
|
||||
t.Cleanup(w.Close)
|
||||
|
||||
base := t.TempDir()
|
||||
baseNorm := tspath.NormalizeSlashes(base)
|
||||
target := tspath.NormalizeSlashes(filepath.Join(base, "pkg"))
|
||||
if err := os.MkdirAll(filepath.Join(base, "pkg"), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
pattern := target + "/*"
|
||||
kind := lsproto.WatchKindCreate | lsproto.WatchKindChange | lsproto.WatchKindDelete
|
||||
|
||||
if err := w.WatchFiles("id", []*lsproto.FileSystemWatcher{{
|
||||
GlobPattern: lsproto.PatternOrRelativePattern{Pattern: &pattern},
|
||||
Kind: &kind,
|
||||
}}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !backend.isWatching(target) {
|
||||
t.Fatalf("expected target watch on %q, got %v", target, backend.watchedDirs())
|
||||
}
|
||||
|
||||
// Delete the directory and deliver ErrWatchTerminated together with the
|
||||
// directory's own delete event (as the real backends do).
|
||||
if err := os.RemoveAll(filepath.Join(base, "pkg")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
backend.emit(target, []fswatch.Event{
|
||||
{Kind: fswatch.EventDelete, Path: filepath.Join(base, "pkg")},
|
||||
}, errors.Join(fswatch.ErrWatchTerminated, errors.New("removed")))
|
||||
|
||||
// The delete must be forwarded, and the watch must fall back to watching
|
||||
// the ancestor.
|
||||
waitFor(t, func() bool {
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
for _, e := range got {
|
||||
if e.Type == lsproto.FileChangeTypeDeleted && strings.HasSuffix(string(e.Uri), "/pkg") {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}, "forwarded delete of terminated dir")
|
||||
waitFor(t, func() bool { return backend.isWatching(baseNorm) && !backend.isWatching(target) }, "fallback to ancestor watch")
|
||||
|
||||
// Recreate the directory; the ancestor watch must promote back to target.
|
||||
if err := os.MkdirAll(filepath.Join(base, "pkg"), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
backend.emit(baseNorm, []fswatch.Event{{Kind: fswatch.EventUpdate, Path: filepath.Join(base, "pkg")}}, nil)
|
||||
waitFor(t, func() bool { return backend.isWatching(target) }, "recovery to target watch after recreation")
|
||||
}
|
||||
|
||||
func TestWatcher_GenuineFailureRollsBackForRetry(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
fs := bundled.WrapFS(osvfs.FS())
|
||||
backend := newFakeBackend()
|
||||
w := newWithBackend(fs, backend, func([]*lsproto.FileEvent) {}, logging.NewLogger(os.Stderr))
|
||||
t.Cleanup(w.Close)
|
||||
|
||||
dir := t.TempDir()
|
||||
dirNorm := tspath.NormalizeSlashes(dir)
|
||||
pattern := dirNorm + "/*"
|
||||
|
||||
// Inject a genuine backend failure for the existing directory.
|
||||
backend.mu.Lock()
|
||||
backend.failDirs[dirNorm] = errors.New("too many open files")
|
||||
backend.mu.Unlock()
|
||||
|
||||
err := w.WatchFiles("id", []*lsproto.FileSystemWatcher{{
|
||||
GlobPattern: lsproto.PatternOrRelativePattern{Pattern: &pattern},
|
||||
}})
|
||||
if err == nil {
|
||||
t.Fatal("expected error from genuine backend failure")
|
||||
}
|
||||
|
||||
// The id must have been rolled back so a retry can re-register cleanly
|
||||
// (rather than hitting the duplicate-id error). Clear the injected failure
|
||||
// to simulate the resource pressure easing on retry.
|
||||
backend.mu.Lock()
|
||||
delete(backend.failDirs, dirNorm)
|
||||
backend.mu.Unlock()
|
||||
|
||||
if err := w.WatchFiles("id", []*lsproto.FileSystemWatcher{{
|
||||
GlobPattern: lsproto.PatternOrRelativePattern{Pattern: &pattern},
|
||||
}}); err != nil {
|
||||
t.Fatalf("retry after rollback should succeed, got %v", err)
|
||||
}
|
||||
if !backend.isWatching(dirNorm) {
|
||||
t.Fatalf("expected watch on %q after successful retry, got %v", dirNorm, backend.watchedDirs())
|
||||
}
|
||||
}
|
||||
|
||||
func TestWatcher_WatchTerminatedDoesNotDropEvents(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
dir := t.TempDir()
|
||||
dirNorm := tspath.NormalizeSlashes(dir)
|
||||
fs := bundled.WrapFS(osvfs.FS())
|
||||
backend := newFakeBackend()
|
||||
var got []*lsproto.FileEvent
|
||||
var mu sync.Mutex
|
||||
w := newWithBackend(fs, backend, func(changes []*lsproto.FileEvent) {
|
||||
mu.Lock()
|
||||
got = append(got, changes...)
|
||||
mu.Unlock()
|
||||
}, logging.NewLogger(os.Stderr))
|
||||
|
||||
pattern := dirNorm + "/**/*"
|
||||
if err := w.WatchFiles("id", []*lsproto.FileSystemWatcher{{
|
||||
GlobPattern: lsproto.PatternOrRelativePattern{Pattern: &pattern},
|
||||
}}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
backend.emitAll([]fswatch.Event{
|
||||
{Kind: fswatch.EventUpdate, Path: filepath.FromSlash(filepath.Join(dirNorm, "b.ts"))},
|
||||
}, errors.Join(fswatch.ErrWatchTerminated, errors.New("simulated")))
|
||||
|
||||
waitFor(t, func() bool {
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
return len(got) > 0
|
||||
}, "events with watch-terminated error")
|
||||
}
|
||||
Reference in New Issue
Block a user