From 4cb795cc205a8395cfeba579631a0fdac17a241a Mon Sep 17 00:00:00 2001 From: Eric Rakestraw Date: Wed, 7 Oct 2026 16:47:28 +0000 Subject: [PATCH] Honor cancellation at standard inspection boundaries --- cancellation_test.go | 120 ++++++++++++++++++++++++++++++++++++ docs/roadmap/remediation.md | 3 + explain.go | 16 +++++ promptkit.go | 26 ++++++++ show.go | 19 ++++++ sources.go | 13 ++++ validate.go | 10 +++ 7 files changed, 207 insertions(+) create mode 100644 cancellation_test.go diff --git a/cancellation_test.go b/cancellation_test.go new file mode 100644 index 0000000..41919a4 --- /dev/null +++ b/cancellation_test.go @@ -0,0 +1,120 @@ +package commandkit_test + +import ( + "bytes" + "context" + "errors" + "fmt" + commandkit "gitea.maximumdirect.net/eric/CommandKit" + configkit "gitea.maximumdirect.net/eric/ConfigKit" + "gitea.maximumdirect.net/eric/promptkit" + "strings" + "testing" +) + +func TestStandardConfigurationCancellationBoundaries(t *testing.T) { + for _, operation := range []string{"validate", "show", "sources", "explain"} { + for _, before := range []bool{true, false} { + t.Run(fmt.Sprintf("%s/before=%t", operation, before), func(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + if before { + cancel() + } + loads, contexts := 0, 0 + scope, err := commandkit.NewConfigKitScope[struct{}, struct{}]("app", "", func(context.Context, commandkit.Selection, struct{}) (*configkit.Result[struct{}], error) { + loads++ + cancel() + return configkit.Load(configkit.NoSource(), configkit.Options[struct{}]{}) + }) + if err != nil { + t.Fatal(err) + } + app, _ := commandkit.New(commandkit.Identity{Name: "example"}) + if err := commandkit.RegisterConfig(app, commandkit.ConfigSuite[struct{}]{Scopes: []commandkit.ConfigScope[struct{}]{scope}, NewContext: func() struct{} { contexts++; return struct{}{} }}); err != nil { + t.Fatal(err) + } + args := []string{"config", operation} + if operation == "explain" { + args = append(args, "--path", "") + } + var out bytes.Buffer + result := app.Execute(ctx, args, commandkit.Streams{Out: &out}) + want := 1 + if before { + want = 0 + } + if result.Kind != commandkit.OutcomeOperational || result.Status != 1 || !errors.Is(result.Cause, context.Canceled) || loads != want || contexts != want || out.Len() != 0 { + t.Fatalf("result=%#v loads=%d contexts=%d out=%q", result, loads, contexts, out.String()) + } + }) + } + } +} + +func TestAggregateCancellationRetainsCompletedValidationAndDiagnostics(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + app, _ := commandkit.New(commandkit.Identity{Name: "example"}) + calls := 0 + acquire := func(context.Context, commandkit.ConfigRequest, struct{}) (commandkit.Acquired[commandkit.ConfigView], error) { + calls++ + if calls == 2 { + cancel() + } + return commandkit.Acquired[commandkit.ConfigView]{Diagnostics: []commandkit.Diagnostic{commandkit.MessageDiagnostic(configkit.SeverityWarning, "acquired warning")}}, nil + } + if err := commandkit.RegisterConfig(app, commandkit.ConfigSuite[struct{}]{Scopes: []commandkit.ConfigScope[struct{}]{ + {Name: "app", Operations: []commandkit.ConfigOperation{commandkit.ConfigValidate}, Acquire: acquire}, + {Name: "promptkit", Operations: []commandkit.ConfigOperation{commandkit.ConfigValidate}, Acquire: acquire}, + {Name: "zeta", Operations: []commandkit.ConfigOperation{commandkit.ConfigValidate}, Acquire: acquire}, + }}); err != nil { + t.Fatal(err) + } + var out, diagnostics bytes.Buffer + result := app.Execute(ctx, []string{"config", "validate", "--scope", "all"}, commandkit.Streams{Out: &out, Err: &diagnostics}) + if !errors.Is(result.Cause, context.Canceled) || !result.ReportIncomplete || calls != 2 || out.String() != "app: valid\n" || strings.Count(diagnostics.String(), "acquired warning") != 2 { + t.Fatalf("result=%#v calls=%d out=%q diagnostics=%q", result, calls, out.String(), diagnostics.String()) + } +} + +func TestDefinitionInspectionCancellationBoundaries(t *testing.T) { + for _, resource := range []string{"prompt", "profile"} { + for _, before := range []bool{true, false} { + t.Run(fmt.Sprintf("%s/before=%t", resource, before), func(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + if before { + cancel() + } + calls, contexts := 0, 0 + diagnostics := []commandkit.Diagnostic{commandkit.MessageDiagnostic(configkit.SeverityWarning, "acquired warning")} + suite := commandkit.PromptKitSuite[struct{}]{NewContext: func() struct{} { contexts++; return struct{}{} }, + InspectPrompt: func(context.Context, commandkit.Selection, struct{}, string, string) (commandkit.Acquired[*promptkit.PromptInspection], error) { + calls++ + cancel() + return commandkit.Acquired[*promptkit.PromptInspection]{Value: &promptkit.PromptInspection{}, Diagnostics: diagnostics}, nil + }, + InspectProfile: func(context.Context, commandkit.Selection, struct{}, string) (commandkit.Acquired[*promptkit.ProfileInspection], error) { + calls++ + cancel() + return commandkit.Acquired[*promptkit.ProfileInspection]{Value: &promptkit.ProfileInspection{}, Diagnostics: diagnostics}, nil + }, + } + app, _ := commandkit.New(commandkit.Identity{Name: "example"}) + if err := commandkit.RegisterPromptKit(app, suite); err != nil { + t.Fatal(err) + } + var out, errs bytes.Buffer + result := app.Execute(ctx, []string{"promptkit", "show", resource, "example"}, commandkit.Streams{Out: &out, Err: &errs}) + want := 1 + if before { + want = 0 + } + if result.Kind != commandkit.OutcomeOperational || !errors.Is(result.Cause, context.Canceled) || calls != want || contexts != want || out.Len() != 0 || (!before && !strings.Contains(errs.String(), "acquired warning")) { + t.Fatalf("result=%#v calls=%d contexts=%d out=%q diagnostics=%q", result, calls, contexts, out.String(), errs.String()) + } + }) + } + } +} diff --git a/docs/roadmap/remediation.md b/docs/roadmap/remediation.md index 170ba05..fec6ccf 100644 --- a/docs/roadmap/remediation.md +++ b/docs/roadmap/remediation.md @@ -51,6 +51,9 @@ architecture. The issues below affect behavior within the implemented structure. - R-02 complete: inline document starts are recognized and native content is retained, including block scalars. +- R-03 complete: standard inspection handlers check cancellation before runtime + construction/acquisition, after acquisition, and before prepared report delivery. + ## Findings and priority P1 means a supported invocation is blocked or its report content is corrupted. diff --git a/explain.go b/explain.go index a44690d..87dcbbd 100644 --- a/explain.go +++ b/explain.go @@ -83,6 +83,9 @@ type secretReferenceDTO struct { } func runConfigExplanation[C any](invocation Invocation, suite ConfigSuite[C], input configInput[C]) Outcome { + if err := invocation.Context.Err(); err != nil { + return Failure(err) + } scopeNames, err := selectConfigScopes(suite, input, ConfigExplain) if err != nil { return Failure(err) @@ -100,11 +103,18 @@ func runConfigExplanation[C any](invocation Invocation, suite ConfigSuite[C], in if !ok || scope.Acquire == nil { return Failure(fmt.Errorf("configuration scope %q has no acquisition callback", name)) } + if err := invocation.Context.Err(); err != nil { + return explanationFailure(err, nil, false) + } acquired, acquireErr := scope.Acquire(invocation.Context, ConfigRequest{Operation: ConfigExplain, Selection: input.Selection}, contexts[name]) diagnostics := append([]Diagnostic(nil), acquired.Diagnostics...) if acquireErr != nil { return explanationFailure(acquireErr, diagnostics, false) } + if err := invocation.Context.Err(); err != nil { + return explanationFailure(err, diagnostics, false) + } + if acquired.Value.Explanation == nil { return explanationFailure(fmt.Errorf("configuration scope %q did not provide an explanation reader", name), diagnostics, false) } @@ -112,6 +122,9 @@ func runConfigExplanation[C any](invocation Invocation, suite ConfigSuite[C], in if !found { return explanationFailure(fmt.Errorf("configuration path %q is not known in scope %q", path.String(), name), diagnostics, false) } + if err := invocation.Context.Err(); err != nil { + return explanationFailure(err, diagnostics, false) + } if input.Format != "yaml" { return writeNativeExplanation(invocation.Streams.Out, acquired.Value.Explanation, path, diagnostics) } @@ -119,6 +132,9 @@ func runConfigExplanation[C any](invocation Invocation, suite ConfigSuite[C], in if err := encodeYAML(&report, makeExplanationReport(name, explanation)); err != nil { return explanationFailure(err, diagnostics, false) } + if err := invocation.Context.Err(); err != nil { + return explanationFailure(err, diagnostics, false) + } if err := writeReport(invocation.Streams.Out, report.Bytes()); err != nil { outcome := explanationFailure(err, diagnostics, true) outcome.DeliveryError = err diff --git a/promptkit.go b/promptkit.go index 39950d2..48fd5b9 100644 --- a/promptkit.go +++ b/promptkit.go @@ -70,6 +70,11 @@ func loadPromptKitInspection[C any](ctx context.Context, selection Selection, ap if engine == nil { return nil, promptkit.ConfigurationInspection{}, nil, fmt.Errorf("PromptKit configuration loader returned a nil engine") } + if ctx != nil { + if err := ctx.Err(); err != nil { + return nil, promptkit.ConfigurationInspection{}, nil, err + } + } inspection, err := engine.InspectConfiguration() if err != nil { return nil, promptkit.ConfigurationInspection{}, nil, err @@ -264,11 +269,20 @@ func newPromptKitProfileCommand[C any](suite PromptKitSuite[C]) (Command, error) } func runPromptKitPrompt[C any](invocation Invocation, suite PromptKitSuite[C], input promptKitInput[C]) Outcome { + if err := invocation.Context.Err(); err != nil { + return Failure(err) + } contextValue := promptKitContext(suite, input) + if err := invocation.Context.Err(); err != nil { + return Failure(err) + } acquired, err := suite.InspectPrompt(invocation.Context, input.Selection, contextValue, input.ID, input.Version) if err != nil { return promptKitFailure(err, acquired.Diagnostics) } + if err := invocation.Context.Err(); err != nil { + return promptKitFailure(err, acquired.Diagnostics) + } if acquired.Value == nil { return promptKitFailure(fmt.Errorf("PromptKit prompt inspection returned no result"), acquired.Diagnostics) } @@ -277,11 +291,20 @@ func runPromptKitPrompt[C any](invocation Invocation, suite PromptKitSuite[C], i } func runPromptKitProfile[C any](invocation Invocation, suite PromptKitSuite[C], input promptKitInput[C]) Outcome { + if err := invocation.Context.Err(); err != nil { + return Failure(err) + } contextValue := promptKitContext(suite, input) + if err := invocation.Context.Err(); err != nil { + return Failure(err) + } acquired, err := suite.InspectProfile(invocation.Context, input.Selection, contextValue, input.ID) if err != nil { return promptKitFailure(err, acquired.Diagnostics) } + if err := invocation.Context.Err(); err != nil { + return promptKitFailure(err, acquired.Diagnostics) + } if acquired.Value == nil { return promptKitFailure(fmt.Errorf("PromptKit profile inspection returned no result"), acquired.Diagnostics) } @@ -308,6 +331,9 @@ func writePromptKitReport(invocation Invocation, format string, report any, text } else if _, err := io.WriteString(&output, textReport); err != nil { return promptKitFailure(err, diagnostics) } + if err := invocation.Context.Err(); err != nil { + return promptKitFailure(err, diagnostics) + } if err := writeReport(invocation.Streams.Out, []byte(output.String())); err != nil { outcome := incompleteReport(err) outcome.Diagnostics = diagnostics diff --git a/show.go b/show.go index 3ea1f6f..81dbbfc 100644 --- a/show.go +++ b/show.go @@ -31,6 +31,9 @@ type effectiveDocument struct { } func runConfigShow[C any](invocation Invocation, suite ConfigSuite[C], input configInput[C]) Outcome { + if err := invocation.Context.Err(); err != nil { + return Failure(err) + } scopeNames, err := selectConfigScopes(suite, input, ConfigShow) if err != nil { return Failure(err) @@ -43,17 +46,27 @@ func runConfigShow[C any](invocation Invocation, suite ConfigSuite[C], input con if !ok || scope.Acquire == nil { return showFailure(fmt.Errorf("configuration scope %q has no acquisition callback", name), diagnostics, false) } + if err := invocation.Context.Err(); err != nil { + return showFailure(err, diagnostics, false) + } acquired, acquireErr := scope.Acquire(invocation.Context, ConfigRequest{Operation: ConfigShow, Selection: input.Selection}, contexts[name]) diagnostics = append(diagnostics, acquired.Diagnostics...) if acquireErr != nil { return showFailure(acquireErr, diagnostics, false) } + if err := invocation.Context.Err(); err != nil { + return showFailure(err, diagnostics, false) + } + if acquired.Value.Effective == nil { return showFailure(fmt.Errorf("configuration scope %q did not provide an effective configuration writer", name), diagnostics, false) } documents = append(documents, effectiveDocument{name: name, view: acquired.Value}) } + if err := invocation.Context.Err(); err != nil { + return showFailure(err, diagnostics, false) + } if len(documents) == 1 && input.Scope != "all" { checked := &checkedWriter{writer: invocation.Streams.Out} renderErr := documents[0].view.Effective.WriteEffective(checked) @@ -76,10 +89,16 @@ func runConfigShow[C any](invocation Invocation, suite ConfigSuite[C], input con written := false for index, document := range documents { + if err := invocation.Context.Err(); err != nil { + return showFailure(err, diagnostics, written) + } var native bytes.Buffer if err := document.view.Effective.WriteEffective(&native); err != nil { return showFailure(err, diagnostics, written) } + if err := invocation.Context.Err(); err != nil { + return showFailure(err, diagnostics, written) + } if index > 0 && hasYAMLDirective(native.Bytes()) { if err := writeReport(invocation.Streams.Out, []byte("...\n")); err != nil { outcome := showFailure(err, diagnostics, true) diff --git a/sources.go b/sources.go index 4f18f33..73931ab 100644 --- a/sources.go +++ b/sources.go @@ -44,6 +44,9 @@ type locationDTO struct { } func runConfigSources[C any](invocation Invocation, suite ConfigSuite[C], input configInput[C]) Outcome { + if err := invocation.Context.Err(); err != nil { + return Failure(err) + } scopeNames, err := selectConfigScopes(suite, input, ConfigSources) if err != nil { return Failure(err) @@ -54,11 +57,18 @@ func runConfigSources[C any](invocation Invocation, suite ConfigSuite[C], input if !ok || scope.Acquire == nil { return sourceReportFailure(fmt.Errorf("configuration scope %q has no acquisition callback", name), nil, false) } + if err := invocation.Context.Err(); err != nil { + return sourceReportFailure(err, nil, false) + } acquired, acquireErr := scope.Acquire(invocation.Context, ConfigRequest{Operation: ConfigSources, Selection: input.Selection}, contexts[name]) diagnostics := append([]Diagnostic(nil), acquired.Diagnostics...) if acquireErr != nil { return sourceReportFailure(acquireErr, diagnostics, false) } + if err := invocation.Context.Err(); err != nil { + return sourceReportFailure(err, diagnostics, false) + } + if acquired.Value.Sources == nil { return sourceReportFailure(fmt.Errorf("configuration scope %q did not provide a source reader", name), diagnostics, false) } @@ -87,6 +97,9 @@ func runConfigSources[C any](invocation Invocation, suite ConfigSuite[C], input } else { writeSourceReportText(&report, document) } + if err := invocation.Context.Err(); err != nil { + return sourceReportFailure(err, diagnostics, false) + } if err := writeReport(invocation.Streams.Out, report.Bytes()); err != nil { outcome := sourceReportFailure(err, diagnostics, true) outcome.DeliveryError = err diff --git a/validate.go b/validate.go index e46492f..d7ddc06 100644 --- a/validate.go +++ b/validate.go @@ -34,6 +34,9 @@ func newConfigValidateCommand[C any](suite ConfigSuite[C]) (Command, error) { } func runConfigValidation[C any](invocation Invocation, suite ConfigSuite[C], input configInput[C]) Outcome { + if err := invocation.Context.Err(); err != nil { + return Failure(err) + } scopeNames, err := selectConfigScopes(suite, input, ConfigValidate) if err != nil { return Failure(err) @@ -46,12 +49,19 @@ func runConfigValidation[C any](invocation Invocation, suite ConfigSuite[C], inp if !ok || scope.Acquire == nil { return validationFailure(fmt.Errorf("configuration scope %q has no acquisition callback", name), diagnostics, writtenRecord) } + if err := invocation.Context.Err(); err != nil { + return validationFailure(err, diagnostics, writtenRecord) + } acquired, acquireErr := scope.Acquire(invocation.Context, ConfigRequest{Operation: ConfigValidate, Selection: input.Selection}, contexts[name]) diagnostics = append(diagnostics, acquired.Diagnostics...) if acquireErr != nil { return validationFailure(acquireErr, diagnostics, writtenRecord) } + if err := invocation.Context.Err(); err != nil { + return validationFailure(err, diagnostics, writtenRecord) + } + record, err := validationSuccessRecord(name, input.Format) if err != nil { return validationFailure(err, diagnostics, writtenRecord)