Files
wehub-resource-sync f99010fae1
CI / lint (push) Failing after 1s
CI / frontend (push) Failing after 1s
CI / scripts (push) Failing after 1s
CI / Go Test (ubuntu-latest) (push) Failing after 0s
CI / frontend-node-25 (push) Failing after 1s
CI / docs (push) Failing after 0s
CI / coverage (push) Failing after 0s
CI / e2e (push) Failing after 0s
Docker / build-and-push (push) Failing after 1s
CI / integration (push) Failing after 4m43s
CI / Go Test (windows-latest) (push) Has been cancelled
CI / Desktop Unit Tests (Windows) (push) Has been cancelled
Desktop Artifacts / Desktop Build (Linux (arm64)) (push) Has been cancelled
Desktop Artifacts / Desktop Build (Linux) (push) Has been cancelled
Desktop Artifacts / Desktop Build (Windows) (push) Has been cancelled
Desktop Artifacts (macOS) / Desktop Build (macOS (aarch64)) (push) Has been cancelled
Desktop Artifacts (macOS) / Desktop Build (macOS (x86_64)) (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 12:30:36 +08:00

545 lines
13 KiB
Go

package sync
import (
"errors"
"fmt"
"io/fs"
"log"
"math"
"os"
"path/filepath"
"slices"
"strings"
"sync"
"syscall"
"time"
"github.com/fsnotify/fsnotify"
)
type RecursiveWatchResult struct {
Watched int
Unwatched int
Err error
BudgetExhausted bool
ResourceExhausted bool
ResourceExhaustedAt string
}
const (
// A callback can hold one batch while the event loop accumulates the next.
// Bounding each batch keeps watcher backpressure below two bounded path
// sets instead of allowing a slow sync to retain an unbounded event storm.
defaultWatchBatchMaxEntries = 8192
defaultWatchBatchMaxPathBytes = 2 << 20
)
// WatchBatch describes one serialized watcher callback. FullSync is an
// explicit overflow signal: Paths is empty and the consumer must rescan all
// configured sources so coalescing never silently drops a changed path.
type WatchBatch struct {
Paths []string
FullSync bool
}
// pendingWatchBatch bounds retained strings by both unique path count and the
// sum of their byte lengths. Map overhead is bounded separately by the entry
// limit. Once either limit would be exceeded, individual paths are discarded
// in favor of one full-sync marker until Take resets the accumulator.
type pendingWatchBatch struct {
paths map[string]struct{}
pathBytes int
maxEntries int
maxPathBytes int
fullSync bool
}
func newPendingWatchBatch(maxEntries, maxPathBytes int) *pendingWatchBatch {
return &pendingWatchBatch{
paths: make(map[string]struct{}),
maxEntries: maxEntries,
maxPathBytes: maxPathBytes,
}
}
func (p *pendingWatchBatch) Empty() bool {
return !p.fullSync && len(p.paths) == 0
}
func (p *pendingWatchBatch) Add(path string) {
if p.fullSync {
return
}
if _, exists := p.paths[path]; exists {
return
}
if len(p.paths)+1 > p.maxEntries ||
p.pathBytes+len(path) > p.maxPathBytes {
clear(p.paths)
p.pathBytes = 0
p.fullSync = true
return
}
p.paths[path] = struct{}{}
p.pathBytes += len(path)
}
func (p *pendingWatchBatch) Take() (WatchBatch, bool) {
if p.Empty() {
return WatchBatch{}, false
}
if p.fullSync {
p.fullSync = false
return WatchBatch{FullSync: true}, true
}
paths := make([]string, 0, len(p.paths))
for path := range p.paths {
paths = append(paths, path)
}
slices.Sort(paths)
clear(p.paths)
p.pathBytes = 0
return WatchBatch{Paths: paths}, true
}
// Watcher uses fsnotify to watch session directories for changes and triggers
// serialized callbacks with short-burst batching and a dispatch floor.
type Watcher struct {
onChange func(batch WatchBatch)
watcher *fsnotify.Watcher
batchDelay time.Duration
minInterval time.Duration
maxEntries int
maxPathBytes int
excludes []string
roots []string
shallow []string
rootsMu sync.RWMutex
dispatchMu sync.Mutex
stopping bool
stop chan struct{}
done chan struct{}
stopOnce sync.Once
}
// NewWatcher creates a file watcher that uses delay for both batching and the
// minimum interval between callbacks.
func NewWatcher(delay time.Duration, onChange func(batch WatchBatch), excludes []string) (*Watcher, error) {
return NewWatcherWithInterval(delay, delay, onChange, excludes)
}
// NewWatcherWithInterval creates a file watcher with separate batching and
// minimum callback intervals.
func NewWatcherWithInterval(
batchDelay, minInterval time.Duration,
onChange func(batch WatchBatch), excludes []string,
) (*Watcher, error) {
return newWatcherWithLimits(
batchDelay,
minInterval,
onChange,
excludes,
defaultWatchBatchMaxEntries,
defaultWatchBatchMaxPathBytes,
)
}
func newWatcherWithLimits(
batchDelay, minInterval time.Duration,
onChange func(batch WatchBatch), excludes []string,
maxEntries, maxPathBytes int,
) (*Watcher, error) {
if onChange == nil {
return nil, fmt.Errorf("onChange callback is nil: %w", os.ErrInvalid)
}
fsw, err := fsnotify.NewWatcher()
if err != nil {
return nil, err
}
w := &Watcher{
onChange: onChange,
watcher: fsw,
batchDelay: batchDelay,
minInterval: minInterval,
maxEntries: maxEntries,
maxPathBytes: maxPathBytes,
excludes: normalizeExcludePatterns(excludes),
stop: make(chan struct{}),
done: make(chan struct{}),
}
return w, nil
}
// WatchRecursive walks a directory tree and adds all
// subdirectories to the watch list. Returns the number
// of directories watched and unwatched (failed to add).
func (w *Watcher) WatchRecursive(root string) (watched int, unwatched int, err error) {
result := w.WatchRecursiveBudgeted(root, math.MaxInt)
return result.Watched, result.Unwatched, result.Err
}
// WatchRecursiveBudgeted walks a directory tree and adds at most
// budget subdirectories to the watch list. The walk stops as soon
// as the budget is exhausted or fsnotify reports resource
// exhaustion, so the caller can degrade the rest of the tree to
// polling without continuing to traverse it.
func (w *Watcher) WatchRecursiveBudgeted(root string, budget int) RecursiveWatchResult {
var result RecursiveWatchResult
root = filepath.Clean(root)
w.addRoot(root)
remaining := budget
result.Err = filepath.WalkDir(root,
func(path string, d fs.DirEntry, err error) error {
if err != nil {
return nil // skip inaccessible dirs
}
if !d.IsDir() {
return nil
}
// Skip entire excluded subtrees, but always keep the root.
if path != root && w.shouldExcludeForRoot(path, root) {
return filepath.SkipDir
}
if remaining <= 0 {
result.BudgetExhausted = true
return filepath.SkipAll
}
if addErr := w.watcher.Add(path); addErr != nil {
result.Unwatched++
if isWatchResourceExhaustion(addErr) {
result.ResourceExhausted = true
result.ResourceExhaustedAt = path
return filepath.SkipAll
}
return nil
}
remaining--
result.Watched++
return nil
})
if errors.Is(result.Err, filepath.SkipAll) {
result.Err = nil
}
return result
}
func isWatchResourceExhaustion(err error) bool {
return errors.Is(err, syscall.EMFILE) || errors.Is(err, syscall.ENOSPC)
}
// WatchShallow adds only the root directory to the watch list,
// without recursing into subdirectories. Use this for directories
// with many subdirectories where periodic sync handles changes.
// Returns true if the directory was successfully watched.
func (w *Watcher) WatchShallow(root string) bool {
root = filepath.Clean(root)
w.addRoot(root)
w.addShallowRoot(root)
return w.watcher.Add(root) == nil
}
// Start begins processing file events in a goroutine.
func (w *Watcher) Start() {
go w.loop()
}
// Stop stops the watcher and waits for it to finish.
func (w *Watcher) Stop() {
w.stopOnce.Do(func() {
w.dispatchMu.Lock()
w.stopping = true
close(w.stop)
w.dispatchMu.Unlock()
<-w.done
w.watcher.Close()
})
}
func (w *Watcher) loop() {
batches := make(chan WatchBatch)
callbackDone := make(chan time.Time, 1)
var worker sync.WaitGroup
worker.Go(func() {
for batch := range batches {
if batch.FullSync {
log.Printf("watcher: pending path limit exceeded, triggering full sync")
} else {
log.Printf("watcher: %d file(s) changed, triggering sync", len(batch.Paths))
}
startedAt := time.Now()
w.onChange(batch)
callbackDone <- startedAt
}
})
pending := newPendingWatchBatch(w.maxEntries, w.maxPathBytes)
var firstPendingAt time.Time
var lastDispatch time.Time
var timer *time.Timer
var timerC <-chan time.Time
callbackBusy := false
stopTimer := func() {
if timer != nil {
timer.Stop()
}
timer = nil
timerC = nil
}
defer func() {
stopTimer()
close(batches)
worker.Wait()
close(w.done)
}()
schedule := func() {
if callbackBusy || pending.Empty() || timerC != nil {
return
}
deadline := firstPendingAt.Add(w.batchDelay)
if !lastDispatch.IsZero() {
floor := lastDispatch.Add(w.minInterval)
if floor.After(deadline) {
deadline = floor
}
}
timer = time.NewTimer(time.Until(deadline))
timerC = timer.C
}
dispatch := func() bool {
if callbackBusy || pending.Empty() {
return true
}
w.dispatchMu.Lock()
defer w.dispatchMu.Unlock()
if w.stopping {
return false
}
batch, ok := pending.Take()
if !ok {
return true
}
firstPendingAt = time.Time{}
callbackBusy = true
batches <- batch
return true
}
for {
// Give shutdown priority over ready event and timer channels. Pending
// changes are recovered by the next startup sync.
select {
case <-w.stop:
return
default:
}
select {
case <-w.stop:
return
case event, ok := <-w.watcher.Events:
if !ok {
return
}
path, relevant := w.handleEvent(event)
if !relevant {
continue
}
if pending.Empty() {
firstPendingAt = time.Now()
}
pending.Add(path)
schedule()
case err, ok := <-w.watcher.Errors:
if !ok {
return
}
log.Printf("watcher error: %v", err)
case <-timerC:
timer = nil
timerC = nil
select {
case <-w.stop:
return
default:
}
if !dispatch() {
return
}
case startedAt := <-callbackDone:
lastDispatch = startedAt
callbackBusy = false
schedule()
}
}
}
// handleEvent processes a single fsnotify event, auto-watching
// newly created directories and returning relevant changed paths.
func (w *Watcher) handleEvent(event fsnotify.Event) (string, bool) {
if event.Op&(fsnotify.Write|
fsnotify.Create|
fsnotify.Remove|
fsnotify.Rename) == 0 {
return "", false
}
if event.Op&fsnotify.Create != 0 {
isDir, excluded := w.watchIfDir(event.Name)
if isDir && excluded {
return "", false
}
}
return filepath.Clean(event.Name), true
}
// watchIfDir adds a path to the watch list if it is a directory.
// Returns whether path is a directory and whether it was excluded.
func (w *Watcher) watchIfDir(path string) (isDir bool, excluded bool) {
info, err := os.Stat(path)
if err != nil || !info.IsDir() {
return false, false
}
if w.isUnderShallowRoot(path) {
return true, false
}
if w.shouldExclude(path) {
return true, true
}
_ = w.watcher.Add(path)
return true, false
}
func normalizeExcludePatterns(patterns []string) []string {
if len(patterns) == 0 {
return nil
}
out := make([]string, 0, len(patterns))
for _, p := range patterns {
p = strings.TrimSpace(filepath.Clean(p))
if p == "" || p == "." {
continue
}
if !slices.Contains(out, p) {
out = append(out, p)
}
}
return out
}
func (w *Watcher) addRoot(root string) {
w.rootsMu.Lock()
defer w.rootsMu.Unlock()
if !slices.Contains(w.roots, root) {
w.roots = append(w.roots, root)
}
}
func (w *Watcher) addShallowRoot(root string) {
w.rootsMu.Lock()
defer w.rootsMu.Unlock()
if !slices.Contains(w.shallow, root) {
w.shallow = append(w.shallow, root)
}
}
// isUnderShallowRoot reports whether path's most specific containing watch
// root is a shallow root. A path that also sits under a more specific
// recursive root (for example a new sessions/YYYY/MM/DD directory beneath a
// recursive sessions root that itself lives inside a shallow parent root) is
// NOT shadowed, so auto-watching still adds new subdirectories of recursive
// roots.
func (w *Watcher) isUnderShallowRoot(path string) bool {
root, ok := w.mostSpecificContainingRoot(path)
if !ok {
return false
}
w.rootsMu.RLock()
defer w.rootsMu.RUnlock()
return slices.Contains(w.shallow, root)
}
func (w *Watcher) shouldExclude(path string) bool {
if len(w.excludes) == 0 {
return false
}
root, ok := w.mostSpecificContainingRoot(path)
if !ok {
return false
}
return w.shouldExcludeForRoot(path, root)
}
func (w *Watcher) shouldExcludeForRoot(path string, root string) bool {
if len(w.excludes) == 0 {
return false
}
clean := filepath.Clean(path)
root = filepath.Clean(root)
rel, err := filepath.Rel(root, clean)
if err != nil {
return false
}
if rel == ".." || strings.HasPrefix(rel, ".."+string(filepath.Separator)) {
return false
}
rel = filepath.Clean(rel)
if rel == "." {
return false
}
parts := strings.Split(rel, string(filepath.Separator))
for _, pat := range w.excludes {
if strings.Contains(pat, string(filepath.Separator)) {
if ok, _ := filepath.Match(pat, rel); ok {
return true
}
continue
}
for _, part := range parts {
if ok, _ := filepath.Match(pat, part); ok {
return true
}
}
}
return false
}
func (w *Watcher) mostSpecificContainingRoot(path string) (string, bool) {
w.rootsMu.RLock()
defer w.rootsMu.RUnlock()
if len(w.roots) == 0 {
return "", false
}
clean := filepath.Clean(path)
var best string
for _, root := range w.roots {
rel, err := filepath.Rel(root, clean)
if err != nil {
continue
}
if rel == ".." || strings.HasPrefix(rel, ".."+string(filepath.Separator)) {
continue
}
if best == "" || len(root) > len(best) {
best = root
}
}
if best == "" {
return "", false
}
return best, true
}