From 49bf3c36f496b6b72ce8e9d969965aa500533e1f Mon Sep 17 00:00:00 2001 From: Snider Date: Thu, 16 Apr 2026 06:32:41 +0100 Subject: [PATCH] Harden lint cancellation handling --- pkg/lint/adapter.go | 27 ++++++++- pkg/lint/service.go | 3 + pkg/lint/service_test.go | 118 +++++++++++++++++++++++++++++++++++++++ 3 files changed, 147 insertions(+), 1 deletion(-) diff --git a/pkg/lint/adapter.go b/pkg/lint/adapter.go index 812bd59..c0bd4fb 100644 --- a/pkg/lint/adapter.go +++ b/pkg/lint/adapter.go @@ -206,6 +206,15 @@ func (adapter CommandAdapter) Run(ctx context.Context, input RunInput, files []s } } + if err := runContext.Err(); err != nil { + if errors.Is(err, context.DeadlineExceeded) { + result.Tool.Status = "timeout" + } else { + result.Tool.Status = "canceled" + } + return result + } + if adapter.parseOutput != nil && output != "" { result.Findings = adapter.parseOutput(adapter.name, adapter.category, output) } @@ -300,7 +309,7 @@ func (CatalogAdapter) Category() string { return "correctness" } func (CatalogAdapter) Fast() bool { return true } -func (CatalogAdapter) Run(_ context.Context, input RunInput, files []string) AdapterResult { +func (CatalogAdapter) Run(ctx context.Context, input RunInput, files []string) AdapterResult { startedAt := time.Now() result := AdapterResult{ Tool: ToolRun{ @@ -346,6 +355,9 @@ func (CatalogAdapter) Run(_ context.Context, input RunInput, files []string) Ada var findings []Finding if len(files) > 0 { for _, file := range files { + if err := ctx.Err(); err != nil { + break + } scanPath := file if !filepath.IsAbs(scanPath) { scanPath = filepath.Join(input.Path, file) @@ -357,9 +369,22 @@ func (CatalogAdapter) Run(_ context.Context, input RunInput, files []string) Ada findings = append(findings, fileFindings...) } } else { + if ctx.Err() != nil { + result.Tool.Status = "canceled" + result.Tool.Duration = time.Since(startedAt).Round(time.Millisecond).String() + return result + } findings, _ = scanner.ScanDir(input.Path) } + if err := ctx.Err(); err != nil { + result.Tool.Status = "canceled" + result.Tool.Duration = time.Since(startedAt).Round(time.Millisecond).String() + result.Tool.Findings = len(findings) + result.Findings = findings + return result + } + for index := range findings { rule := catalog.ByID(findings[index].RuleID) findings[index].Tool = "catalog" diff --git a/pkg/lint/service.go b/pkg/lint/service.go index 4734fd6..9ea2795 100644 --- a/pkg/lint/service.go +++ b/pkg/lint/service.go @@ -144,6 +144,9 @@ func (service *Service) Run(ctx context.Context, input RunInput) (Report, error) var toolRuns []ToolRun for _, adapter := range selectedAdapters { + if err := ctx.Err(); err != nil { + break + } if input.Hook && !adapter.Fast() { toolRuns = append(toolRuns, ToolRun{ Name: adapter.Name(), diff --git a/pkg/lint/service_test.go b/pkg/lint/service_test.go index df33c14..cdca31a 100644 --- a/pkg/lint/service_test.go +++ b/pkg/lint/service_test.go @@ -566,6 +566,38 @@ func TestServiceTools_EmptyInventoryReturnsEmptySlice(t *testing.T) { assert.Empty(t, tools) } +func TestServiceRun_Good_StopsDispatchingAfterContextCancel(t *testing.T) { + dir := t.TempDir() + require.NoError(t, os.WriteFile(filepath.Join(dir, "composer.json"), []byte("{\n \"name\": \"example/test\"\n}\n"), 0o644)) + require.NoError(t, os.MkdirAll(filepath.Join(dir, ".core"), 0o755)) + require.NoError(t, os.WriteFile(filepath.Join(dir, ".core", "lint.yaml"), []byte(`lint: + php: + - first + - second +`), 0o644)) + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + var secondRan bool + svc := &Service{adapters: []Adapter{ + cancellingAdapter{name: "first", cancel: cancel}, + recordingAdapter{name: "second", ran: &secondRan}, + }} + + report, err := svc.Run(ctx, RunInput{ + Path: dir, + Lang: "php", + FailOn: "warning", + }) + require.NoError(t, err) + + require.Len(t, report.Tools, 1) + assert.Equal(t, "first", report.Tools[0].Name) + assert.False(t, secondRan) + assert.Empty(t, report.Findings) +} + type shortcutAdapter struct { name string category string @@ -599,6 +631,92 @@ func (adapter shortcutAdapter) Run(_ context.Context, _ RunInput, _ []string) Ad } } +type recordingAdapter struct { + name string + ran *bool +} + +func (adapter recordingAdapter) Name() string { return adapter.name } + +func (adapter recordingAdapter) Available() bool { return true } + +func (adapter recordingAdapter) Languages() []string { return []string{"php"} } + +func (adapter recordingAdapter) Command() string { return adapter.name } + +func (adapter recordingAdapter) Entitlement() string { return "" } + +func (adapter recordingAdapter) RequiresEntitlement() bool { return false } + +func (adapter recordingAdapter) MatchesLanguage(languages []string) bool { + for _, language := range languages { + if language == "php" { + return true + } + } + return false +} + +func (adapter recordingAdapter) Category() string { return "correctness" } + +func (adapter recordingAdapter) Fast() bool { return true } + +func (adapter recordingAdapter) Run(_ context.Context, _ RunInput, _ []string) AdapterResult { + if adapter.ran != nil { + *adapter.ran = true + } + return AdapterResult{ + Tool: ToolRun{ + Name: adapter.name, + Status: "passed", + Duration: "0s", + }, + } +} + +type cancellingAdapter struct { + name string + cancel context.CancelFunc +} + +func (adapter cancellingAdapter) Name() string { return adapter.name } + +func (adapter cancellingAdapter) Available() bool { return true } + +func (adapter cancellingAdapter) Languages() []string { return []string{"php"} } + +func (adapter cancellingAdapter) Command() string { return adapter.name } + +func (adapter cancellingAdapter) Entitlement() string { return "" } + +func (adapter cancellingAdapter) RequiresEntitlement() bool { return false } + +func (adapter cancellingAdapter) MatchesLanguage(languages []string) bool { + for _, language := range languages { + if language == "php" { + return true + } + } + return false +} + +func (adapter cancellingAdapter) Category() string { return "correctness" } + +func (adapter cancellingAdapter) Fast() bool { return true } + +func (adapter cancellingAdapter) Run(_ context.Context, _ RunInput, _ []string) AdapterResult { + if adapter.cancel != nil { + adapter.cancel() + } + return AdapterResult{ + Tool: ToolRun{ + Name: adapter.name, + Status: "passed", + Duration: "0s", + }, + } +} + type duplicateAdapter struct { name string finding Finding