From 84fb943a9445173dfa84723f736a45e8523d6ff7 Mon Sep 17 00:00:00 2001 From: Alliballibaba Date: Wed, 14 Oct 2026 17:15:53 +0200 Subject: [PATCH 01/19] adds go watcher --- caddy/go.mod | 1 + caddy/go.sum | 2 + go.mod | 1 + go.sum | 2 + hotreload.go | 4 +- internal/watcher/watcher.go | 182 +++++++++++++---------------------- internal/watcher/wildcard.go | 131 +++++++++++++++++++++++++ watcher.go | 6 +- 8 files changed, 208 insertions(+), 121 deletions(-) create mode 100644 internal/watcher/wildcard.go diff --git a/caddy/go.mod b/caddy/go.mod index 74ead77b02..305bd0624a 100644 --- a/caddy/go.mod +++ b/caddy/go.mod @@ -128,6 +128,7 @@ require ( github.com/rs/xid v1.6.0 // indirect github.com/russross/blackfriday/v2 v2.1.0 // indirect github.com/santhosh-tekuri/jsonschema/v6 v6.0.3 // indirect + github.com/sgtdi/fswatcher v1.3.0 // indirect github.com/shopspring/decimal v1.5.0 // indirect github.com/shurcooL/sanitized_anchor_name v1.0.0 // indirect github.com/sirupsen/logrus v1.10.2 // indirect diff --git a/caddy/go.sum b/caddy/go.sum index 29971dd6bb..a56ae4f1f6 100644 --- a/caddy/go.sum +++ b/caddy/go.sum @@ -356,6 +356,8 @@ github.com/santhosh-tekuri/jsonschema/v6 v6.0.3 h1:1EYB5IzjZawrrnELUi78f9fPu57Hu github.com/santhosh-tekuri/jsonschema/v6 v6.0.3/go.mod h1:JXeL+ps8p7/KNMjDQk3TCwPpBy0wYklyWTfbkIzdIFU= github.com/schollz/jsonstore v1.1.0 h1:WZBDjgezFS34CHI+myb4s8GGpir3UMpy7vWoCeO0n6E= github.com/schollz/jsonstore v1.1.0/go.mod h1:15c6+9guw8vDRyozGjN3FoILt0wpruJk9Pi66vjaZfg= +github.com/sgtdi/fswatcher v1.3.0 h1:2tFEnBml5EipRF4TvUP0x+T4ty2OSYlmvcnQ6dSTp04= +github.com/sgtdi/fswatcher v1.3.0/go.mod h1:I4FUeG0e27WFw+ogs5OjZSgPKobnGrUa17EwjRjZQaY= github.com/shopspring/decimal v1.5.0 h1:HxSWk31RmJv0EyY4oLiGQB9u7WPludUwPBdvQZnYFPc= github.com/shopspring/decimal v1.5.0/go.mod h1:gawqmDU56v4yIKSwfBSFip1HdCCXN8/+DMd9qYNcwME= github.com/shurcooL/sanitized_anchor_name v1.0.0 h1:PdmoCO6wvbs+7yrJyMORt4/BmY5IYyJwS/kOiWx8mHo= diff --git a/go.mod b/go.mod index c94eed6eae..cd1ccad6ac 100644 --- a/go.mod +++ b/go.mod @@ -10,6 +10,7 @@ require ( github.com/e-dant/watcher v0.0.0-20260223030516-06f84a1314be github.com/maypok86/otter/v2 v2.3.0 github.com/prometheus/client_golang v1.25.0 + github.com/sgtdi/fswatcher v1.3.0 github.com/stretchr/testify v1.12.1 golang.org/x/net v0.61.0 golang.org/x/sys v0.49.0 diff --git a/go.sum b/go.sum index 07af36776d..1fb3b4949d 100644 --- a/go.sum +++ b/go.sum @@ -74,6 +74,8 @@ github.com/rogpeppe/go-internal v1.13.1 h1:KvO1DLK/DRN07sQ1LQKScxyZJuNnedQ5/wKSR github.com/rogpeppe/go-internal v1.13.1/go.mod h1:uMEvuHeurkdAXX61udpOXGD/AzZDWNMNyH2VO9fmH0o= github.com/rs/cors v1.11.1 h1:eU3gRzXLRK57F5rKMGMZURNdIG4EoAmX8k94r9wXWHA= github.com/rs/cors v1.11.1/go.mod h1:XyqrcTp5zjWr1wsJ8PIRZssZ8b/WMcMf71DJnit4EMU= +github.com/sgtdi/fswatcher v1.3.0 h1:2tFEnBml5EipRF4TvUP0x+T4ty2OSYlmvcnQ6dSTp04= +github.com/sgtdi/fswatcher v1.3.0/go.mod h1:I4FUeG0e27WFw+ogs5OjZSgPKobnGrUa17EwjRjZQaY= github.com/shopspring/decimal v1.5.0 h1:HxSWk31RmJv0EyY4oLiGQB9u7WPludUwPBdvQZnYFPc= github.com/shopspring/decimal v1.5.0/go.mod h1:gawqmDU56v4yIKSwfBSFip1HdCCXN8/+DMd9qYNcwME= github.com/spf13/cast v1.10.0 h1:h2x0u2shc1QuLHfxi+cTJvs30+ZAHOGRic8uyGTDWxY= diff --git a/hotreload.go b/hotreload.go index dde11a6cf1..0a4150c11b 100644 --- a/hotreload.go +++ b/hotreload.go @@ -8,7 +8,7 @@ import ( "github.com/dunglas/frankenphp/internal/watcher" "github.com/dunglas/mercure" - watcherGo "github.com/e-dant/watcher/watcher-go" + "github.com/sgtdi/fswatcher" ) // WithHotReload sets files to watch for file changes to trigger a hot reload update. @@ -16,7 +16,7 @@ func WithHotReload(topic string, hub *mercure.Hub, patterns []string) Option { return func(o *opt) error { o.hotReload = append(o.hotReload, &watcher.PatternGroup{ Patterns: patterns, - Callback: func(events []*watcherGo.Event) { + Callback: func(events []fswatcher.WatchEvent) { // Wait for workers to restart before sending the update go func() { data, err := json.Marshal(events) diff --git a/internal/watcher/watcher.go b/internal/watcher/watcher.go index 61b5fb81e3..77b5d7142d 100644 --- a/internal/watcher/watcher.go +++ b/internal/watcher/watcher.go @@ -10,7 +10,8 @@ import ( "sync/atomic" "time" - "github.com/e-dant/watcher/watcher-go" + "github.com/sgtdi/fswatcher" + "github.com/dunglas/frankenphp/internal/fastabs" ) const ( @@ -31,27 +32,22 @@ var ( activeWatcher *globalWatcher // after stopping the watcher we will wait for eventual reloads to finish reloadWaitGroup sync.WaitGroup + reloadMutex sync.Mutex // we are passing the context from the main package to the watcher globalCtx context.Context + globalCancel context.CancelFunc // we are passing the globalLogger from the main package to the watcher globalLogger *slog.Logger ) type PatternGroup struct { Patterns []string - Callback func([]*watcher.Event) -} - -type eventHolder struct { - patternGroup *PatternGroup - event *watcher.Event + Callback func([]fswatcher.WatchEvent) + fsw fswatcher.Watcher } type globalWatcher struct { groups []*PatternGroup - watchers []*pattern - events chan eventHolder - stop chan struct{} } func InitWatcher(ct context.Context, slogger *slog.Logger, groups []*PatternGroup) error { @@ -64,23 +60,14 @@ func InitWatcher(ct context.Context, slogger *slog.Logger, groups []*PatternGrou } watcherIsActive.Store(true) - globalCtx = ct + globalCtx, globalCancel = context.WithCancel(ct) globalLogger = slogger activeWatcher = &globalWatcher{groups: groups} - for _, g := range groups { - if len(g.Patterns) == 0 { - continue - } - - for _, p := range g.Patterns { - activeWatcher.watchers = append(activeWatcher.watchers, &pattern{patternGroup: g, value: p}) - } - } - - if err := activeWatcher.startWatching(); err != nil { - return err + for _, pg := range groups { + globalLogger.Warn("starting watcher", "patternGroup", pg.Patterns) + pg.startWatcher() } return nil @@ -97,125 +84,88 @@ func DrainWatcher() { globalLogger.LogAttrs(globalCtx, slog.LevelDebug, "stopping watcher") } - activeWatcher.stopWatching() + globalCancel() reloadWaitGroup.Wait() activeWatcher = nil } -// TODO: how to test this? -func (p *pattern) retryWatching() { - failureMu.Lock() - defer failureMu.Unlock() - - if p.failureCount >= maxFailureCount { - if globalLogger.Enabled(globalCtx, slog.LevelWarn) { - globalLogger.LogAttrs(globalCtx, slog.LevelWarn, "giving up watching", slog.String("pattern", p.value)) - } - - return - } - - if globalLogger.Enabled(globalCtx, slog.LevelInfo) { - globalLogger.LogAttrs(globalCtx, slog.LevelInfo, "watcher was closed prematurely, retrying...", slog.String("pattern", p.value)) +func (pg *PatternGroup) startWatcher() { + opts := []fswatcher.WatcherOpt{ + fswatcher.WithSeverity(fswatcher.SeverityDebug), + fswatcher.WithCooldown(150*time.Millisecond), } - - p.failureCount++ - - p.startSession() - - // reset the failure-count if the watcher hasn't reached max failures after 5 seconds - go func() { - time.Sleep(failureResetDuration) - - failureMu.Lock() - if p.failureCount < maxFailureCount { - p.failureCount = 0 + for _, pattern := range pg.Patterns { + absPattern, err := fastabs.FastAbs(pattern) + if err != nil { + globalLogger.Error("invalid watcher pattern", "error", err, "pattern", pattern) + continue + } + regex, err := wildcardToRegex(absPattern) + globalLogger.Warn("converted wildcard to regex", "pattern", absPattern, "regex", regex) + if err != nil { + globalLogger.Error("failed to convert wildcard to regex", "error", err) + continue } - failureMu.Unlock() - }() -} - -func (g *globalWatcher) startWatching() error { - g.events = make(chan eventHolder) - g.stop = make(chan struct{}) - - if err := g.parseFilePatterns(); err != nil { - return err - } - for _, w := range g.watchers { - w.events = g.events - w.startSession() + opts = append(opts, fswatcher.WithIncRegex(regex)) } - go g.listenForFileEvents() + fsw, err := fswatcher.New(opts...) - return nil -} - -func (g *globalWatcher) parseFilePatterns() error { - for _, w := range g.watchers { - if err := w.parse(); err != nil { - return err - } + if err != nil { + globalLogger.Error("failed to create watcher", "error", err) + return } - return nil + pg.fsw = fsw + go pg.runWatcher() + go pg.listenForFileEvents() } -func (g *globalWatcher) stopWatching() { - close(g.stop) - for _, w := range g.watchers { - w.stop() - } +func (pg *PatternGroup) runWatcher() { + err := pg.fsw.Watch(globalCtx) + if err != nil { + globalLogger.Error("failed to start watching", "error", err, "patternGroup", pg.Patterns) + } } -func (g *globalWatcher) listenForFileEvents() { - timer := time.NewTimer(debounceDuration) - timer.Stop() - - eventsPerGroup := make(map[*PatternGroup][]*watcher.Event, len(g.groups)) +func (pg *PatternGroup) listenForFileEvents() { + eventChan := pg.fsw.Events() - defer timer.Stop() for { - select { - case <-g.stop: - return - case eh := <-g.events: - timer.Reset(debounceDuration) - - eventsPerGroup[eh.patternGroup] = append(eventsPerGroup[eh.patternGroup], eh.event) - case <-timer.C: - timer.Stop() - - if globalLogger.Enabled(globalCtx, slog.LevelInfo) { - var events []*watcher.Event - for _, eventList := range eventsPerGroup { - events = append(events, eventList...) - } - - globalLogger.LogAttrs(globalCtx, slog.LevelInfo, "filesystem changes detected", slog.Any("events", events)) + debouncedEvents := make([]fswatcher.WatchEvent, 0) + event := <-eventChan + debouncedEvents = append(debouncedEvents, event) + + // drain the event channel to send events back as a group + DrainLoop: + for { + select { + case event := <-eventChan: + debouncedEvents = append(debouncedEvents, event) + default: + break DrainLoop } + } + + if globalLogger.Enabled(globalCtx, slog.LevelInfo) { + globalLogger.LogAttrs(globalCtx, slog.LevelInfo, "filesystem changes detected", slog.Any("events", debouncedEvents)) + } - g.scheduleReload(eventsPerGroup) - eventsPerGroup = make(map[*PatternGroup][]*watcher.Event, len(g.groups)) + pg.triggerCallbacks(debouncedEvents) + + if !pg.fsw.IsRunning() { + break } } } -func (g *globalWatcher) scheduleReload(eventsPerGroup map[*PatternGroup][]*watcher.Event) { +func (pg *PatternGroup) triggerCallbacks(events []fswatcher.WatchEvent) { + reloadMutex.Lock() reloadWaitGroup.Add(1) - // Call callbacks in order - for _, g := range g.groups { - if len(g.Patterns) == 0 { - g.Callback(nil) - } - - if e, ok := eventsPerGroup[g]; ok { - g.Callback(e) - } - } + pg.Callback(events) reloadWaitGroup.Done() + reloadMutex.Unlock() } diff --git a/internal/watcher/wildcard.go b/internal/watcher/wildcard.go new file mode 100644 index 0000000000..b88d075247 --- /dev/null +++ b/internal/watcher/wildcard.go @@ -0,0 +1,131 @@ +package watcher + +import ( + "fmt" + "regexp" + "strings" +) + +// ToRegex converts a glob pattern to an anchored regular expression string. +// +// Supported syntax: +// +// * any chars except '/' +// ? exactly one char except '/' +// ** any chars including '/' (when it forms a full path segment) +// /**/ zero or more directories +// {a,b,c} alternation (can be nested) +// [abc] character class, [!abc] / [^abc] negated +// \x literal x +func wildcardToRegex(pattern string) (string, error) { + var sb strings.Builder + sb.WriteString("^") + + runes := []rune(pattern) + braceDepth := 0 + + for i := 0; i < len(runes); i++ { + c := runes[i] + + switch c { + case '*': + // Consume a run of stars. + start := i + for i+1 < len(runes) && runes[i+1] == '*' { + i++ + } + isDouble := i > start + + // A "**" is a globstar only if it is a whole path segment. + atSegStart := start == 0 || runes[start-1] == '/' + atSegEnd := i+1 >= len(runes) || runes[i+1] == '/' + + switch { + case isDouble && atSegStart && atSegEnd: + if i+1 < len(runes) && runes[i+1] == '/' { + // "**/" -> zero or more directories + i++ // consume the slash + sb.WriteString("(?:.*/)?") + } else { + // trailing "**" -> everything + sb.WriteString(".*") + } + default: + // plain "*" (or "**" embedded in a name, treated as "*") + sb.WriteString("[^/]*") + } + + case '?': + sb.WriteString("[^/]") + + case '{': + braceDepth++ + sb.WriteString("(?:") + + case '}': + if braceDepth == 0 { + sb.WriteString(`\}`) // stray '}' is literal + } else { + braceDepth-- + sb.WriteString(")") + } + + case ',': + if braceDepth > 0 { + sb.WriteString("|") + } else { + sb.WriteString(",") + } + + case '[': + // Find closing bracket. + j := i + 1 + if j < len(runes) && (runes[j] == '!' || runes[j] == '^') { + j++ + } + if j < len(runes) && runes[j] == ']' { // leading ']' is literal + j++ + } + for j < len(runes) && runes[j] != ']' { + j++ + } + if j >= len(runes) { + // No closing bracket: treat '[' as literal. + sb.WriteString(`\[`) + break + } + class := runes[i+1 : j] + sb.WriteString("[") + if len(class) > 0 && (class[0] == '!' || class[0] == '^') { + sb.WriteString("^") + class = class[1:] + } + for _, r := range class { + if r == '\\' || r == '[' { + sb.WriteRune('\\') + } + sb.WriteRune(r) + } + sb.WriteString("]") + i = j + + case '\\': + if i+1 < len(runes) { + i++ + sb.WriteString(regexp.QuoteMeta(string(runes[i]))) + } else { + sb.WriteString(`\\`) + } + + default: + sb.WriteString(regexp.QuoteMeta(string(c))) + } + } + + if braceDepth != 0 { + return "", fmt.Errorf("glob %q: unbalanced '{'", pattern) + } + + sb.WriteString("$") + return sb.String(), nil +} diff --git a/watcher.go b/watcher.go index 63864b9ef6..5a64d158df 100644 --- a/watcher.go +++ b/watcher.go @@ -6,7 +6,7 @@ import ( "sync/atomic" "github.com/dunglas/frankenphp/internal/watcher" - watcherGo "github.com/e-dant/watcher/watcher-go" + "github.com/sgtdi/fswatcher" ) type hotReloadOpt struct { @@ -34,14 +34,14 @@ func initWatchers(o *opt) error { } watcherIsEnabled = true - watchPatterns = append(watchPatterns, &watcher.PatternGroup{Patterns: o.watch, Callback: func(_ []*watcherGo.Event) { + watchPatterns = append(watchPatterns, &watcher.PatternGroup{Patterns: o.watch, Callback: func(_ []fswatcher.WatchEvent) { restartWorkers.Store(true) }}) } if watcherIsEnabled { watchPatterns = append(watchPatterns, &watcher.PatternGroup{ - Callback: func(_ []*watcherGo.Event) { + Callback: func(_ []fswatcher.WatchEvent) { if restartWorkers.Swap(false) { RestartWorkers() } From de45752b98b66458e515d777a80367658b3499c7 Mon Sep 17 00:00:00 2001 From: Alliballibaba Date: Wed, 14 Oct 2026 17:29:55 +0200 Subject: [PATCH 02/19] fixes inc regexes --- internal/watcher/pattern.go | 233 ----------------------------------- internal/watcher/watcher.go | 12 +- internal/watcher/wildcard.go | 25 ++++ 3 files changed, 31 insertions(+), 239 deletions(-) delete mode 100644 internal/watcher/pattern.go diff --git a/internal/watcher/pattern.go b/internal/watcher/pattern.go deleted file mode 100644 index 0f0bf3603e..0000000000 --- a/internal/watcher/pattern.go +++ /dev/null @@ -1,233 +0,0 @@ -//go:build !nowatcher - -package watcher - -import ( - "log/slog" - "path/filepath" - "strings" - - "github.com/dunglas/frankenphp/internal/fastabs" - "github.com/e-dant/watcher/watcher-go" -) - -const sep = string(filepath.Separator) - -type pattern struct { - patternGroup *PatternGroup - value string - parsedValues []string - events chan eventHolder - failureCount int - - watcher *watcher.Watcher -} - -func (p *pattern) startSession() { - p.watcher = watcher.NewWatcher(p.value, p.handle) - - if globalLogger.Enabled(globalCtx, slog.LevelDebug) { - globalLogger.LogAttrs(globalCtx, slog.LevelDebug, "watching", slog.String("pattern", p.value)) - } -} - -// this method prepares the pattern struct (aka /path/*pattern) -func (p *pattern) parse() (err error) { - // first we clean the value - absPattern, err := fastabs.FastAbs(p.value) - if err != nil { - return err - } - - p.value = absPattern - - volumeName := filepath.VolumeName(p.value) - p.value = strings.TrimPrefix(p.value, volumeName) - - // then we split the pattern to determine where the directory ends and the pattern starts - splitPattern := strings.Split(p.value, sep) - patternWithoutDir := "" - for i, part := range splitPattern { - isFilename := i == len(splitPattern)-1 && strings.Contains(part, ".") - isGlobCharacter := strings.ContainsAny(part, "[*?{") - - if isFilename || isGlobCharacter { - patternWithoutDir = filepath.Join(splitPattern[i:]...) - p.value = filepath.Join(splitPattern[:i]...) - - break - } - } - - // now we split the pattern according to the recursive '**' syntax - p.parsedValues = strings.Split(patternWithoutDir, "**") - for i, pp := range p.parsedValues { - p.parsedValues[i] = strings.Trim(pp, sep) - } - - // remove the trailing separator and add leading separator (except on Windows) - if volumeName == "" { - p.value = sep + strings.Trim(p.value, sep) - } else { - p.value = volumeName + sep + strings.Trim(p.value, sep) - } - - // try to canonicalize the path - canonicalPattern, err := filepath.EvalSymlinks(p.value) - if err == nil { - p.value = canonicalPattern - } - - return nil -} - -func (p *pattern) allowReload(event *watcher.Event) bool { - if !isValidEventType(event.EffectType) || !isValidPathType(event) { - return false - } - - // some editors create temporary files and never actually modify the original file - // so we need to also check Event.AssociatedPathName - // see https://github.com/php/frankenphp/issues/1375 - return p.isValidPattern(event.PathName) || p.isValidPattern(event.AssociatedPathName) -} - -func (p *pattern) handle(event *watcher.Event) { - // If the watcher prematurely sends the die@ event, retry watching - if event.PathType == watcher.PathTypeWatcher && strings.HasPrefix(event.PathName, "e/self/die@") && watcherIsActive.Load() { - p.retryWatching() - - return - } - - if p.allowReload(event) { - p.events <- eventHolder{p.patternGroup, event} - } -} - -func (p *pattern) stop() { - p.watcher.Close() -} - -func isValidEventType(effectType watcher.EffectType) bool { - return effectType <= watcher.EffectTypeDestroy -} - -func isValidPathType(event *watcher.Event) bool { - if event.PathType == watcher.PathTypeWatcher && globalLogger.Enabled(globalCtx, slog.LevelDebug) { - globalLogger.LogAttrs(globalCtx, slog.LevelDebug, "special e-dant/watcher event", slog.Any("event", event)) - } - - return event.PathType <= watcher.PathTypeHardLink -} - -func (p *pattern) isValidPattern(fileName string) bool { - if fileName == "" { - return false - } - - // first we remove the dir from the file name - if !strings.HasPrefix(fileName, p.value) { - return false - } - - // remove the directory path and separator from the filename - fileNameWithoutDir := strings.TrimPrefix(strings.TrimPrefix(fileName, p.value), sep) - - // if the pattern has size 1 we can match it directly against the filename - if len(p.parsedValues) == 1 { - return matchCurlyBracePattern(p.parsedValues[0], fileNameWithoutDir) - } - - return p.matchPatterns(fileNameWithoutDir) -} - -func (p *pattern) matchPatterns(fileName string) bool { - partsToMatch := strings.Split(fileName, sep) - cursor := 0 - - // if there are multiple parsedValues due to '**' we need to match them individually - for i, pattern := range p.parsedValues { - patternSize := strings.Count(pattern, sep) + 1 - - // if we are at the last pattern we will start matching from the end of the filename - if i == len(p.parsedValues)-1 { - cursor = len(partsToMatch) - patternSize - - if cursor < 0 { - return false - } - } - - // the cursor will move through the fileName until the pattern matches - for j := cursor; j < len(partsToMatch); j++ { - if j+patternSize > len(partsToMatch) { - return false - } - - cursor = j - subPattern := strings.Join(partsToMatch[j:j+patternSize], sep) - - if matchCurlyBracePattern(pattern, subPattern) { - cursor = j + patternSize - 1 - - break - } - - if cursor > len(partsToMatch)-patternSize-1 { - return false - } - } - } - - return true -} - -// we also check for the following syntax: /path/*.{php,twig,yaml} -func matchCurlyBracePattern(pattern string, fileName string) bool { - for _, subPattern := range expandCurlyBraces(pattern) { - if matchPattern(subPattern, fileName) { - return true - } - } - - return false -} - -// {dir1,dir2}/path -> []string{"dir1/path", "dir2/path"} -func expandCurlyBraces(s string) []string { - before, rest, found := strings.Cut(s, "{") - if !found { - return []string{s} - } - - inside, after, found := strings.Cut(rest, "}") - if !found { - return []string{s} // no closing brace - } - - var out []string - for subPattern := range strings.SplitSeq(inside, ",") { - out = append(out, expandCurlyBraces(before+subPattern+after)...) - } - - return out -} - -func matchPattern(pattern string, fileName string) bool { - if pattern == "" { - return true - } - - patternMatches, err := filepath.Match(pattern, fileName) - - if err != nil { - if globalLogger.Enabled(globalCtx, slog.LevelError) { - globalLogger.LogAttrs(globalCtx, slog.LevelError, "failed to match filename", slog.String("file", fileName), slog.Any("error", err)) - } - - return false - } - - return patternMatches -} diff --git a/internal/watcher/watcher.go b/internal/watcher/watcher.go index 77b5d7142d..7305d0c60d 100644 --- a/internal/watcher/watcher.go +++ b/internal/watcher/watcher.go @@ -11,7 +11,6 @@ import ( "time" "github.com/sgtdi/fswatcher" - "github.com/dunglas/frankenphp/internal/fastabs" ) const ( @@ -66,8 +65,9 @@ func InitWatcher(ct context.Context, slogger *slog.Logger, groups []*PatternGrou activeWatcher = &globalWatcher{groups: groups} for _, pg := range groups { - globalLogger.Warn("starting watcher", "patternGroup", pg.Patterns) - pg.startWatcher() + if len(pg.Patterns) > 0 { + pg.startWatcher() + } } return nil @@ -95,8 +95,8 @@ func (pg *PatternGroup) startWatcher() { fswatcher.WithCooldown(150*time.Millisecond), } for _, pattern := range pg.Patterns { - absPattern, err := fastabs.FastAbs(pattern) - if err != nil { + dir, absPattern, err := extractRootFromPattern(pattern) + if err != nil { globalLogger.Error("invalid watcher pattern", "error", err, "pattern", pattern) continue } @@ -107,7 +107,7 @@ func (pg *PatternGroup) startWatcher() { continue } - opts = append(opts, fswatcher.WithIncRegex(regex)) + opts = append(opts, fswatcher.WithPath(dir, fswatcher.WithPathIncRegex(regex))) } fsw, err := fswatcher.New(opts...) diff --git a/internal/watcher/wildcard.go b/internal/watcher/wildcard.go index b88d075247..f36a07a2b9 100644 --- a/internal/watcher/wildcard.go +++ b/internal/watcher/wildcard.go @@ -4,6 +4,10 @@ import ( "fmt" "regexp" "strings" + "path/filepath" + + "github.com/dunglas/frankenphp/internal/fastabs" + ) // ToRegex converts a glob pattern to an anchored regular expression string. @@ -129,3 +133,24 @@ func wildcardToRegex(pattern string) (string, error) { sb.WriteString("$") return sb.String(), nil } + + +func extractRootFromPattern(pattern string) (dir, absPattern string, err error) { + absPattern, err = fastabs.FastAbs(pattern) // joins with cwd if relative, and cleans + if err != nil { + return "", "", err + } + + segs := strings.Split(filepath.ToSlash(absPattern), "/") + n := 0 + // Keep static segments, but never consume the last one (the file part). + for n < len(segs)-1 && !strings.ContainsAny(segs[n], `*?[{\`) { + n++ + } + + dir = strings.Join(segs[:n], "/") + if dir == "" { + dir = "/" + } + return filepath.FromSlash(dir), absPattern, nil +} \ No newline at end of file From a5663006195d364f403bbb230251c688b546aa4c Mon Sep 17 00:00:00 2001 From: Alliballibaba Date: Wed, 14 Oct 2026 17:30:37 +0200 Subject: [PATCH 03/19] uses old name --- internal/watcher/{wildcard.go => pattern.go} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename internal/watcher/{wildcard.go => pattern.go} (100%) diff --git a/internal/watcher/wildcard.go b/internal/watcher/pattern.go similarity index 100% rename from internal/watcher/wildcard.go rename to internal/watcher/pattern.go From b109959c090e08105cc39c923ac1435638945cef Mon Sep 17 00:00:00 2001 From: Alliballibaba Date: Wed, 14 Oct 2026 20:08:15 +0200 Subject: [PATCH 04/19] adds pattern tests back in --- internal/watcher/pattern.go | 96 ++++++++++++++++++++------------ internal/watcher/pattern_test.go | 95 ++++++++++++++++--------------- internal/watcher/watcher.go | 17 +++--- 3 files changed, 118 insertions(+), 90 deletions(-) diff --git a/internal/watcher/pattern.go b/internal/watcher/pattern.go index f36a07a2b9..e12ad5cd78 100644 --- a/internal/watcher/pattern.go +++ b/internal/watcher/pattern.go @@ -1,15 +1,55 @@ package watcher import ( - "fmt" "regexp" "strings" "path/filepath" + "fmt" "github.com/dunglas/frankenphp/internal/fastabs" ) +func extractRootAndRegex(pattern string) (string, string, error) { + forceDir := strings.HasSuffix(pattern, "/") || strings.HasSuffix(pattern, string(filepath.Separator)) + + absPattern, err := fastabs.FastAbs(pattern) // joins with cwd if relative, and cleans + if err != nil { + return "", "", err + } + + segs := strings.Split(filepath.ToSlash(absPattern), "/") + n := 0 + for n < len(segs) && !strings.ContainsAny(segs[n], `*?[{\`) { + n++ + } + + // No wildcard anywhere: the pattern is a concrete path. + if n == len(segs) { + // ".x" counts as an extension, a bare "." (e.g. "foo.") does not. + isFile := !forceDir && len(filepath.Ext(absPattern)) > 1 + if isFile { + // Watch the parent so atomic saves (write temp + rename) + // don't invalidate the watch; filter events by name. + return filepath.Dir(absPattern), absPattern, nil + } + return absPattern, "", nil + } + + // Wildcard present: watch the topmost static directory. + dir := strings.Join(segs[:n], "/") + if dir == "" { + dir = "/" + } + + regex, err := wildcardToRegex(absPattern) + if err != nil { + return "", "", err + } + + return filepath.FromSlash(dir), regex, nil +} + // ToRegex converts a glob pattern to an anchored regular expression string. // // Supported syntax: @@ -22,6 +62,11 @@ import ( // [abc] character class, [!abc] / [^abc] negated // \x literal x func wildcardToRegex(pattern string) (string, error) { + pattern, err := fastabs.FastAbs(pattern) + if err != nil { + return "", err + } + var sb strings.Builder sb.WriteString("^") @@ -40,23 +85,21 @@ func wildcardToRegex(pattern string) (string, error) { } isDouble := i > start - // A "**" is a globstar only if it is a whole path segment. - atSegStart := start == 0 || runes[start-1] == '/' - atSegEnd := i+1 >= len(runes) || runes[i+1] == '/' - switch { - case isDouble && atSegStart && atSegEnd: - if i+1 < len(runes) && runes[i+1] == '/' { - // "**/" -> zero or more directories - i++ // consume the slash - sb.WriteString("(?:.*/)?") - } else { - // trailing "**" -> everything - sb.WriteString(".*") - } - default: - // plain "*" (or "**" embedded in a name, treated as "*") + case !isDouble: + // plain "*" -> anything except '/' sb.WriteString("[^/]*") + + case i+1 < len(runes) && runes[i+1] == '/' && + (start == 0 || runes[start-1] == '/'): + // "**/" at a segment start -> zero or more directories, + // so "a/**/b" matches "a/b", "a/x/b", "a/x/y/b". + i++ // consume the slash + sb.WriteString("(?:.*/)?") + + default: + // any other "**" -> anything, including '/' + sb.WriteString(".*") } case '?': @@ -133,24 +176,3 @@ func wildcardToRegex(pattern string) (string, error) { sb.WriteString("$") return sb.String(), nil } - - -func extractRootFromPattern(pattern string) (dir, absPattern string, err error) { - absPattern, err = fastabs.FastAbs(pattern) // joins with cwd if relative, and cleans - if err != nil { - return "", "", err - } - - segs := strings.Split(filepath.ToSlash(absPattern), "/") - n := 0 - // Keep static segments, but never consume the last one (the file part). - for n < len(segs)-1 && !strings.ContainsAny(segs[n], `*?[{\`) { - n++ - } - - dir = strings.Join(segs[:n], "/") - if dir == "" { - dir = "/" - } - return filepath.FromSlash(dir), absPattern, nil -} \ No newline at end of file diff --git a/internal/watcher/pattern_test.go b/internal/watcher/pattern_test.go index 8ee3907c7e..e02ba127f6 100644 --- a/internal/watcher/pattern_test.go +++ b/internal/watcher/pattern_test.go @@ -4,10 +4,11 @@ package watcher import ( "path/filepath" + "regexp" "strings" "testing" - "github.com/e-dant/watcher/watcher-go" + "github.com/dunglas/frankenphp/internal/fastabs" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) @@ -27,31 +28,6 @@ func normalizePath(t *testing.T, path string) string { return path } -func newPattern(t *testing.T, value string) pattern { - t.Helper() - - p := pattern{value: normalizePath(t, value)} - require.NoError(t, p.parse()) - - return p -} - -func TestDisallowOnEventTypeBiggerThan3(t *testing.T) { - t.Parallel() - - w := newPattern(t, "/some/path") - - assert.False(t, w.allowReload(&watcher.Event{PathName: "/some/path/watch-me.php", EffectType: watcher.EffectTypeOwner})) -} - -func TestDisallowOnPathTypeBiggerThan2(t *testing.T) { - t.Parallel() - - w := newPattern(t, "/some/path") - - assert.False(t, w.allowReload(&watcher.Event{PathName: "/some/path/watch-me.php", PathType: watcher.PathTypeSymLink})) -} - func TestWatchesCorrectDir(t *testing.T) { t.Parallel() @@ -331,7 +307,11 @@ func TestInvalidCurlyBracePatterns(t *testing.T) { {"/path/{dir1,dir2}/**/*.php", "/path/dir1/subpath/file.txt"}, {"/path/{dir1,dir2}/{a,b}{a,b}.php", "/path/dir1/ac.php"}, {"/path/{}/{a,b}{a,b}.php", "/path/dir1/ac.php"}, - {"/path/}dir{/{a,b}{a,b}.php", "/path/dir1/aa.php"}, + + } + + errorPatterns := []string{ + "/path/}dir{/{a,b}{a,b}.php", } for _, d := range data { @@ -341,18 +321,14 @@ func TestInvalidCurlyBracePatterns(t *testing.T) { assertPatternNotMatch(t, d.pattern, d.dir) }) } -} -func TestAnAssociatedEventTriggersTheWatcher(t *testing.T) { - t.Parallel() - - w := newPattern(t, "/**/*.php") - w.events = make(chan eventHolder) - - e := &watcher.Event{PathName: normalizePath(t, "/path/temporary_file"), AssociatedPathName: normalizePath(t, "/path/file.php")} - go w.handle(e) + for _, p := range errorPatterns { + t.Run(p, func(t *testing.T) { + t.Parallel() - assert.Equal(t, e, (<-w.events).event) + assertPatternError(t, p) + }) + } } func relativeDir(t *testing.T, relativePath string) string { @@ -367,23 +343,54 @@ func relativeDir(t *testing.T, relativePath string) string { func hasDir(t *testing.T, p string, dir string) { t.Helper() - w := newPattern(t, p) + got, _, err := extractRootAndRegex(p) + require.NoError(t, err) - assert.Equal(t, normalizePath(t, dir), w.value) + assert.Equal(t, normalizePath(t, dir), normalizePath(t, got)) } func assertPatternMatch(t *testing.T, p, fileName string) { t.Helper() - w := newPattern(t, p) - - assert.True(t, w.allowReload(&watcher.Event{PathName: normalizePath(t, fileName)})) + assertPattern(t, p, fileName, true) } func assertPatternNotMatch(t *testing.T, p, fileName string) { t.Helper() - w := newPattern(t, p) + assertPattern(t, p, fileName, false) +} + +func assertPatternError(t *testing.T, p string) { + t.Helper() + + _, _, err := extractRootAndRegex(p) + require.Error(t, err) +} + +func assertPattern(t *testing.T, p, fileName string, want bool) { + t.Helper() - assert.False(t, w.allowReload(&watcher.Event{PathName: normalizePath(t, fileName)})) + root, regex, err := extractRootAndRegex(p) + require.NoError(t, err) + + // no regex, just assert the file path starts with the root + if regex == "" { + if want { + assert.True(t, strings.HasPrefix(normalizePath(t, fileName), normalizePath(t, root))) + } else { + assert.False(t, strings.HasPrefix(normalizePath(t, fileName), normalizePath(t, root))) + } + return + } + re := regexp.MustCompile(regex) + + absFile, err := fastabs.FastAbs(normalizePath(t, fileName)) + require.NoError(t, err) + + if want { + assert.True(t, re.MatchString(absFile)) + } else { + assert.False(t, re.MatchString(absFile)) + } } diff --git a/internal/watcher/watcher.go b/internal/watcher/watcher.go index 7305d0c60d..9e7a66789e 100644 --- a/internal/watcher/watcher.go +++ b/internal/watcher/watcher.go @@ -95,19 +95,18 @@ func (pg *PatternGroup) startWatcher() { fswatcher.WithCooldown(150*time.Millisecond), } for _, pattern := range pg.Patterns { - dir, absPattern, err := extractRootFromPattern(pattern) - if err != nil { - globalLogger.Error("invalid watcher pattern", "error", err, "pattern", pattern) - continue - } - regex, err := wildcardToRegex(absPattern) - globalLogger.Warn("converted wildcard to regex", "pattern", absPattern, "regex", regex) + dir, regex, err := extractRootAndRegex(pattern) if err != nil { - globalLogger.Error("failed to convert wildcard to regex", "error", err) + globalLogger.Error("invalid watcher pattern, unable to watch directory", "error", err, "pattern", pattern) continue } - opts = append(opts, fswatcher.WithPath(dir, fswatcher.WithPathIncRegex(regex))) + // pattern is a concrete directory, just watch it without any regex filtering + if regex == "" { + opts = append(opts, fswatcher.WithPath(dir)) + }else{ + opts = append(opts, fswatcher.WithPath(dir, fswatcher.WithPathIncRegex(regex))) + } } fsw, err := fswatcher.New(opts...) From 9eddef4618c0962ee86e639b63826db33cd83e3c Mon Sep 17 00:00:00 2001 From: Alliballibaba Date: Wed, 14 Oct 2026 20:46:22 +0200 Subject: [PATCH 05/19] fixes races --- internal/watcher/pattern.go | 19 ++++--- internal/watcher/pattern_test.go | 1 - internal/watcher/watcher.go | 85 ++++++++++++++++++-------------- watcher.go | 25 ++++------ 4 files changed, 66 insertions(+), 64 deletions(-) diff --git a/internal/watcher/pattern.go b/internal/watcher/pattern.go index e12ad5cd78..96ea41dcdd 100644 --- a/internal/watcher/pattern.go +++ b/internal/watcher/pattern.go @@ -1,13 +1,12 @@ package watcher import ( + "fmt" + "path/filepath" "regexp" "strings" - "path/filepath" - "fmt" "github.com/dunglas/frankenphp/internal/fastabs" - ) func extractRootAndRegex(pattern string) (string, string, error) { @@ -54,13 +53,13 @@ func extractRootAndRegex(pattern string) (string, string, error) { // // Supported syntax: // -// * any chars except '/' -// ? exactly one char except '/' -// ** any chars including '/' (when it forms a full path segment) -// /**/ zero or more directories -// {a,b,c} alternation (can be nested) -// [abc] character class, [!abc] / [^abc] negated -// \x literal x +// - any chars except '/' +// ? exactly one char except '/' +// ** any chars including '/' (when it forms a full path segment) +// /**/ zero or more directories +// {a,b,c} alternation (can be nested) +// [abc] character class, [!abc] / [^abc] negated +// \x literal x func wildcardToRegex(pattern string) (string, error) { pattern, err := fastabs.FastAbs(pattern) if err != nil { diff --git a/internal/watcher/pattern_test.go b/internal/watcher/pattern_test.go index e02ba127f6..9d3129b1e2 100644 --- a/internal/watcher/pattern_test.go +++ b/internal/watcher/pattern_test.go @@ -307,7 +307,6 @@ func TestInvalidCurlyBracePatterns(t *testing.T) { {"/path/{dir1,dir2}/**/*.php", "/path/dir1/subpath/file.txt"}, {"/path/{dir1,dir2}/{a,b}{a,b}.php", "/path/dir1/ac.php"}, {"/path/{}/{a,b}{a,b}.php", "/path/dir1/ac.php"}, - } errorPatterns := []string{ diff --git a/internal/watcher/watcher.go b/internal/watcher/watcher.go index 9e7a66789e..9ae9502a1f 100644 --- a/internal/watcher/watcher.go +++ b/internal/watcher/watcher.go @@ -24,29 +24,26 @@ const ( var ( ErrAlreadyStarted = errors.New("watcher is already running") - failureMu sync.Mutex + // the currently active file watcher + activeWatcher *globalWatcher watcherIsActive atomic.Bool - // the currently active file watcher - activeWatcher *globalWatcher - // after stopping the watcher we will wait for eventual reloads to finish - reloadWaitGroup sync.WaitGroup - reloadMutex sync.Mutex - // we are passing the context from the main package to the watcher - globalCtx context.Context - globalCancel context.CancelFunc // we are passing the globalLogger from the main package to the watcher - globalLogger *slog.Logger + globalLogger *slog.Logger + globalContext context.Context ) type PatternGroup struct { Patterns []string Callback func([]fswatcher.WatchEvent) fsw fswatcher.Watcher + done chan any } type globalWatcher struct { - groups []*PatternGroup + groups []*PatternGroup + mu sync.Mutex + waitGroup sync.WaitGroup } func InitWatcher(ct context.Context, slogger *slog.Logger, groups []*PatternGroup) error { @@ -59,14 +56,13 @@ func InitWatcher(ct context.Context, slogger *slog.Logger, groups []*PatternGrou } watcherIsActive.Store(true) - globalCtx, globalCancel = context.WithCancel(ct) + activeWatcher = &globalWatcher{groups: groups, ctx: context.Background()} globalLogger = slogger - activeWatcher = &globalWatcher{groups: groups} - for _, pg := range groups { if len(pg.Patterns) > 0 { - pg.startWatcher() + pg.done = make(chan any) + pg.startWatching() } } @@ -80,19 +76,27 @@ func DrainWatcher() { watcherIsActive.Store(false) - if globalLogger.Enabled(globalCtx, slog.LevelDebug) { - globalLogger.LogAttrs(globalCtx, slog.LevelDebug, "stopping watcher") + if globalLogger.Enabled(globalContext, slog.LevelDebug) { + globalLogger.LogAttrs(globalContext, slog.LevelDebug, "stopping watcher") } - globalCancel() - reloadWaitGroup.Wait() + for _, pg := range activeWatcher.groups { + pg.fsw.Close() + close(pg.done) + } + activeWatcher.mu.Lock() + activeWatcher.mu.Unlock() + globalLogger.Info("waiting for watcher to finish") + activeWatcher.waitGroup.Wait() + globalLogger.Info("watcher finished") + activeWatcher = nil } -func (pg *PatternGroup) startWatcher() { +func (pg *PatternGroup) startWatching() { opts := []fswatcher.WatcherOpt{ fswatcher.WithSeverity(fswatcher.SeverityDebug), - fswatcher.WithCooldown(150*time.Millisecond), + fswatcher.WithCooldown(150 * time.Millisecond), } for _, pattern := range pg.Patterns { dir, regex, err := extractRootAndRegex(pattern) @@ -104,7 +108,7 @@ func (pg *PatternGroup) startWatcher() { // pattern is a concrete directory, just watch it without any regex filtering if regex == "" { opts = append(opts, fswatcher.WithPath(dir)) - }else{ + } else { opts = append(opts, fswatcher.WithPath(dir, fswatcher.WithPathIncRegex(regex))) } } @@ -117,24 +121,31 @@ func (pg *PatternGroup) startWatcher() { } pg.fsw = fsw - go pg.runWatcher() + go pg.runWatcher() go pg.listenForFileEvents() } func (pg *PatternGroup) runWatcher() { - err := pg.fsw.Watch(globalCtx) - if err != nil { - globalLogger.Error("failed to start watching", "error", err, "patternGroup", pg.Patterns) - } + err := pg.fsw.Watch(globalContext) + if err != nil { + globalLogger.Error("failed to start watching", "error", err, "patternGroup", pg.Patterns) + } } func (pg *PatternGroup) listenForFileEvents() { + activeWatcher.waitGroup.Add(1) + defer activeWatcher.waitGroup.Done() + eventChan := pg.fsw.Events() for { debouncedEvents := make([]fswatcher.WatchEvent, 0) - event := <-eventChan - debouncedEvents = append(debouncedEvents, event) + select { + case event := <-eventChan: + debouncedEvents = append(debouncedEvents, event) + case <-pg.done: + return + } // drain the event channel to send events back as a group DrainLoop: @@ -147,24 +158,24 @@ func (pg *PatternGroup) listenForFileEvents() { } } - if globalLogger.Enabled(globalCtx, slog.LevelInfo) { - globalLogger.LogAttrs(globalCtx, slog.LevelInfo, "filesystem changes detected", slog.Any("events", debouncedEvents)) + if globalLogger.Enabled(globalContext, slog.LevelInfo) { + globalLogger.LogAttrs(globalContext, slog.LevelInfo, "filesystem changes detected", slog.Any("events", debouncedEvents)) } pg.triggerCallbacks(debouncedEvents) - if !pg.fsw.IsRunning() { + if !pg.fsw.IsRunning() || !watcherIsActive.Load() { break } } } func (pg *PatternGroup) triggerCallbacks(events []fswatcher.WatchEvent) { - reloadMutex.Lock() - reloadWaitGroup.Add(1) + activeWatcher.mu.Lock() - pg.Callback(events) + if watcherIsActive.Load() { + pg.Callback(events) + } - reloadWaitGroup.Done() - reloadMutex.Unlock() + activeWatcher.mu.Unlock() } diff --git a/watcher.go b/watcher.go index 5a64d158df..f4d54f9b5f 100644 --- a/watcher.go +++ b/watcher.go @@ -3,8 +3,6 @@ package frankenphp import ( - "sync/atomic" - "github.com/dunglas/frankenphp/internal/watcher" "github.com/sgtdi/fswatcher" ) @@ -13,8 +11,6 @@ type hotReloadOpt struct { hotReload []*watcher.PatternGroup } -var restartWorkers atomic.Bool - // validateWatchers has nothing to refuse where the watcher is compiled in: // the patterns are only known to be good once the watcher took them func validateWatchers(*opt) error { @@ -26,7 +22,12 @@ func (o *hotReloadOpt) hasHotReload() bool { } func initWatchers(o *opt) error { - watchPatterns := make([]*watcher.PatternGroup, 0, len(o.hotReload)) + allPatterns := make([]*watcher.PatternGroup, 0, len(o.hotReload)) + workerPattern := &watcher.PatternGroup{ + Callback: func(_ []fswatcher.WatchEvent) { + RestartWorkers() + }, + } for _, o := range o.workers { if len(o.watch) == 0 { @@ -34,22 +35,14 @@ func initWatchers(o *opt) error { } watcherIsEnabled = true - watchPatterns = append(watchPatterns, &watcher.PatternGroup{Patterns: o.watch, Callback: func(_ []fswatcher.WatchEvent) { - restartWorkers.Store(true) - }}) + workerPattern.Patterns = append(workerPattern.Patterns, o.watch...) } if watcherIsEnabled { - watchPatterns = append(watchPatterns, &watcher.PatternGroup{ - Callback: func(_ []fswatcher.WatchEvent) { - if restartWorkers.Swap(false) { - RestartWorkers() - } - }, - }) + allPatterns = append(allPatterns, workerPattern) } - return watcher.InitWatcher(globalCtx, globalLogger, append(watchPatterns, o.hotReload...)) + return watcher.InitWatcher(globalCtx, globalLogger, append(allPatterns, o.hotReload...)) } func drainWatchers() { From 28a713257ff8fb7f8467f41151f7dc29f9069468 Mon Sep 17 00:00:00 2001 From: Alliballibaba Date: Wed, 14 Oct 2026 21:43:53 +0200 Subject: [PATCH 06/19] fixes tests --- internal/watcher/watcher.go | 24 ++++++++++++------------ 1 file changed, 12 insertions(+), 12 deletions(-) diff --git a/internal/watcher/watcher.go b/internal/watcher/watcher.go index 9ae9502a1f..32af513c01 100644 --- a/internal/watcher/watcher.go +++ b/internal/watcher/watcher.go @@ -51,12 +51,12 @@ func InitWatcher(ct context.Context, slogger *slog.Logger, groups []*PatternGrou return nil } - if watcherIsActive.Load() { + if !watcherIsActive.CompareAndSwap(false, true) { return ErrAlreadyStarted } - watcherIsActive.Store(true) - activeWatcher = &globalWatcher{groups: groups, ctx: context.Background()} + activeWatcher = &globalWatcher{groups: groups} + globalContext = ct globalLogger = slogger for _, pg := range groups { @@ -74,8 +74,6 @@ func DrainWatcher() { return } - watcherIsActive.Store(false) - if globalLogger.Enabled(globalContext, slog.LevelDebug) { globalLogger.LogAttrs(globalContext, slog.LevelDebug, "stopping watcher") } @@ -84,6 +82,8 @@ func DrainWatcher() { pg.fsw.Close() close(pg.done) } + + watcherIsActive.Store(false) activeWatcher.mu.Lock() activeWatcher.mu.Unlock() globalLogger.Info("waiting for watcher to finish") @@ -101,7 +101,7 @@ func (pg *PatternGroup) startWatching() { for _, pattern := range pg.Patterns { dir, regex, err := extractRootAndRegex(pattern) if err != nil { - globalLogger.Error("invalid watcher pattern, unable to watch directory", "error", err, "pattern", pattern) + globalLogger.Error("invalid watcher pattern, unable to watch", "error", err, "pattern", pattern) continue } @@ -121,6 +121,7 @@ func (pg *PatternGroup) startWatching() { } pg.fsw = fsw + activeWatcher.waitGroup.Add(1) go pg.runWatcher() go pg.listenForFileEvents() } @@ -133,20 +134,20 @@ func (pg *PatternGroup) runWatcher() { } func (pg *PatternGroup) listenForFileEvents() { - activeWatcher.waitGroup.Add(1) defer activeWatcher.waitGroup.Done() - eventChan := pg.fsw.Events() - for { + globalLogger.Warn("listening for file events") + eventChan := pg.fsw.Events() debouncedEvents := make([]fswatcher.WatchEvent, 0) select { case event := <-eventChan: + globalLogger.Warn("file event detected", "event", event) debouncedEvents = append(debouncedEvents, event) case <-pg.done: return } - + // drain the event channel to send events back as a group DrainLoop: for { @@ -172,10 +173,9 @@ func (pg *PatternGroup) listenForFileEvents() { func (pg *PatternGroup) triggerCallbacks(events []fswatcher.WatchEvent) { activeWatcher.mu.Lock() + defer activeWatcher.mu.Unlock() if watcherIsActive.Load() { pg.Callback(events) } - - activeWatcher.mu.Unlock() } From 2febcf85743af3065f6d8e62229a5bbc1cb55d75 Mon Sep 17 00:00:00 2001 From: Alliballibaba Date: Wed, 14 Oct 2026 21:50:56 +0200 Subject: [PATCH 07/19] removes watcher installations/docs --- .github/actions/watcher/action.yaml | 35 ----------------------------- .github/workflows/release.yaml | 3 --- .github/workflows/sanitizers.yaml | 2 -- .github/workflows/tests.yaml | 2 -- .github/workflows/windows.yaml | 12 ---------- CONTRIBUTING.md | 13 +++++------ Dockerfile | 22 ------------------ alpine.Dockerfile | 20 ----------------- caddy/go.mod | 1 - caddy/go.sum | 2 -- dev-alpine.Dockerfile | 7 ------ dev.Dockerfile | 9 -------- docs/compile.md | 2 +- docs/config.md | 2 -- docs/hot-reload.md | 2 +- go.mod | 1 - go.sum | 2 -- phpinfo.go | 1 - types_test.go | 5 ++--- 19 files changed, 10 insertions(+), 133 deletions(-) delete mode 100644 .github/actions/watcher/action.yaml diff --git a/.github/actions/watcher/action.yaml b/.github/actions/watcher/action.yaml deleted file mode 100644 index 6610d65f56..0000000000 --- a/.github/actions/watcher/action.yaml +++ /dev/null @@ -1,35 +0,0 @@ -name: watcher -description: Install e-dant/watcher -runs: - using: composite - steps: - - name: Determine e-dant/watcher version - id: determine-watcher-version - run: echo version="$(gh release view --repo e-dant/watcher --json tagName --template '{{ .tagName }}')" >> "${GITHUB_OUTPUT}" - shell: bash - env: - GH_TOKEN: ${{ github.token }} - - name: Cache e-dant/watcher - id: cache-watcher - uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 - with: - path: watcher/target - key: watcher-${{ runner.os }}-${{ runner.arch }}-${{ steps.determine-watcher-version.outputs.version }}-${{ env.CC && env.CC || 'gcc' }} - - if: steps.cache-watcher.outputs.cache-hit != 'true' - name: Compile e-dant/watcher - run: | - mkdir -p watcher - rm -rf watcher/* - gh release download --repo e-dant/watcher -A tar.gz -O - | tar -xz -C watcher --strip-components 1 - cd watcher - cmake -S . -B build -DCMAKE_BUILD_TYPE=Release - cmake --build build - sudo cmake --install build --prefix target - shell: bash - env: - GH_TOKEN: ${{ github.token }} - - name: Update LD_LIBRARY_PATH - run: | - sudo sh -c "echo ${PWD}/watcher/target/lib > /etc/ld.so.conf.d/watcher.conf" - sudo ldconfig - shell: bash diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml index 3ea099d2ff..e040cb71d5 100644 --- a/.github/workflows/release.yaml +++ b/.github/workflows/release.yaml @@ -161,9 +161,6 @@ jobs: cache: "false" - if: steps.state.outputs.resume != 'true' uses: ./.github/actions/setup-php - - if: steps.state.outputs.resume != 'true' - name: Install e-dant/watcher - uses: ./.github/actions/watcher - if: steps.state.outputs.resume != 'true' name: Set CGO flags run: echo "CGO_CFLAGS=-I${PWD}/watcher/target/include $(php-config --includes)" >> "${GITHUB_ENV}" diff --git a/.github/workflows/sanitizers.yaml b/.github/workflows/sanitizers.yaml index 0f6e2e2c7c..49c8b7ce2e 100644 --- a/.github/workflows/sanitizers.yaml +++ b/.github/workflows/sanitizers.yaml @@ -96,8 +96,6 @@ jobs: URL: ${{ steps.determine-php-version.outputs.archive }} - name: Add PHP to the PATH run: echo "$(pwd)/php/target/bin" >> "$GITHUB_PATH" - - name: Install e-dant/watcher - uses: ./.github/actions/watcher - name: Install gotestsum run: go install gotest.tools/gotestsum@latest - name: Set CGO flags diff --git a/.github/workflows/tests.yaml b/.github/workflows/tests.yaml index 0d9a0c04eb..866e2ecea1 100644 --- a/.github/workflows/tests.yaml +++ b/.github/workflows/tests.yaml @@ -47,8 +47,6 @@ jobs: - uses: ./.github/actions/setup-php with: php-version: ${{ matrix.php-versions }} - - name: Install e-dant/watcher - uses: ./.github/actions/watcher - name: Reinstall libbrotli-dev # TODO: remove this workaround when fixed upstream run: sudo apt-get install --reinstall -y libbrotli-dev diff --git a/.github/workflows/windows.yaml b/.github/workflows/windows.yaml index c45cfb1437..c9009aa6ec 100644 --- a/.github/workflows/windows.yaml +++ b/.github/workflows/windows.yaml @@ -98,18 +98,6 @@ jobs: working-directory: frankenphp run: "vcpkg install" - - name: Download Watcher - run: | - $latestTag = gh release list --repo e-dant/watcher --limit 1 --exclude-drafts --exclude-pre-releases --json tagName --jq '.[0].tagName' - Write-Host "Latest Watcher version: $latestTag" - - gh release download $latestTag --repo e-dant/watcher --pattern "*x86_64-pc-windows-msvc.tar" -O watcher.tar - - tar -xf "watcher.tar" -C "$env:GITHUB_WORKSPACE" - Rename-Item -Path "$env:GITHUB_WORKSPACE\x86_64-pc-windows-msvc" -NewName "watcher" - env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - - name: Download PHP run: | $webContent = Invoke-WebRequest -Uri $env:PHP_DOWNLOAD_BASE diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index b9534ec101..05e2d881aa 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -113,22 +113,21 @@ curl -v http://127.0.0.1:8080/phpinfo.php .\vcpkg\bootstrap-vcpkg.bat ``` -4. [Download the latest version of the watcher library for Windows](https://github.com/e-dant/watcher/releases) and extract it to a directory named `C:\watcher` -5. [Download the latest **Thread Safe** version of PHP and of the PHP SDK for Windows](https://windows.php.net/download/), extract them in directories named `C:\php` and `C:\php-devel` -6. Clone the FrankenPHP Git repository: +4. [Download the latest **Thread Safe** version of PHP and of the PHP SDK for Windows](https://windows.php.net/download/), extract them in directories named `C:\php` and `C:\php-devel` +5. Clone the FrankenPHP Git repository: ```powershell git clone https://github.com/php/frankenphp C:\frankenphp cd C:\frankenphp ``` -7. Install the dependencies: +6. Install the dependencies: ```powershell C:\vcpkg\vcpkg.exe install ``` -8. Configure the needed environment variables (PowerShell): +7. Configure the needed environment variables (PowerShell): ```powershell $env:PATH += ';C:\Program Files\Microsoft Visual Studio\2022\Community\VC\Tools\Llvm\bin' @@ -138,7 +137,7 @@ curl -v http://127.0.0.1:8080/phpinfo.php $env:CGO_LDFLAGS = '-LC:\frankenphp\vcpkg_installed\x64-windows\lib -lbrotlienc -LC:\watcher -llibwatcher-c -LC:\php -LC:\php-devel\lib -lphp8ts -lphp8embed' ``` -9. Run the tests: +8. Run the tests: ```powershell go test -race -ldflags '-extldflags="-fuse-ld=lld"' ./... @@ -147,7 +146,7 @@ curl -v http://127.0.0.1:8080/phpinfo.php cd .. ``` -10. Build the binary: +9. Build the binary: ```powershell cd caddy/frankenphp diff --git a/Dockerfile b/Dockerfile index 6697209d38..4e0956dfe7 100644 --- a/Dockerfile +++ b/Dockerfile @@ -81,28 +81,6 @@ RUN apt-get update && \ && \ apt-get clean -# Install e-dant/watcher (necessary for file watching) -WORKDIR /usr/local/src/watcher -RUN --mount=type=secret,id=github-token <<'EOF' -set -e -api=https://api.github.com/repos/e-dant/watcher/releases/latest -if [ -s /run/secrets/github-token ]; then - tarball_url=$(curl -fsSL -H "Authorization: Bearer $(cat /run/secrets/github-token)" "${api}" | jq -r '.tarball_url // empty') -else - tarball_url=$(curl -fsSL "${api}" | jq -r '.tarball_url // empty') -fi -if [ -z "${tarball_url}" ]; then - echo "failed to resolve e-dant/watcher tarball URL (rate limited?)" >&2 - exit 1 -fi -curl -fsSL "${tarball_url}" | tar xz --strip-components 1 -# -Wno-error=use-after-free: GCC 12 on Bookworm i386 emits a spurious warning in libstdc++ basic_string.h -cmake -S . -B build -DCMAKE_BUILD_TYPE=Release -DCMAKE_CXX_FLAGS="-Wno-error=use-after-free" -cmake --build build -cmake --install build -ldconfig -EOF - WORKDIR /go/src/app COPY --link go.mod go.sum ./ diff --git a/alpine.Dockerfile b/alpine.Dockerfile index e9e0fb3176..c08a9dbb16 100644 --- a/alpine.Dockerfile +++ b/alpine.Dockerfile @@ -87,26 +87,6 @@ RUN apk add --no-cache --virtual .build-deps \ sqlite-dev \ upx -# Install e-dant/watcher (necessary for file watching) -WORKDIR /usr/local/src/watcher -RUN --mount=type=secret,id=github-token <<'EOF' -set -e -api=https://api.github.com/repos/e-dant/watcher/releases/latest -if [ -s /run/secrets/github-token ]; then - tarball_url=$(curl -fsSL -H "Authorization: Bearer $(cat /run/secrets/github-token)" "${api}" | jq -r '.tarball_url // empty') -else - tarball_url=$(curl -fsSL "${api}" | jq -r '.tarball_url // empty') -fi -if [ -z "${tarball_url}" ]; then - echo "failed to resolve e-dant/watcher tarball URL (rate limited?)" >&2 - exit 1 -fi -curl -fsSL "${tarball_url}" | tar xz --strip-components 1 -cmake -S . -B build -DCMAKE_BUILD_TYPE=Release -cmake --build build -cmake --install build -EOF - WORKDIR /go/src/app COPY --link go.mod go.sum ./ diff --git a/caddy/go.mod b/caddy/go.mod index 305bd0624a..fcd08095ec 100644 --- a/caddy/go.mod +++ b/caddy/go.mod @@ -63,7 +63,6 @@ require ( github.com/dunglas/skipfilter v1.2.0 // indirect github.com/dunglas/vulcain v1.4.5 // indirect github.com/dustin/go-humanize v1.1.0 // indirect - github.com/e-dant/watcher v0.0.0-20260223030516-06f84a1314be // indirect github.com/elnormous/contenttype v1.0.4 // indirect github.com/felixge/httpsnoop v1.1.0 // indirect github.com/fxamacker/cbor/v2 v2.9.6 // indirect diff --git a/caddy/go.sum b/caddy/go.sum index a56ae4f1f6..85e341cb82 100644 --- a/caddy/go.sum +++ b/caddy/go.sum @@ -170,8 +170,6 @@ github.com/dunglas/vulcain/caddy v1.4.5/go.mod h1:HFvLJVVuRG7JQJr+rUBWJnYxHDVn1O github.com/dustin/go-humanize v1.0.0/go.mod h1:HtrtbFcZ19U5GC7JDqmcUSB87Iq5E25KnS6fMYU6eOk= github.com/dustin/go-humanize v1.1.0 h1:dbKTrvD0klcbBV/h4AWJdMuZogJACoMlvWIWZ5b2xWg= github.com/dustin/go-humanize v1.1.0/go.mod h1:hc1CvRkJMsgxqjmjMQF3QNRAZBwY8AXBAzKYoSX9sFI= -github.com/e-dant/watcher v0.0.0-20260223030516-06f84a1314be h1:vqHrvilasyJcnru/0Z4FoojsQJUIfXGVplte7JtupfY= -github.com/e-dant/watcher v0.0.0-20260223030516-06f84a1314be/go.mod h1:PmV4IVmBJVqT2NcfTGN4+sZ+qGe3PA0qkphAtOHeFG0= github.com/elnormous/contenttype v1.0.4 h1:FjmVNkvQOGqSX70yvocph7keC8DtmJaLzTTq6ZOQCI8= github.com/elnormous/contenttype v1.0.4/go.mod h1:5KTOW8m1kdX1dLMiUJeN9szzR2xkngiv2K+RVZwWBbI= github.com/felixge/httpsnoop v1.1.0 h1:3YtUj32ZZkqZtt3sZZsClsymw/QDuVfpNhoA31zeORc= diff --git a/dev-alpine.Dockerfile b/dev-alpine.Dockerfile index b3b63c1e31..7df12a3e27 100644 --- a/dev-alpine.Dockerfile +++ b/dev-alpine.Dockerfile @@ -68,13 +68,6 @@ RUN git clone --branch=PHP-8.5 https://github.com/php/php-src.git . && \ echo "opcache.enable=1" >> /etc/frankenphp/php.ini && \ php --version -# Install e-dant/watcher (necessary for file watching) -WORKDIR /usr/local/src/watcher -RUN git clone https://github.com/e-dant/watcher . && \ - cmake -S . -B build -DCMAKE_BUILD_TYPE=Release && \ - cmake --build build/ && \ - cmake --install build - WORKDIR /go/src/app COPY . . diff --git a/dev.Dockerfile b/dev.Dockerfile index d6ee769e88..e6dccf90e2 100644 --- a/dev.Dockerfile +++ b/dev.Dockerfile @@ -70,15 +70,6 @@ RUN git clone --branch=PHP-8.5 https://github.com/php/php-src.git . && \ echo "opcache.enable=1" >> /etc/frankenphp/php.ini && \ php --version -# Install e-dant/watcher (necessary for file watching) -WORKDIR /usr/local/src/watcher -RUN git clone https://github.com/e-dant/watcher . && \ - cmake -S . -B build -DCMAKE_BUILD_TYPE=Release && \ - cmake --build build/ && \ - cmake --install build && \ - cp build/libwatcher-c.so /usr/local/lib/libwatcher-c.so && \ - ldconfig - WORKDIR /go/src/app COPY --link . ./ diff --git a/docs/compile.md b/docs/compile.md index ac77f29b5c..4224596ea2 100644 --- a/docs/compile.md +++ b/docs/compile.md @@ -91,7 +91,7 @@ Alternatively, these features can be disabled by passing build tags to the Go co | Feature | Dependency | Build tag to disable it | | ------------------------------ | ------------------------------------------------------------------------------------------------------------ | ----------------------- | | Brotli compression | [Brotli](https://github.com/google/brotli) | nobrotli | -| Restart workers on file change | [Watcher C](https://github.com/e-dant/watcher/tree/release/watcher-c) | nowatcher | +| Restart workers on file change | [Watcher C](https://github.com/sgtdi/fswatcher) | nowatcher | | [Mercure](mercure.md) | [Mercure Go library](https://pkg.go.dev/github.com/dunglas/mercure) (automatically installed, AGPL licensed) | nomercure | ## Compile the Go app diff --git a/docs/config.md b/docs/config.md index 3dc5a9cbbe..94b75a046c 100644 --- a/docs/config.md +++ b/docs/config.md @@ -248,8 +248,6 @@ where the FrankenPHP process was started. You can instead also specify one or mo - If you have multiple workers defined, all of them will be restarted when a file changes - Be wary about watching files that are created at runtime (like logs) since they might cause unwanted worker restarts. -The file watcher is based on [e-dant/watcher](https://github.com/e-dant/watcher). - ## Matching the worker to a path In traditional PHP applications, scripts are always placed in the public directory. diff --git a/docs/hot-reload.md b/docs/hot-reload.md index d1eff0b49d..c33191ddc0 100644 --- a/docs/hot-reload.md +++ b/docs/hot-reload.md @@ -149,7 +149,7 @@ php_server { ## How FrankenPHP hot reload works -1. **Watch**: FrankenPHP monitors the filesystem for modifications using [the `e-dant/watcher` library](https://github.com/e-dant/watcher) under the hood (we contributed the Go binding). +1. **Watch**: FrankenPHP monitors the filesystem for modifications using [the `sgtdi/fswatcher` library](https://github.com/sgtdi/fswatcher) under the hood. 2. **Restart (Worker Mode)**: if `watch` is enabled in the worker config, the PHP worker is restarted to load the new code. 3. **Push**: a JSON payload containing the list of changed files is sent to the built-in [Mercure hub](https://mercure.rocks). 4. **Receive**: The browser, listening via the JavaScript library, receives the Mercure event. diff --git a/go.mod b/go.mod index cd1ccad6ac..b3a29720f0 100644 --- a/go.mod +++ b/go.mod @@ -7,7 +7,6 @@ retract v1.0.0-rc.1 // Human error require ( github.com/Masterminds/sprig/v3 v3.3.0 github.com/dunglas/mercure v1.1.1 - github.com/e-dant/watcher v0.0.0-20260223030516-06f84a1314be github.com/maypok86/otter/v2 v2.3.0 github.com/prometheus/client_golang v1.25.0 github.com/sgtdi/fswatcher v1.3.0 diff --git a/go.sum b/go.sum index 1fb3b4949d..702a9fdb4e 100644 --- a/go.sum +++ b/go.sum @@ -23,8 +23,6 @@ github.com/dunglas/mercure v1.1.1 h1:dcgO9E9bPY/6ZIjyCL+zJz9mFxgP0KQ2mCUjcaUXzR0 github.com/dunglas/mercure v1.1.1/go.mod h1:kI5cNv6Q/Ky7pYaHfFkzksYosGUFNDo/JwxRQs37Lw4= github.com/dunglas/skipfilter v1.2.0 h1:J7/RXRxEx3t+DEu2Zt8ISsRuptbUFOWHEIzWIjraqRM= github.com/dunglas/skipfilter v1.2.0/go.mod h1:ZVvGnWsdN5Hdwt27Wbi98ixUeFF3yLZ/WsFtcWQBN3s= -github.com/e-dant/watcher v0.0.0-20260223030516-06f84a1314be h1:vqHrvilasyJcnru/0Z4FoojsQJUIfXGVplte7JtupfY= -github.com/e-dant/watcher v0.0.0-20260223030516-06f84a1314be/go.mod h1:PmV4IVmBJVqT2NcfTGN4+sZ+qGe3PA0qkphAtOHeFG0= github.com/elnormous/contenttype v1.0.4 h1:FjmVNkvQOGqSX70yvocph7keC8DtmJaLzTTq6ZOQCI8= github.com/elnormous/contenttype v1.0.4/go.mod h1:5KTOW8m1kdX1dLMiUJeN9szzR2xkngiv2K+RVZwWBbI= github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8= diff --git a/phpinfo.go b/phpinfo.go index c62b41b922..dac78a1148 100644 --- a/phpinfo.go +++ b/phpinfo.go @@ -59,7 +59,6 @@ func collectPHPInfoEntries(buildInfo *debug.BuildInfo) (entries, modules []phpin modules = buildGoModuleEntries(buildInfo) moduleAliases := map[string]string{ "github.com/dunglas/mercure": "dunglas/mercure", - "github.com/e-dant/watcher": "e-dant/watcher", "github.com/dunglas/caddy-cbrotli": "dunglas/caddy-cbrotli", } for _, module := range modules { diff --git a/types_test.go b/types_test.go index 1efe1a90d9..900096fa91 100644 --- a/types_test.go +++ b/types_test.go @@ -295,7 +295,6 @@ func TestCollectPHPInfoEntries(t *testing.T) { t.Run(tt.name, func(t *testing.T) { entries, modules := collectPHPInfoEntries(&debug.BuildInfo{ GoVersion: "go1.26.0", - Main: debug.Module{Path: "github.com/e-dant/watcher", Version: "v3.0.0"}, Deps: []*debug.Module{ {Path: path, Version: "v1.2.3", Replace: tt.replace}, {Path: "example.com/other", Version: "v4.0.0"}, @@ -303,11 +302,11 @@ func TestCollectPHPInfoEntries(t *testing.T) { }, }) require.Equal(t, []phpinfoEntry{ - {"custom", "value"}, {"Go", "go1.26.0"}, {"e-dant/watcher", "v3.0.0"}, {key, tt.want}, + {"custom", "value"}, {"Go", "go1.26.0"}, {key, tt.want}, {"dunglas/caddy-cbrotli", "v1.0.0"}, }, entries) require.Equal(t, []phpinfoEntry{ - {"github.com/e-dant/watcher", "v3.0.0"}, {path, tt.want}, {"example.com/other", "v4.0.0"}, + {path, tt.want}, {"example.com/other", "v4.0.0"}, {"github.com/dunglas/caddy-cbrotli", "v1.0.0"}, }, modules) }) From a7e360714a53cbe05d03aad397260dafca601ece Mon Sep 17 00:00:00 2001 From: Alliballibaba Date: Wed, 14 Oct 2026 21:53:35 +0200 Subject: [PATCH 08/19] removes stdc++ dependency --- Dockerfile | 9 --------- alpine.Dockerfile | 7 ------- dev-alpine.Dockerfile | 2 -- 3 files changed, 18 deletions(-) diff --git a/Dockerfile b/Dockerfile index 4e0956dfe7..a7cf5c65d6 100644 --- a/Dockerfile +++ b/Dockerfile @@ -113,15 +113,6 @@ FROM common AS runner ENV GODEBUG=cgocheck=0 -# copy watcher shared library -COPY --from=builder /usr/local/lib/libwatcher* /usr/local/lib/ -# fix for the file watcher on arm -RUN apt-get install -y --no-install-recommends libstdc++6 && \ - apt-get clean && \ - ldconfig && \ - # Remove files with non-deterministic content (embedded timestamps and mtimes) to keep image digests reproducible - rm -rf /var/log/apt /var/log/dpkg.log /var/log/alternatives.log /var/cache/ldconfig/aux-cache - COPY --from=builder /usr/local/bin/frankenphp /usr/local/bin/frankenphp RUN setcap cap_net_bind_service=+ep /usr/local/bin/frankenphp && \ frankenphp version && \ diff --git a/alpine.Dockerfile b/alpine.Dockerfile index c08a9dbb16..db98dd412c 100644 --- a/alpine.Dockerfile +++ b/alpine.Dockerfile @@ -77,8 +77,6 @@ RUN apk add --no-cache --virtual .build-deps \ jq \ libsodium-dev \ # Needed for the file watcher \ - cmake \ - libstdc++ \ libxml2-dev \ linux-headers \ oniguruma-dev \ @@ -119,11 +117,6 @@ FROM common AS runner ENV GODEBUG=cgocheck=0 -# copy watcher shared library (libgcc and libstdc++ are needed for the watcher) -COPY --from=builder /usr/local/lib/libwatcher* /usr/local/lib/ -RUN apk add --no-cache libstdc++ && \ - ldconfig /usr/local/lib - COPY --from=builder /usr/local/bin/frankenphp /usr/local/bin/frankenphp RUN setcap cap_net_bind_service=+ep /usr/local/bin/frankenphp && \ frankenphp version && \ diff --git a/dev-alpine.Dockerfile b/dev-alpine.Dockerfile index 7df12a3e27..4c2a45c51f 100644 --- a/dev-alpine.Dockerfile +++ b/dev-alpine.Dockerfile @@ -32,8 +32,6 @@ RUN apk add --no-cache \ zlib-dev \ bison \ nss-tools \ - # file watcher \ - libstdc++ \ linux-headers \ # Dev tools \ git \ From a1ed3cc86dfcaa2e94c8b03a1b6a6f106c54eb5c Mon Sep 17 00:00:00 2001 From: Alliballibaba Date: Wed, 14 Oct 2026 21:53:55 +0200 Subject: [PATCH 09/19] fmt --- internal/watcher/watcher.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/internal/watcher/watcher.go b/internal/watcher/watcher.go index 32af513c01..7a2aa83e15 100644 --- a/internal/watcher/watcher.go +++ b/internal/watcher/watcher.go @@ -147,7 +147,7 @@ func (pg *PatternGroup) listenForFileEvents() { case <-pg.done: return } - + // drain the event channel to send events back as a group DrainLoop: for { From 2a87d56bcd135048998fffbda4ce307911eab8cd Mon Sep 17 00:00:00 2001 From: Alliballibaba Date: Wed, 14 Oct 2026 22:03:41 +0200 Subject: [PATCH 10/19] windows fix --- .github/workflows/windows.yaml | 3 +-- CONTRIBUTING.md | 2 +- docs/it/CONTRIBUTING.md | 2 +- internal/watcher/pattern.go | 2 ++ 4 files changed, 5 insertions(+), 4 deletions(-) diff --git a/.github/workflows/windows.yaml b/.github/workflows/windows.yaml index c9009aa6ec..63b79f4e47 100644 --- a/.github/workflows/windows.yaml +++ b/.github/workflows/windows.yaml @@ -138,7 +138,7 @@ jobs: "$phpBin" >> $env:GITHUB_PATH "CGO_CFLAGS=-DFRANKENPHP_VERSION=$env:FRANKENPHP_VERSION -I$vcpkgRoot\include -I$watcherRoot -I$phpDevel\include -I$phpDevel\include\main -I$phpDevel\include\TSRM -I$phpDevel\include\Zend -I$phpDevel\include\ext" >> $env:GITHUB_ENV - "CGO_LDFLAGS=-L$vcpkgRoot\lib -lbrotlienc -L$watcherRoot -llibwatcher-c -L$phpBin -L$phpDevel\lib -lphp8ts -lphp8embed" >> $env:GITHUB_ENV + "CGO_LDFLAGS=-L$vcpkgRoot\lib -lbrotlienc -L$phpBin -L$phpDevel\lib -lphp8ts -lphp8embed" >> $env:GITHUB_ENV env: FRANKENPHP_VERSION: ${{ steps.version.outputs.version }} @@ -194,7 +194,6 @@ jobs: if: env.BUILD_RELEASE == 'true' run: | Copy-Item frankenphp\caddy\frankenphp\frankenphp.exe $env:DIR_NAME - Copy-Item watcher\libwatcher-c.dll $env:DIR_NAME Copy-Item frankenphp\vcpkg_installed\x64-windows\bin\brotlienc.dll $env:DIR_NAME Copy-Item frankenphp\vcpkg_installed\x64-windows\bin\brotlidec.dll $env:DIR_NAME Copy-Item frankenphp\vcpkg_installed\x64-windows\bin\brotlicommon.dll $env:DIR_NAME diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 05e2d881aa..c16ccf944c 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -134,7 +134,7 @@ curl -v http://127.0.0.1:8080/phpinfo.php $env:CC = 'clang' $env:CXX = 'clang++' $env:CGO_CFLAGS = "-O0 -g -IC:\frankenphp\vcpkg_installed\x64-windows\include -IC:\watcher -IC:\php-devel\include -IC:\php-devel\include\main -IC:\php-devel\include\TSRM -IC:\php-devel\include\Zend -IC:\php-devel\include\ext" - $env:CGO_LDFLAGS = '-LC:\frankenphp\vcpkg_installed\x64-windows\lib -lbrotlienc -LC:\watcher -llibwatcher-c -LC:\php -LC:\php-devel\lib -lphp8ts -lphp8embed' + $env:CGO_LDFLAGS = '-LC:\frankenphp\vcpkg_installed\x64-windows\lib -lbrotlienc -LC:\watcher -LC:\php -LC:\php-devel\lib -lphp8ts -lphp8embed' ``` 8. Run the tests: diff --git a/docs/it/CONTRIBUTING.md b/docs/it/CONTRIBUTING.md index 81df0ca395..cf6e8561de 100644 --- a/docs/it/CONTRIBUTING.md +++ b/docs/it/CONTRIBUTING.md @@ -135,7 +135,7 @@ curl -v http://127.0.0.1:8080/phpinfo.php $env:CC = 'clang' $env:CXX = 'clang++' $env:CGO_CFLAGS = "-O0 -g -IC:\frankenphp\vcpkg_installed\x64-windows\include -IC:\watcher -IC:\php-devel\include -IC:\php-devel\include\main -IC:\php-devel\include\TSRM -IC:\php-devel\include\Zend -IC:\php-devel\include\ext" - $env:CGO_LDFLAGS = '-LC:\frankenphp\vcpkg_installed\x64-windows\lib -lbrotlienc -LC:\watcher -llibwatcher-c -LC:\php -LC:\php-devel\lib -lphp8ts -lphp8embed' + $env:CGO_LDFLAGS = '-LC:\frankenphp\vcpkg_installed\x64-windows\lib -lbrotlienc -LC:\watcher -LC:\php -LC:\php-devel\lib -lphp8ts -lphp8embed' ``` 9. Eseguire i test: diff --git a/internal/watcher/pattern.go b/internal/watcher/pattern.go index 96ea41dcdd..c0c13b96d1 100644 --- a/internal/watcher/pattern.go +++ b/internal/watcher/pattern.go @@ -1,3 +1,5 @@ +//go:build !nowatcher + package watcher import ( From 7d90374f71907feca156ea1fe4286ce549cbd8fd Mon Sep 17 00:00:00 2001 From: Alliballibaba Date: Wed, 14 Oct 2026 22:18:09 +0200 Subject: [PATCH 11/19] fixes shtdowns on failure --- internal/watcher/watcher.go | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/internal/watcher/watcher.go b/internal/watcher/watcher.go index 7a2aa83e15..b85ee72ea7 100644 --- a/internal/watcher/watcher.go +++ b/internal/watcher/watcher.go @@ -79,7 +79,9 @@ func DrainWatcher() { } for _, pg := range activeWatcher.groups { - pg.fsw.Close() + if pg.fsw != nil { + pg.fsw.Close() + } close(pg.done) } From 79048fedb3b1c0771f776124b2e284450a4287e2 Mon Sep 17 00:00:00 2001 From: Alliballibaba Date: Wed, 14 Oct 2026 22:27:42 +0200 Subject: [PATCH 12/19] matches regex per pattern group --- internal/watcher/watcher.go | 166 +++++++++++++++++++++++++----------- 1 file changed, 115 insertions(+), 51 deletions(-) diff --git a/internal/watcher/watcher.go b/internal/watcher/watcher.go index b85ee72ea7..b59424834d 100644 --- a/internal/watcher/watcher.go +++ b/internal/watcher/watcher.go @@ -6,21 +6,16 @@ import ( "context" "errors" "log/slog" + "regexp" + "strings" "sync" "sync/atomic" "time" + "github.com/dunglas/frankenphp/internal/fastabs" "github.com/sgtdi/fswatcher" ) -const ( - // duration to wait before triggering a reload after a file change - debounceDuration = 150 * time.Millisecond - // times to retry watching if the watcher was closed prematurely - maxFailureCount = 5 - failureResetDuration = 5 * time.Second -) - var ( ErrAlreadyStarted = errors.New("watcher is already running") @@ -33,15 +28,22 @@ var ( globalContext context.Context ) +type compiledPattern struct { + root string + regex *regexp.Regexp // nil: match any path under root +} + type PatternGroup struct { Patterns []string Callback func([]fswatcher.WatchEvent) - fsw fswatcher.Watcher - done chan any + + matchers []compiledPattern } type globalWatcher struct { groups []*PatternGroup + fsw fswatcher.Watcher + done chan any mu sync.Mutex waitGroup sync.WaitGroup } @@ -59,11 +61,10 @@ func InitWatcher(ct context.Context, slogger *slog.Logger, groups []*PatternGrou globalContext = ct globalLogger = slogger - for _, pg := range groups { - if len(pg.Patterns) > 0 { - pg.done = make(chan any) - pg.startWatching() - } + if err := activeWatcher.start(); err != nil { + activeWatcher = nil + watcherIsActive.Store(false) + return err } return nil @@ -78,11 +79,13 @@ func DrainWatcher() { globalLogger.LogAttrs(globalContext, slog.LevelDebug, "stopping watcher") } - for _, pg := range activeWatcher.groups { - if pg.fsw != nil { - pg.fsw.Close() + if activeWatcher != nil { + if activeWatcher.fsw != nil { + activeWatcher.fsw.Close() + } + if activeWatcher.done != nil { + close(activeWatcher.done) } - close(pg.done) } watcherIsActive.Store(false) @@ -95,62 +98,109 @@ func DrainWatcher() { activeWatcher = nil } -func (pg *PatternGroup) startWatching() { - opts := []fswatcher.WatcherOpt{ - fswatcher.WithSeverity(fswatcher.SeverityDebug), - fswatcher.WithCooldown(150 * time.Millisecond), - } +func (pg *PatternGroup) compileMatchers() { for _, pattern := range pg.Patterns { - dir, regex, err := extractRootAndRegex(pattern) + root, regexStr, err := extractRootAndRegex(pattern) if err != nil { globalLogger.Error("invalid watcher pattern, unable to watch", "error", err, "pattern", pattern) continue } - // pattern is a concrete directory, just watch it without any regex filtering - if regex == "" { - opts = append(opts, fswatcher.WithPath(dir)) - } else { - opts = append(opts, fswatcher.WithPath(dir, fswatcher.WithPathIncRegex(regex))) + cp := compiledPattern{root: root} + if regexStr != "" { + re, err := regexp.Compile(regexStr) + if err != nil { + globalLogger.Error("invalid watcher pattern regex", "error", err, "pattern", pattern) + continue + } + cp.regex = re } + pg.matchers = append(pg.matchers, cp) } +} - fsw, err := fswatcher.New(opts...) +func (pg *PatternGroup) matchesPath(path string) bool { + absPath, err := fastabs.FastAbs(path) + if err != nil { + return false + } + + for _, m := range pg.matchers { + if m.regex == nil { + if strings.HasPrefix(absPath, m.root) { + return true + } + continue + } + if m.regex.MatchString(absPath) { + return true + } + } + + return false +} + +func (gw *globalWatcher) start() error { + opts := []fswatcher.WatcherOpt{ + fswatcher.WithSeverity(fswatcher.SeverityDebug), + fswatcher.WithCooldown(150 * time.Millisecond), + } + + watchedDirs := make(map[string]struct{}) + for _, pg := range gw.groups { + if len(pg.Patterns) == 0 { + continue + } + pg.compileMatchers() + for _, m := range pg.matchers { + if _, ok := watchedDirs[m.root]; ok { + continue + } + watchedDirs[m.root] = struct{}{} + opts = append(opts, fswatcher.WithPath(m.root)) + } + } + if len(watchedDirs) == 0 { + return errors.New("no valid watch patterns") + } + + fsw, err := fswatcher.New(opts...) if err != nil { globalLogger.Error("failed to create watcher", "error", err) - return + return err } - pg.fsw = fsw - activeWatcher.waitGroup.Add(1) - go pg.runWatcher() - go pg.listenForFileEvents() + gw.fsw = fsw + gw.done = make(chan any) + gw.waitGroup.Add(1) + go gw.runWatcher() + go gw.listenForFileEvents() + + return nil } -func (pg *PatternGroup) runWatcher() { - err := pg.fsw.Watch(globalContext) +func (gw *globalWatcher) runWatcher() { + err := gw.fsw.Watch(globalContext) if err != nil { - globalLogger.Error("failed to start watching", "error", err, "patternGroup", pg.Patterns) + globalLogger.Error("failed to start watching", "error", err) } } -func (pg *PatternGroup) listenForFileEvents() { - defer activeWatcher.waitGroup.Done() +func (gw *globalWatcher) listenForFileEvents() { + defer gw.waitGroup.Done() + + eventChan := gw.fsw.Events() for { - globalLogger.Warn("listening for file events") - eventChan := pg.fsw.Events() debouncedEvents := make([]fswatcher.WatchEvent, 0) select { case event := <-eventChan: - globalLogger.Warn("file event detected", "event", event) debouncedEvents = append(debouncedEvents, event) - case <-pg.done: + case <-gw.done: return } - // drain the event channel to send events back as a group DrainLoop: for { select { @@ -161,13 +211,27 @@ func (pg *PatternGroup) listenForFileEvents() { } } - if globalLogger.Enabled(globalContext, slog.LevelInfo) { - globalLogger.LogAttrs(globalContext, slog.LevelInfo, "filesystem changes detected", slog.Any("events", debouncedEvents)) + changesDetected := false + for _, pg := range gw.groups { + if len(pg.matchers) == 0 { + continue + } + matched := make([]fswatcher.WatchEvent, 0) + for _, event := range debouncedEvents { + if pg.matchesPath(event.Path) { + matched = append(matched, event) + } + } + if len(matched) > 0 { + if !changesDetected && globalLogger.Enabled(globalContext, slog.LevelInfo) { + globalLogger.LogAttrs(globalContext, slog.LevelInfo, "filesystem changes detected", slog.Any("events", debouncedEvents)) + } + changesDetected = true + pg.triggerCallbacks(matched) + } } - pg.triggerCallbacks(debouncedEvents) - - if !pg.fsw.IsRunning() || !watcherIsActive.Load() { + if !gw.fsw.IsRunning() || !watcherIsActive.Load() { break } } From 905e2506e172d65e20cbbc61792652fe1b4aef8b Mon Sep 17 00:00:00 2001 From: Alliballibaba Date: Wed, 14 Oct 2026 22:32:05 +0200 Subject: [PATCH 13/19] windows pattern compatibility --- internal/watcher/pattern.go | 94 +++++++++++++++++++++++++------------ 1 file changed, 63 insertions(+), 31 deletions(-) diff --git a/internal/watcher/pattern.go b/internal/watcher/pattern.go index c0c13b96d1..26dcc62871 100644 --- a/internal/watcher/pattern.go +++ b/internal/watcher/pattern.go @@ -68,6 +68,13 @@ func wildcardToRegex(pattern string) (string, error) { return "", err } + // On Windows, paths can use backslashes or forward slashes. + // We can define a separator character class for regex: [/\\] + sep := `/` + if filepath.Separator == '\\' { + sep = `[/\\\\]` + } + var sb strings.Builder sb.WriteString("^") @@ -77,34 +84,67 @@ func wildcardToRegex(pattern string) (string, error) { for i := 0; i < len(runes); i++ { c := runes[i] + // Handle windows drive letter colon (e.g., "C:") so it's treated literally + if c == ':' && i == 1 && ((runes[0] >= 'a' && runes[0] <= 'z') || (runes[0] >= 'A' && runes[0] <= 'Z')) { + sb.WriteString(":") + continue + } + switch c { - case '*': - // Consume a run of stars. - start := i - for i+1 < len(runes) && runes[i+1] == '*' { - i++ + case '*', '\\': + // If it's a backslash on Windows, treat it as a path separator or escape + if c == '\\' && filepath.Separator == '\\' { + // Check if it acts as a separator or escape + if i+1 < len(runes) && runes[i+1] != '*' && runes[i+1] != '?' && runes[i+1] != '{' && runes[i+1] != '}' && runes[i+1] != '[' && runes[i+1] != ']' { + // Treat backslash as separator equivalent to '/' + sb.WriteString(sep) + continue + } } - isDouble := i > start - - switch { - case !isDouble: - // plain "*" -> anything except '/' - sb.WriteString("[^/]*") - - case i+1 < len(runes) && runes[i+1] == '/' && - (start == 0 || runes[start-1] == '/'): - // "**/" at a segment start -> zero or more directories, - // so "a/**/b" matches "a/b", "a/x/b", "a/x/y/b". - i++ // consume the slash - sb.WriteString("(?:.*/)?") - - default: - // any other "**" -> anything, including '/' - sb.WriteString(".*") + + if c == '*' { + // Consume a run of stars. + start := i + for i+1 < len(runes) && (runes[i+1] == '*' || runes[i+1] == '\\' && filepath.Separator == '\\' && i+2 < len(runes) && runes[i+2] == '*') { + // Handle potential escaped stars or standard stars + i++ + } + isDouble := i > start + + switch { + case !isDouble: + // plain "*" -> anything except path separator + sb.WriteString(fmt.Sprintf("[^%s]*", sep)) + + case (i+1 < len(runes) && (string(runes[i+1]) == "/" || (filepath.Separator == '\\' && runes[i+1] == '\\')) && + (start == 0 || string(runes[start-1]) == "/" || (filepath.Separator == '\\' && runes[start-1] == '\\'))): + // "**/" at a segment start -> zero or more directories + i++ // consume the separator + sb.WriteString(fmt.Sprintf("(?:.*%s)?", sep)) + + default: + // any other "**" -> anything, including separators + sb.WriteString(".*") + } + break + } + + fallthrough // for standard backslash escaping logic if not handled above + + case '/': + if c == '/' || (filepath.Separator == '\\' && c == '\\') { + sb.WriteString(sep) + } else { + if i+1 < len(runes) { + i++ + sb.WriteString(regexp.QuoteMeta(string(runes[i]))) + } else { + sb.WriteString(`\\`) + } } case '?': - sb.WriteString("[^/]") + sb.WriteString(fmt.Sprintf("[^%s]", sep)) case '{': braceDepth++ @@ -157,14 +197,6 @@ func wildcardToRegex(pattern string) (string, error) { sb.WriteString("]") i = j - case '\\': - if i+1 < len(runes) { - i++ - sb.WriteString(regexp.QuoteMeta(string(runes[i]))) - } else { - sb.WriteString(`\\`) - } - default: sb.WriteString(regexp.QuoteMeta(string(c))) } From 2e338ff711245c2379084534a0a2d19321f18efb Mon Sep 17 00:00:00 2001 From: Alliballibaba Date: Wed, 14 Oct 2026 22:58:00 +0200 Subject: [PATCH 14/19] more windows tests. --- internal/watcher/pattern.go | 106 ++++++++++++++---------------------- 1 file changed, 40 insertions(+), 66 deletions(-) diff --git a/internal/watcher/pattern.go b/internal/watcher/pattern.go index 26dcc62871..283ca07178 100644 --- a/internal/watcher/pattern.go +++ b/internal/watcher/pattern.go @@ -51,28 +51,19 @@ func extractRootAndRegex(pattern string) (string, string, error) { return filepath.FromSlash(dir), regex, nil } -// ToRegex converts a glob pattern to an anchored regular expression string. -// -// Supported syntax: -// -// - any chars except '/' -// ? exactly one char except '/' -// ** any chars including '/' (when it forms a full path segment) -// /**/ zero or more directories -// {a,b,c} alternation (can be nested) -// [abc] character class, [!abc] / [^abc] negated -// \x literal x func wildcardToRegex(pattern string) (string, error) { pattern, err := fastabs.FastAbs(pattern) if err != nil { return "", err } - // On Windows, paths can use backslashes or forward slashes. - // We can define a separator character class for regex: [/\\] - sep := `/` + // Normalize pattern backslashes to forward slashes for clean parsing + pattern = filepath.ToSlash(pattern) + + // Use [/\\] for Windows to support mixed slashes, and plain '/' for Unix-like systems + sepRegex := `/` if filepath.Separator == '\\' { - sep = `[/\\\\]` + sepRegex = `[/\\]` } var sb strings.Builder @@ -84,67 +75,42 @@ func wildcardToRegex(pattern string) (string, error) { for i := 0; i < len(runes); i++ { c := runes[i] - // Handle windows drive letter colon (e.g., "C:") so it's treated literally + // Handle Windows drive letter colon (e.g., "C:/") so it's treated literally if c == ':' && i == 1 && ((runes[0] >= 'a' && runes[0] <= 'z') || (runes[0] >= 'A' && runes[0] <= 'Z')) { sb.WriteString(":") continue } switch c { - case '*', '\\': - // If it's a backslash on Windows, treat it as a path separator or escape - if c == '\\' && filepath.Separator == '\\' { - // Check if it acts as a separator or escape - if i+1 < len(runes) && runes[i+1] != '*' && runes[i+1] != '?' && runes[i+1] != '{' && runes[i+1] != '}' && runes[i+1] != '[' && runes[i+1] != ']' { - // Treat backslash as separator equivalent to '/' - sb.WriteString(sep) - continue - } + case '*': + // Consume a run of stars. + start := i + for i+1 < len(runes) && runes[i+1] == '*' { + i++ } - - if c == '*' { - // Consume a run of stars. - start := i - for i+1 < len(runes) && (runes[i+1] == '*' || runes[i+1] == '\\' && filepath.Separator == '\\' && i+2 < len(runes) && runes[i+2] == '*') { - // Handle potential escaped stars or standard stars - i++ - } - isDouble := i > start - - switch { - case !isDouble: - // plain "*" -> anything except path separator - sb.WriteString(fmt.Sprintf("[^%s]*", sep)) - - case (i+1 < len(runes) && (string(runes[i+1]) == "/" || (filepath.Separator == '\\' && runes[i+1] == '\\')) && - (start == 0 || string(runes[start-1]) == "/" || (filepath.Separator == '\\' && runes[start-1] == '\\'))): - // "**/" at a segment start -> zero or more directories - i++ // consume the separator - sb.WriteString(fmt.Sprintf("(?:.*%s)?", sep)) - - default: - // any other "**" -> anything, including separators - sb.WriteString(".*") - } - break + isDouble := i > start + + switch { + case !isDouble: + // plain "*" -> anything except a separator + sb.WriteString(fmt.Sprintf("[^%s]*", sepRegex)) + + case i+1 < len(runes) && runes[i+1] == '/' && + (start == 0 || runes[start-1] == '/'): + // "**/" at a segment start -> zero or more directories + i++ // consume the slash + sb.WriteString(fmt.Sprintf("(?:.*%s)?", sepRegex)) + + default: + // any other "**" -> anything, including separators + sb.WriteString(".*") } - fallthrough // for standard backslash escaping logic if not handled above + case '?': + sb.WriteString(fmt.Sprintf("[^%s]", sepRegex)) case '/': - if c == '/' || (filepath.Separator == '\\' && c == '\\') { - sb.WriteString(sep) - } else { - if i+1 < len(runes) { - i++ - sb.WriteString(regexp.QuoteMeta(string(runes[i]))) - } else { - sb.WriteString(`\\`) - } - } - - case '?': - sb.WriteString(fmt.Sprintf("[^%s]", sep)) + sb.WriteString(sepRegex) case '{': braceDepth++ @@ -197,6 +163,14 @@ func wildcardToRegex(pattern string) (string, error) { sb.WriteString("]") i = j + case '\\': + if i+1 < len(runes) { + i++ + sb.WriteString(regexp.QuoteMeta(string(runes[i]))) + } else { + sb.WriteString(`\\`) + } + default: sb.WriteString(regexp.QuoteMeta(string(c))) } @@ -208,4 +182,4 @@ func wildcardToRegex(pattern string) (string, error) { sb.WriteString("$") return sb.String(), nil -} +} \ No newline at end of file From b25c0e387003e759e4b2ba7eb6adb4edca9fe76f Mon Sep 17 00:00:00 2001 From: Alliballibaba Date: Wed, 14 Oct 2026 23:04:17 +0200 Subject: [PATCH 15/19] fmt --- internal/watcher/pattern.go | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/internal/watcher/pattern.go b/internal/watcher/pattern.go index 283ca07178..c094cb223c 100644 --- a/internal/watcher/pattern.go +++ b/internal/watcher/pattern.go @@ -93,13 +93,13 @@ func wildcardToRegex(pattern string) (string, error) { switch { case !isDouble: // plain "*" -> anything except a separator - sb.WriteString(fmt.Sprintf("[^%s]*", sepRegex)) + fmt.Fprintf(&sb, "[^%s]*", sepRegex) case i+1 < len(runes) && runes[i+1] == '/' && (start == 0 || runes[start-1] == '/'): // "**/" at a segment start -> zero or more directories i++ // consume the slash - sb.WriteString(fmt.Sprintf("(?:.*%s)?", sepRegex)) + fmt.Fprintf(&sb, "(?:.*%s)?", sepRegex) default: // any other "**" -> anything, including separators @@ -107,7 +107,7 @@ func wildcardToRegex(pattern string) (string, error) { } case '?': - sb.WriteString(fmt.Sprintf("[^%s]", sepRegex)) + fmt.Fprintf(&sb, "[^%s]", sepRegex) case '/': sb.WriteString(sepRegex) @@ -182,4 +182,4 @@ func wildcardToRegex(pattern string) (string, error) { sb.WriteString("$") return sb.String(), nil -} \ No newline at end of file +} From a0210ae57edd8453750ac33ac74f21ced33e85a6 Mon Sep 17 00:00:00 2001 From: Alliballibaba Date: Thu, 15 Oct 2026 10:21:24 +0200 Subject: [PATCH 16/19] fixes paths --- internal/watcher/pattern.go | 25 +++++-------------------- internal/watcher/pattern_test.go | 1 + internal/watcher/watcher.go | 12 ++++-------- 3 files changed, 10 insertions(+), 28 deletions(-) diff --git a/internal/watcher/pattern.go b/internal/watcher/pattern.go index c094cb223c..1de1f10ec8 100644 --- a/internal/watcher/pattern.go +++ b/internal/watcher/pattern.go @@ -32,7 +32,7 @@ func extractRootAndRegex(pattern string) (string, string, error) { if isFile { // Watch the parent so atomic saves (write temp + rename) // don't invalidate the watch; filter events by name. - return filepath.Dir(absPattern), absPattern, nil + return filepath.Dir(absPattern), "^" + regexp.QuoteMeta(filepath.ToSlash(absPattern)) + "$", nil } return absPattern, "", nil } @@ -57,15 +57,9 @@ func wildcardToRegex(pattern string) (string, error) { return "", err } - // Normalize pattern backslashes to forward slashes for clean parsing + // Paths are matched in slash form (windows compatibility) pattern = filepath.ToSlash(pattern) - // Use [/\\] for Windows to support mixed slashes, and plain '/' for Unix-like systems - sepRegex := `/` - if filepath.Separator == '\\' { - sepRegex = `[/\\]` - } - var sb strings.Builder sb.WriteString("^") @@ -75,12 +69,6 @@ func wildcardToRegex(pattern string) (string, error) { for i := 0; i < len(runes); i++ { c := runes[i] - // Handle Windows drive letter colon (e.g., "C:/") so it's treated literally - if c == ':' && i == 1 && ((runes[0] >= 'a' && runes[0] <= 'z') || (runes[0] >= 'A' && runes[0] <= 'Z')) { - sb.WriteString(":") - continue - } - switch c { case '*': // Consume a run of stars. @@ -93,13 +81,13 @@ func wildcardToRegex(pattern string) (string, error) { switch { case !isDouble: // plain "*" -> anything except a separator - fmt.Fprintf(&sb, "[^%s]*", sepRegex) + sb.WriteString("[^/]*") case i+1 < len(runes) && runes[i+1] == '/' && (start == 0 || runes[start-1] == '/'): // "**/" at a segment start -> zero or more directories i++ // consume the slash - fmt.Fprintf(&sb, "(?:.*%s)?", sepRegex) + sb.WriteString("(?:.*/)?") default: // any other "**" -> anything, including separators @@ -107,10 +95,7 @@ func wildcardToRegex(pattern string) (string, error) { } case '?': - fmt.Fprintf(&sb, "[^%s]", sepRegex) - - case '/': - sb.WriteString(sepRegex) + sb.WriteString("[^/]") case '{': braceDepth++ diff --git a/internal/watcher/pattern_test.go b/internal/watcher/pattern_test.go index 9d3129b1e2..eeab3640d0 100644 --- a/internal/watcher/pattern_test.go +++ b/internal/watcher/pattern_test.go @@ -386,6 +386,7 @@ func assertPattern(t *testing.T, p, fileName string, want bool) { absFile, err := fastabs.FastAbs(normalizePath(t, fileName)) require.NoError(t, err) + absFile = filepath.ToSlash(absFile) if want { assert.True(t, re.MatchString(absFile)) diff --git a/internal/watcher/watcher.go b/internal/watcher/watcher.go index b59424834d..3d3850a6a6 100644 --- a/internal/watcher/watcher.go +++ b/internal/watcher/watcher.go @@ -10,9 +10,9 @@ import ( "strings" "sync" "sync/atomic" + "path/filepath" "time" - "github.com/dunglas/frankenphp/internal/fastabs" "github.com/sgtdi/fswatcher" ) @@ -120,19 +120,15 @@ func (pg *PatternGroup) compileMatchers() { } func (pg *PatternGroup) matchesPath(path string) bool { - absPath, err := fastabs.FastAbs(path) - if err != nil { - return false - } - for _, m := range pg.matchers { if m.regex == nil { - if strings.HasPrefix(absPath, m.root) { + if strings.HasPrefix(path, m.root) { return true } continue } - if m.regex.MatchString(absPath) { + path = filepath.ToSlash(path) + if m.regex.MatchString(path) { return true } } From 075d43c0304e77ba4bbf55266081912941f75aa2 Mon Sep 17 00:00:00 2001 From: Alliballibaba Date: Thu, 15 Oct 2026 11:18:45 +0200 Subject: [PATCH 17/19] more windows fixes. --- internal/watcher/pattern.go | 5 +++-- internal/watcher/pattern_test.go | 7 +++---- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/internal/watcher/pattern.go b/internal/watcher/pattern.go index 1de1f10ec8..e83b51fbe2 100644 --- a/internal/watcher/pattern.go +++ b/internal/watcher/pattern.go @@ -39,8 +39,9 @@ func extractRootAndRegex(pattern string) (string, string, error) { // Wildcard present: watch the topmost static directory. dir := strings.Join(segs[:n], "/") - if dir == "" { - dir = "/" + if dir == filepath.VolumeName(dir) { + // "" on Unix, "C:" on Windows: watch the root, not the drive's current dir + dir += "/" } regex, err := wildcardToRegex(absPattern) diff --git a/internal/watcher/pattern_test.go b/internal/watcher/pattern_test.go index eeab3640d0..7dc6bb54e9 100644 --- a/internal/watcher/pattern_test.go +++ b/internal/watcher/pattern_test.go @@ -20,10 +20,9 @@ func normalizePath(t *testing.T, path string) string { return path } - path = filepath.FromSlash(path) - if strings.HasPrefix(path, "\\") { - path = "C:\\" + path[1:] - } + // Rooted paths without a volume resolve against the current drive + path, err := filepath.Abs(filepath.FromSlash(path)) + require.NoError(t, err) return path } From 00122a946ef93bd5fb17a9a0c3139956bdebc8f5 Mon Sep 17 00:00:00 2001 From: Alliballibaba Date: Thu, 15 Oct 2026 19:54:53 +0200 Subject: [PATCH 18/19] cleanup --- internal/watcher/pattern.go | 15 +++++++++------ internal/watcher/pattern_test.go | 18 +++--------------- internal/watcher/watcher.go | 18 ++++++------------ 3 files changed, 18 insertions(+), 33 deletions(-) diff --git a/internal/watcher/pattern.go b/internal/watcher/pattern.go index e83b51fbe2..42ff7b4363 100644 --- a/internal/watcher/pattern.go +++ b/internal/watcher/pattern.go @@ -14,11 +14,13 @@ import ( func extractRootAndRegex(pattern string) (string, string, error) { forceDir := strings.HasSuffix(pattern, "/") || strings.HasSuffix(pattern, string(filepath.Separator)) - absPattern, err := fastabs.FastAbs(pattern) // joins with cwd if relative, and cleans + absPattern, err := fastabs.FastAbs(pattern) if err != nil { return "", "", err } + absPattern = filepath.ToSlash(absPattern) + segs := strings.Split(filepath.ToSlash(absPattern), "/") n := 0 for n < len(segs) && !strings.ContainsAny(segs[n], `*?[{\`) { @@ -30,11 +32,12 @@ func extractRootAndRegex(pattern string) (string, string, error) { // ".x" counts as an extension, a bare "." (e.g. "foo.") does not. isFile := !forceDir && len(filepath.Ext(absPattern)) > 1 if isFile { - // Watch the parent so atomic saves (write temp + rename) - // don't invalidate the watch; filter events by name. - return filepath.Dir(absPattern), "^" + regexp.QuoteMeta(filepath.ToSlash(absPattern)) + "$", nil + // watch the parent, but filter only for the exact filename + return filepath.Dir(absPattern), "^" + regexp.QuoteMeta(absPattern) + "$", nil } - return absPattern, "", nil + + // watch the whole directory + return absPattern, "^" + regexp.QuoteMeta(absPattern), nil } // Wildcard present: watch the topmost static directory. @@ -141,7 +144,7 @@ func wildcardToRegex(pattern string) (string, error) { class = class[1:] } for _, r := range class { - if r == '\\' || r == '[' { + if r == '\\' || r == '[' || r == ']' { sb.WriteRune('\\') } sb.WriteRune(r) diff --git a/internal/watcher/pattern_test.go b/internal/watcher/pattern_test.go index 7dc6bb54e9..9f29a7ed10 100644 --- a/internal/watcher/pattern_test.go +++ b/internal/watcher/pattern_test.go @@ -5,7 +5,6 @@ package watcher import ( "path/filepath" "regexp" - "strings" "testing" "github.com/dunglas/frankenphp/internal/fastabs" @@ -192,7 +191,6 @@ func TestInvalidRecursiveFilePatterns(t *testing.T) { {".**/*.php", "/other/file.php"}, {"./**/*.php", "/other/file.php"}, {"/a/**/very/long/path.php", "/a/short.php"}, - {"", ""}, {"/a/**/b/c/d/**/e.php", "/a/x/e.php"}, } @@ -369,27 +367,17 @@ func assertPatternError(t *testing.T, p string) { func assertPattern(t *testing.T, p, fileName string, want bool) { t.Helper() - root, regex, err := extractRootAndRegex(p) + _, regex, err := extractRootAndRegex(p) require.NoError(t, err) - // no regex, just assert the file path starts with the root - if regex == "" { - if want { - assert.True(t, strings.HasPrefix(normalizePath(t, fileName), normalizePath(t, root))) - } else { - assert.False(t, strings.HasPrefix(normalizePath(t, fileName), normalizePath(t, root))) - } - return - } re := regexp.MustCompile(regex) - absFile, err := fastabs.FastAbs(normalizePath(t, fileName)) require.NoError(t, err) absFile = filepath.ToSlash(absFile) if want { - assert.True(t, re.MatchString(absFile)) + assert.True(t, re.MatchString(absFile), "fileName: %s, regex: %s", absFile, regex) } else { - assert.False(t, re.MatchString(absFile)) + assert.False(t, re.MatchString(absFile), "fileName: %s, regex: %s", absFile, regex) } } diff --git a/internal/watcher/watcher.go b/internal/watcher/watcher.go index 3d3850a6a6..aed8525dec 100644 --- a/internal/watcher/watcher.go +++ b/internal/watcher/watcher.go @@ -6,11 +6,11 @@ import ( "context" "errors" "log/slog" + "path/filepath" "regexp" "strings" "sync" "sync/atomic" - "path/filepath" "time" "github.com/sgtdi/fswatcher" @@ -107,14 +107,12 @@ func (pg *PatternGroup) compileMatchers() { } cp := compiledPattern{root: root} - if regexStr != "" { - re, err := regexp.Compile(regexStr) - if err != nil { - globalLogger.Error("invalid watcher pattern regex", "error", err, "pattern", pattern) - continue - } - cp.regex = re + re, err := regexp.Compile(regexStr) + if err != nil { + globalLogger.Error("invalid watcher pattern", "error", err, "pattern", pattern) + continue } + cp.regex = re pg.matchers = append(pg.matchers, cp) } } @@ -226,10 +224,6 @@ func (gw *globalWatcher) listenForFileEvents() { pg.triggerCallbacks(matched) } } - - if !gw.fsw.IsRunning() || !watcherIsActive.Load() { - break - } } } From 87b5c39a7c49a0403b4991abe68d5fee1ab56442 Mon Sep 17 00:00:00 2001 From: Alliballibaba Date: Thu, 15 Oct 2026 20:06:37 +0200 Subject: [PATCH 19/19] fixes shutdown order. --- internal/watcher/watcher.go | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/internal/watcher/watcher.go b/internal/watcher/watcher.go index aed8525dec..2f05c5d24c 100644 --- a/internal/watcher/watcher.go +++ b/internal/watcher/watcher.go @@ -57,7 +57,7 @@ func InitWatcher(ct context.Context, slogger *slog.Logger, groups []*PatternGrou return ErrAlreadyStarted } - activeWatcher = &globalWatcher{groups: groups} + activeWatcher = &globalWatcher{groups: groups, done: make(chan any)} globalContext = ct globalLogger = slogger @@ -88,14 +88,11 @@ func DrainWatcher() { } } - watcherIsActive.Store(false) activeWatcher.mu.Lock() activeWatcher.mu.Unlock() - globalLogger.Info("waiting for watcher to finish") activeWatcher.waitGroup.Wait() - globalLogger.Info("watcher finished") - activeWatcher = nil + watcherIsActive.Store(false) } func (pg *PatternGroup) compileMatchers() { @@ -166,7 +163,6 @@ func (gw *globalWatcher) start() error { } gw.fsw = fsw - gw.done = make(chan any) gw.waitGroup.Add(1) go gw.runWatcher() go gw.listenForFileEvents()