From 0492b2e8ca457743c545d4695fdf7acbc44a06b5 Mon Sep 17 00:00:00 2001 From: Tomas Votruba Date: Fri, 9 Oct 2026 19:46:02 +0200 Subject: [PATCH] Add context cancellation and error aggregation to blink runner Run now takes a context and stops feeding files when it is cancelled, so the CLI can be interrupted cleanly (main wires it to SIGINT/SIGTERM). File errors from all workers are collected and returned with errors.Join instead of keeping only the first and dropping the rest. --- blink/internal/runner/runner.go | 37 +++++++++++++++++++--------- blink/internal/runner/runner_test.go | 22 ++++++++++++++--- blink/main.go | 8 +++++- 3 files changed, 52 insertions(+), 15 deletions(-) diff --git a/blink/internal/runner/runner.go b/blink/internal/runner/runner.go index 14ae9fc32b..62b6bebe51 100644 --- a/blink/internal/runner/runner.go +++ b/blink/internal/runner/runner.go @@ -4,6 +4,8 @@ package runner import ( "cmp" + "context" + "errors" "os" "slices" "sync" @@ -25,16 +27,16 @@ type FileResult struct { func (r FileResult) Changed() bool { return len(r.AppliedRules) > 0 } -// Run scans the config's paths and applies rules across Jobs workers. When -// write is true, changed files are written back to disk. Results are returned -// sorted by path so output is deterministic regardless of worker scheduling. // Progress receives per-file progress updates during a run. type Progress interface { Start(total int) Advance() } -func Run(cfg *config.Config, write bool, prog Progress) ([]FileResult, error) { +// Run scans the config's paths and applies rules across Jobs workers, stopping +// early when ctx is cancelled. Every file error is collected (not just the +// first) and returned joined. +func Run(ctx context.Context, cfg *config.Config, write bool, prog Progress) ([]FileResult, error) { files, err := finder.Find(cfg.Paths, cfg.Skip) if err != nil { return nil, err @@ -47,8 +49,8 @@ func Run(cfg *config.Config, write bool, prog Progress) ([]FileResult, error) { paths := make(chan string) results := make(chan FileResult) - var firstErr error - var errOnce sync.Once + var mu sync.Mutex + var errs []error var wg sync.WaitGroup for range jobs { @@ -59,7 +61,9 @@ func Run(cfg *config.Config, write bool, prog Progress) ([]FileResult, error) { prog.Advance() } if err != nil { - errOnce.Do(func() { firstErr = err }) + mu.Lock() + errs = append(errs, err) + mu.Unlock() continue } if res.Changed() { @@ -69,11 +73,18 @@ func Run(cfg *config.Config, write bool, prog Progress) ([]FileResult, error) { }) } + // Feed paths until exhausted or cancelled, then let the workers finish. go func() { + defer close(paths) for _, p := range files { - paths <- p + select { + case paths <- p: + case <-ctx.Done(): + return + } } - close(paths) + }() + go func() { wg.Wait() close(results) }() @@ -82,8 +93,12 @@ func Run(cfg *config.Config, write bool, prog Progress) ([]FileResult, error) { for r := range results { collected = append(collected, r) } - if firstErr != nil { - return nil, firstErr + + if ctx.Err() != nil { + return nil, ctx.Err() + } + if len(errs) > 0 { + return nil, errors.Join(errs...) } slices.SortFunc(collected, func(a, b FileResult) int { return cmp.Compare(a.Path, b.Path) }) diff --git a/blink/internal/runner/runner_test.go b/blink/internal/runner/runner_test.go index 81bef6b679..359d27b326 100644 --- a/blink/internal/runner/runner_test.go +++ b/blink/internal/runner/runner_test.go @@ -1,6 +1,8 @@ package runner import ( + "context" + "errors" "os" "path/filepath" "testing" @@ -19,7 +21,7 @@ func TestRunFixesFile(t *testing.T) { cfg := config.Configure().WithPaths(dir) // check mode: reports but does not write - results, err := Run(cfg, false, nil) + results, err := Run(context.Background(), cfg, false, nil) if err != nil { t.Fatal(err) } @@ -31,7 +33,7 @@ func TestRunFixesFile(t *testing.T) { } // fix mode: writes cleaned content - if _, err := Run(cfg, true, nil); err != nil { + if _, err := Run(context.Background(), cfg, true, nil); err != nil { t.Fatal(err) } b, _ := os.ReadFile(path) @@ -44,8 +46,22 @@ func TestRunFixesFile(t *testing.T) { } // second check run is clean - results, _ = Run(cfg, false, nil) + results, _ = Run(context.Background(), cfg, false, nil) if len(results) != 0 { t.Fatalf("expected clean after fix, got %+v", results) } } + +func TestRunCancelledContext(t *testing.T) { + dir := t.TempDir() + if err := os.WriteFile(filepath.Join(dir, "a.php"), []byte("