185 lines
6 KiB
Go
185 lines
6 KiB
Go
package api
|
|
|
|
// This file implements a polling file watcher for esbuild (i.e. it detects
|
|
// when files are changed by repeatedly checking their contents). Polling is
|
|
// used instead of more efficient platform-specific file system APIs because:
|
|
//
|
|
// * Go's standard library doesn't have built-in APIs for file watching
|
|
// * Using platform-specific APIs means using cgo, which I want to avoid
|
|
// * Polling is cross-platform and esbuild needs to work on 20+ platforms
|
|
// * Platform-specific APIs might be unreliable and could introduce bugs
|
|
//
|
|
// That said, this polling system is designed to use relatively little CPU vs.
|
|
// a more traditional polling system that scans the whole directory tree at
|
|
// once. The file system is still scanned regularly but each scan only checks
|
|
// a random subset of your files, which means a change to a file will be picked
|
|
// up soon after the change is made but not necessarily instantly.
|
|
//
|
|
// With the current heuristics, large projects should be completely scanned
|
|
// around every 2 seconds so in the worst case it could take up to 2 seconds
|
|
// for a change to be noticed. However, after a change has been noticed the
|
|
// change's path goes on a short list of recently changed paths which are
|
|
// checked on every scan, so further changes to recently changed files should
|
|
// be noticed almost instantly.
|
|
|
|
import (
|
|
"fmt"
|
|
"math/rand"
|
|
"os"
|
|
"sync"
|
|
"sync/atomic"
|
|
"time"
|
|
|
|
"github.com/evanw/esbuild/internal/fs"
|
|
"github.com/evanw/esbuild/internal/logger"
|
|
"github.com/evanw/esbuild/internal/resolver"
|
|
)
|
|
|
|
// The time to wait between watch intervals
|
|
const watchIntervalSleep = 100 * time.Millisecond
|
|
|
|
// The maximum number of recently-edited items to check every interval
|
|
const maxRecentItemCount = 16
|
|
|
|
// The minimum number of non-recent items to check every interval
|
|
const minItemCountPerIter = 64
|
|
|
|
// The maximum number of intervals before a change is detected
|
|
const maxIntervalsBeforeUpdate = 20
|
|
|
|
type watcher struct {
|
|
data fs.WatchData
|
|
fs fs.FS
|
|
rebuild func() fs.WatchData
|
|
recentItems []string
|
|
itemsToScan []string
|
|
mutex sync.Mutex
|
|
itemsPerIteration int
|
|
shouldStop int32
|
|
stopWaitGroup sync.WaitGroup
|
|
}
|
|
|
|
func (w *watcher) setWatchData(data fs.WatchData) {
|
|
defer w.mutex.Unlock()
|
|
w.mutex.Lock()
|
|
w.data = data
|
|
w.itemsToScan = w.itemsToScan[:0] // Reuse memory
|
|
|
|
// Remove any recent items that weren't a part of the latest build
|
|
end := 0
|
|
for _, path := range w.recentItems {
|
|
if data.Paths[path] != nil {
|
|
w.recentItems[end] = path
|
|
end++
|
|
}
|
|
}
|
|
w.recentItems = w.recentItems[:end]
|
|
}
|
|
|
|
func (w *watcher) start(logLevel logger.LogLevel, useColor logger.UseColor) {
|
|
w.stopWaitGroup.Add(1)
|
|
|
|
go func() {
|
|
shouldLog := logLevel == logger.LevelInfo || logLevel == logger.LevelDebug || logLevel == logger.LevelVerbose
|
|
|
|
// Note: Do not change these log messages without a breaking version change.
|
|
// People want to run regexes over esbuild's stderr stream to look for these
|
|
// messages instead of using esbuild's API.
|
|
|
|
if shouldLog {
|
|
logger.PrintTextWithColor(os.Stderr, useColor, func(colors logger.Colors) string {
|
|
return fmt.Sprintf("%s[watch] build finished, watching for changes...%s\n", colors.Dim, colors.Reset)
|
|
})
|
|
}
|
|
|
|
for atomic.LoadInt32(&w.shouldStop) == 0 {
|
|
// Sleep for the watch interval
|
|
time.Sleep(watchIntervalSleep)
|
|
|
|
// Rebuild if we're dirty
|
|
if absPath := w.tryToFindDirtyPath(); absPath != "" {
|
|
if shouldLog {
|
|
logger.PrintTextWithColor(os.Stderr, useColor, func(colors logger.Colors) string {
|
|
prettyPath := resolver.PrettyPath(w.fs, logger.Path{Text: absPath, Namespace: "file"})
|
|
return fmt.Sprintf("%s[watch] build started (change: %q)%s\n", colors.Dim, prettyPath, colors.Reset)
|
|
})
|
|
}
|
|
|
|
// Run the build
|
|
w.setWatchData(w.rebuild())
|
|
|
|
if shouldLog {
|
|
logger.PrintTextWithColor(os.Stderr, useColor, func(colors logger.Colors) string {
|
|
return fmt.Sprintf("%s[watch] build finished%s\n", colors.Dim, colors.Reset)
|
|
})
|
|
}
|
|
}
|
|
}
|
|
|
|
w.stopWaitGroup.Done()
|
|
}()
|
|
}
|
|
|
|
func (w *watcher) stop() {
|
|
atomic.StoreInt32(&w.shouldStop, 1)
|
|
w.stopWaitGroup.Wait()
|
|
}
|
|
|
|
func (w *watcher) tryToFindDirtyPath() string {
|
|
defer w.mutex.Unlock()
|
|
w.mutex.Lock()
|
|
|
|
// If we ran out of items to scan, fill the items back up in a random order
|
|
if len(w.itemsToScan) == 0 {
|
|
items := w.itemsToScan[:0] // Reuse memory
|
|
for path := range w.data.Paths {
|
|
items = append(items, path)
|
|
}
|
|
rand.Seed(time.Now().UnixNano())
|
|
for i := int32(len(items) - 1); i > 0; i-- { // Fisher-Yates shuffle
|
|
j := rand.Int31n(i + 1)
|
|
items[i], items[j] = items[j], items[i]
|
|
}
|
|
w.itemsToScan = items
|
|
|
|
// Determine how many items to check every iteration, rounded up
|
|
perIter := (len(items) + maxIntervalsBeforeUpdate - 1) / maxIntervalsBeforeUpdate
|
|
if perIter < minItemCountPerIter {
|
|
perIter = minItemCountPerIter
|
|
}
|
|
w.itemsPerIteration = perIter
|
|
}
|
|
|
|
// Always check all recent items every iteration
|
|
for i, path := range w.recentItems {
|
|
if dirtyPath := w.data.Paths[path](); dirtyPath != "" {
|
|
// Move this path to the back of the list (i.e. the "most recent" position)
|
|
copy(w.recentItems[i:], w.recentItems[i+1:])
|
|
w.recentItems[len(w.recentItems)-1] = path
|
|
return dirtyPath
|
|
}
|
|
}
|
|
|
|
// Check a constant number of items every iteration
|
|
remainingCount := len(w.itemsToScan) - w.itemsPerIteration
|
|
if remainingCount < 0 {
|
|
remainingCount = 0
|
|
}
|
|
toCheck, remaining := w.itemsToScan[remainingCount:], w.itemsToScan[:remainingCount]
|
|
w.itemsToScan = remaining
|
|
|
|
// Check if any of the entries in this iteration have been modified
|
|
for _, path := range toCheck {
|
|
if dirtyPath := w.data.Paths[path](); dirtyPath != "" {
|
|
// Mark this item as recent by adding it to the back of the list
|
|
w.recentItems = append(w.recentItems, path)
|
|
if len(w.recentItems) > maxRecentItemCount {
|
|
// Remove items from the front of the list when we hit the limit
|
|
copy(w.recentItems, w.recentItems[1:])
|
|
w.recentItems = w.recentItems[:maxRecentItemCount]
|
|
}
|
|
return dirtyPath
|
|
}
|
|
}
|
|
return ""
|
|
}
|