Skip to content

Watch: restarts tasks only when at least one watched file changes.#2493

Open
trulede wants to merge 1 commit intogo-task:mainfrom
trulede:PR/watch-defect
Open

Watch: restarts tasks only when at least one watched file changes.#2493
trulede wants to merge 1 commit intogo-task:mainfrom
trulede:PR/watch-defect

Conversation

@trulede
Copy link
Contributor

@trulede trulede commented Nov 4, 2025

When a task(s) is watched, and a watched event occurs, then; the current context is cancelled (which signals the task and sub-tasks), then the watch conditions are evaluated, and finally if the watch conditions are satsified, then the task(s) are restarted.

This means that if a watch condition is not satisified (i.e. "skipped for file not in sources") then the task is cancelled and not restarted.

In this PR the logic is altered as follows: First the watch condition is evaluated, if at least one task(s) satisifies the condition then all tasks are cancelled, and then all tasks restarted. When no task satisified the watch contition, no action is taken. Given the way this code behaves, I believe its an improvement within the scope of the known limitations of the method used (i.e. the Context object).

closes #2477

@andreynering andreynering added the area: watcher Changes related to the Taskfile watcher. label Jan 18, 2026
@andreynering
Copy link
Member

This needs a rebase.

@trulede
Copy link
Contributor Author

trulede commented Jan 18, 2026

@andreynering rebased

Copy link

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR adjusts the watch-execution flow so watched tasks are only cancelled/restarted when at least one watched file (per the configured sources) actually changes, preventing “cancel without restart” scenarios like the one described in #2477.

Changes:

  • Pre-compute whether the fsnotify event matches watched sources before cancelling the current context.
  • Cancel and restart all watched tasks only if at least one match is found.
  • Skip any action when the event does not match watched sources.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines +112 to +118
go func() {
err = e.RunTask(ctx, c)
if err == nil {
e.Logger.Errf(logger.Green, "task: task \"%s\" finished running\n", c.Task)
} else if !isContextError(err) {
e.Logger.Errf(logger.Red, "%v\n", err)
}
Copy link

Copilot AI Feb 17, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The goroutine assigns to the outer err variable (err = e.RunTask(...)). This introduces a data race between goroutines and also clobbers the function-scope err (used for watcher creation / error handling). Make err a new local inside the goroutine (e.g., err := ...) so each task run has its own error value.

Copilot uses AI. Check for mistakes.
// Cancel _all_ watched tasks, get new context, then restart the tasks.
if watchCount > 0 {
cancel()
ctx, cancel = context.WithCancel(context.Background())
Copy link

Copilot AI Feb 17, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

e.Compiler.ResetCache() was previously called on watch events before restarting tasks, which clears the dynamic variable cache. With the current change, watched tasks restart without resetting this cache, so dynamic vars may remain stale across restarts. Consider resetting the compiler cache inside the watchCount > 0 block before starting the new runs.

Suggested change
ctx, cancel = context.WithCancel(context.Background())
ctx, cancel = context.WithCancel(context.Background())
e.Compiler.ResetCache()

Copilot uses AI. Check for mistakes.
Comment on lines +88 to +98
t, err := e.GetTask(c)
if err != nil {
e.Logger.Errf(logger.Red, "%v\n", err)
continue
}
baseDir := filepathext.SmartJoin(e.Dir, t.Dir)
files, err := e.collectSources(calls)
if err != nil {
e.Logger.Errf(logger.Red, "%v\n", err)
continue
}
Copy link

Copilot AI Feb 17, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

e.collectSources(calls) is recomputed for every task in the loop, but it returns the same combined source list each time for this event. This adds unnecessary traversal/globbing work on every fsnotify event. Compute files once before iterating over calls (or short-circuit after the first match) to reduce per-event overhead.

Copilot uses AI. Check for mistakes.
Comment on lines 83 to +87
for _, c := range calls {
go func() {
if ShouldIgnore(event.Name) {
e.Logger.VerboseErrf(logger.Magenta, "task: event skipped for being an ignored dir: %s\n", event.Name)
return
}
t, err := e.GetTask(c)
if err != nil {
e.Logger.Errf(logger.Red, "%v\n", err)
return
}
baseDir := filepathext.SmartJoin(e.Dir, t.Dir)
files, err := e.collectSources(calls)
if err != nil {
e.Logger.Errf(logger.Red, "%v\n", err)
return
}

if !event.Has(fsnotify.Remove) && !slices.Contains(files, event.Name) {
relPath, _ := filepath.Rel(baseDir, event.Name)
e.Logger.VerboseErrf(logger.Magenta, "task: skipped for file not in sources: %s\n", relPath)
return
}
err = e.RunTask(ctx, c)
if err == nil {
e.Logger.Errf(logger.Green, "task: task \"%s\" finished running\n", c.Task)
} else if !isContextError(err) {
e.Logger.Errf(logger.Red, "%v\n", err)
}
}()
if ShouldIgnore(event.Name) {
e.Logger.VerboseErrf(logger.Magenta, "task: event skipped for being an ignored dir: %s\n", event.Name)
continue
}
Copy link

Copilot AI Feb 17, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ShouldIgnore(event.Name) does not depend on the task call, but it's checked (and logged) once per calls entry, which can spam duplicate "ignored dir" messages on a single event. Consider moving the ignore check outside the for _, c := range calls loop and returning early when ignored.

Copilot uses AI. Check for mistakes.
Comment on lines +81 to 121
// Count the tasks that would be restarted by the watch (_before_ cancelling them).
watchCount := 0
for _, c := range calls {
go func() {
if ShouldIgnore(event.Name) {
e.Logger.VerboseErrf(logger.Magenta, "task: event skipped for being an ignored dir: %s\n", event.Name)
return
}
t, err := e.GetTask(c)
if err != nil {
e.Logger.Errf(logger.Red, "%v\n", err)
return
}
baseDir := filepathext.SmartJoin(e.Dir, t.Dir)
files, err := e.collectSources(calls)
if err != nil {
e.Logger.Errf(logger.Red, "%v\n", err)
return
}

if !event.Has(fsnotify.Remove) && !slices.Contains(files, event.Name) {
relPath, _ := filepath.Rel(baseDir, event.Name)
e.Logger.VerboseErrf(logger.Magenta, "task: skipped for file not in sources: %s\n", relPath)
return
}
err = e.RunTask(ctx, c)
if err == nil {
e.Logger.Errf(logger.Green, "task: task \"%s\" finished running\n", c.Task)
} else if !isContextError(err) {
e.Logger.Errf(logger.Red, "%v\n", err)
}
}()
if ShouldIgnore(event.Name) {
e.Logger.VerboseErrf(logger.Magenta, "task: event skipped for being an ignored dir: %s\n", event.Name)
continue
}
t, err := e.GetTask(c)
if err != nil {
e.Logger.Errf(logger.Red, "%v\n", err)
continue
}
baseDir := filepathext.SmartJoin(e.Dir, t.Dir)
files, err := e.collectSources(calls)
if err != nil {
e.Logger.Errf(logger.Red, "%v\n", err)
continue
}
if !event.Has(fsnotify.Remove) && !slices.Contains(files, event.Name) {
relPath, _ := filepath.Rel(baseDir, event.Name)
e.Logger.VerboseErrf(logger.Magenta, "task: skipped for file not in sources: %s\n", relPath)
continue
}
watchCount++
}
// Cancel _all_ watched tasks, get new context, then restart the tasks.
if watchCount > 0 {
cancel()
ctx, cancel = context.WithCancel(context.Background())
for _, c := range calls {
c := c
go func() {
err = e.RunTask(ctx, c)
if err == nil {
e.Logger.Errf(logger.Green, "task: task \"%s\" finished running\n", c.Task)
} else if !isContextError(err) {
e.Logger.Errf(logger.Red, "%v\n", err)
}
}()
}
}
Copy link

Copilot AI Feb 17, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The new behavior (only cancel/restart when the event matches at least one watched source) is not covered by the existing watch tests. Consider adding a watch_test.go case that triggers a fsnotify event on a file outside sources and asserts that no additional task run happens (and the current run isn't canceled).

Copilot uses AI. Check for mistakes.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area: watcher Changes related to the Taskfile watcher.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Watch hijacking dependency process

2 participants