From 16e5c57fd4276acbbdceabf965913945a97a38e9 Mon Sep 17 00:00:00 2001 From: Virgil Date: Sat, 4 Apr 2026 01:16:58 +0000 Subject: [PATCH] feat(process): skip pending runner specs on cancellation Co-Authored-By: Virgil --- runner.go | 44 +++++++++++++++++++++++++++++++++++++++----- runner_test.go | 50 ++++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 89 insertions(+), 5 deletions(-) diff --git a/runner.go b/runner.go index 9a80fab..8bd0a34 100644 --- a/runner.go +++ b/runner.go @@ -123,6 +123,13 @@ func (r *Runner) RunAll(ctx context.Context, specs []RunSpec) (*RunAllResult, er } for len(remaining) > 0 { + if err := ctx.Err(); err != nil { + for name := range remaining { + results[indexMap[name]] = cancelledRunResult("Runner.RunAll", remaining[name], err) + } + break + } + // Find specs ready to run (all dependencies satisfied) ready := make([]RunSpec, 0) for _, spec := range remaining { @@ -276,17 +283,18 @@ func (r *Runner) RunSequential(ctx context.Context, specs []RunSpec) (*RunAllRes results := make([]RunResult, 0, len(specs)) for _, spec := range specs { + if err := ctx.Err(); err != nil { + results = append(results, cancelledRunResult("Runner.RunSequential", spec, err)) + continue + } + result := r.runSpec(ctx, spec) results = append(results, result) if !result.Passed() && !spec.AllowFailure { // Mark remaining as skipped for i := len(results); i < len(specs); i++ { - results = append(results, RunResult{ - Name: specs[i].Name, - Spec: specs[i], - Skipped: true, - }) + results = append(results, skippedRunResult("Runner.RunSequential", specs[i], nil)) } break } @@ -330,6 +338,10 @@ func (r *Runner) RunParallel(ctx context.Context, specs []RunSpec) (*RunAllResul wg.Add(1) go func(i int, spec RunSpec) { defer wg.Done() + if err := ctx.Err(); err != nil { + results[i] = cancelledRunResult("Runner.RunParallel", spec, err) + return + } results[i] = r.runSpec(ctx, spec) }(i, spec) } @@ -366,3 +378,25 @@ func validateSpecs(specs []RunSpec) error { } return nil } + +func skippedRunResult(op string, spec RunSpec, err error) RunResult { + result := RunResult{ + Name: spec.Name, + Spec: spec, + Skipped: true, + } + if err != nil { + result.ExitCode = 1 + result.Error = coreerr.E(op, "skipped", err) + } + return result +} + +func cancelledRunResult(op string, spec RunSpec, err error) RunResult { + result := skippedRunResult(op, spec, err) + if result.Error == nil { + result.ExitCode = 1 + result.Error = coreerr.E(op, "context cancelled", err) + } + return result +} diff --git a/runner_test.go b/runner_test.go index a6081ca..9b16729 100644 --- a/runner_test.go +++ b/runner_test.go @@ -206,6 +206,56 @@ func TestRunner_RunAll_CircularDeps(t *testing.T) { }) } +func TestRunner_ContextCancellation(t *testing.T) { + t.Run("run sequential skips pending specs", func(t *testing.T) { + runner := newTestRunner(t) + + ctx, cancel := context.WithCancel(context.Background()) + cancel() + + result, err := runner.RunSequential(ctx, []RunSpec{ + {Name: "first", Command: "echo", Args: []string{"1"}}, + {Name: "second", Command: "echo", Args: []string{"2"}}, + }) + require.NoError(t, err) + + assert.Equal(t, 0, result.Passed) + assert.Equal(t, 0, result.Failed) + assert.Equal(t, 2, result.Skipped) + require.Len(t, result.Results, 2) + for _, res := range result.Results { + assert.True(t, res.Skipped) + assert.Equal(t, 1, res.ExitCode) + assert.Error(t, res.Error) + assert.Contains(t, res.Error.Error(), "context canceled") + } + }) + + t.Run("run all skips pending specs", func(t *testing.T) { + runner := newTestRunner(t) + + ctx, cancel := context.WithCancel(context.Background()) + cancel() + + result, err := runner.RunAll(ctx, []RunSpec{ + {Name: "first", Command: "echo", Args: []string{"1"}}, + {Name: "second", Command: "echo", Args: []string{"2"}, After: []string{"first"}}, + }) + require.NoError(t, err) + + assert.Equal(t, 0, result.Passed) + assert.Equal(t, 0, result.Failed) + assert.Equal(t, 2, result.Skipped) + require.Len(t, result.Results, 2) + for _, res := range result.Results { + assert.True(t, res.Skipped) + assert.Equal(t, 1, res.ExitCode) + assert.Error(t, res.Error) + assert.Contains(t, res.Error.Error(), "context canceled") + } + }) +} + func TestRunResult_Passed(t *testing.T) { t.Run("success", func(t *testing.T) { r := RunResult{ExitCode: 0}