From ff57dbf8b3d868864fcc7596e15fe601252e3f2f Mon Sep 17 00:00:00 2001 From: Eric Rakestraw Date: Thu, 5 Nov 2026 15:58:32 +0000 Subject: [PATCH] Preserve authentication cancellation at the response limit --- bundled_smoke_contract_test.go | 40 +++++++++++++++++++++++ docs/roadmap/remediation.md | 6 +++- internal/llm/openrouter_authentication.go | 3 ++ 3 files changed, 48 insertions(+), 1 deletion(-) diff --git a/bundled_smoke_contract_test.go b/bundled_smoke_contract_test.go index 92bdbd9..f751876 100644 --- a/bundled_smoke_contract_test.go +++ b/bundled_smoke_contract_test.go @@ -3,6 +3,7 @@ package promptkit_test import ( "context" "encoding/json" + "errors" "io" "net/http" "reflect" @@ -13,6 +14,45 @@ import ( "go.maximumdirect.net/promptkit" ) +type cancelingAuthenticationBody struct { + remaining int + cancel context.CancelFunc + closed bool +} + +func (b *cancelingAuthenticationBody) Read(p []byte) (int, error) { + n := min(len(p), b.remaining) + for i := range p[:n] { + p[i] = 'x' + } + b.remaining -= n + if b.remaining == 0 { + b.cancel() + return n, context.Canceled + } + return n, nil +} +func (b *cancelingAuthenticationBody) Close() error { b.closed = true; return nil } + +func TestAuthenticationReadCancellationWinsOverOverflow(t *testing.T) { + for _, size := range []int{1024, (1 << 20) + 1} { + ctx, cancel := context.WithCancel(context.Background()) + body := &cancelingAuthenticationBody{remaining: size, cancel: cancel} + engine, err := promptkit.NewEngine(promptkit.Config{HTTPClient: &http.Client{Transport: roundTripFunc(func(*http.Request) (*http.Response, error) { + return &http.Response{StatusCode: 200, Header: make(http.Header), Body: body}, nil + })}}) + if err != nil { + cancel() + t.Fatal(err) + } + report, err := engine.SmokeTest(ctx, promptkit.SmokeTestRequest{Kind: promptkit.SmokeTestAuthentication, BackendID: "openrouter"}) + cancel() + if !errors.Is(err, context.Canceled) || report == nil || report.Completed || report.Failure != promptkit.ExecutionFailureCanceled || report.Facts.CredentialsAccepted != nil || report.Facts.ResponseReceived == nil || !*report.Facts.ResponseReceived || !body.closed { + t.Fatalf("size %d: report=%+v error=%v closed=%v", size, report, err, body.closed) + } + } +} + func TestBundledSmokeHTTPDefaults(t *testing.T) { t.Setenv("OPENROUTER_API_KEY", "synthetic-key") var urls []string diff --git a/docs/roadmap/remediation.md b/docs/roadmap/remediation.md index 0327e2f..8658a85 100644 --- a/docs/roadmap/remediation.md +++ b/docs/roadmap/remediation.md @@ -27,7 +27,11 @@ contracts. ### 1. Preserve Caller Cancellation When Authentication Also Overflows [P2] -**Status:** Open. **Stages:** 7, 21–22. +**Status:** Complete. **Stages:** 7, 21–22. + +Completion: interrupted body reads preserve caller cancellation before overflow +classification. Public below-limit and overflow-boundary regressions retain HTTP +facts, unknown acceptance, incomplete cancellation reports, and body closure. **Locations:** [authentication adapter](../../internal/llm/openrouter_authentication.go), `ProbeOpenRouterKey`, lines 61–67; diff --git a/internal/llm/openrouter_authentication.go b/internal/llm/openrouter_authentication.go index a3afd5b..b6ee30a 100644 --- a/internal/llm/openrouter_authentication.go +++ b/internal/llm/openrouter_authentication.go @@ -59,6 +59,9 @@ func (c *OpenAICompatibleClient) ProbeOpenRouterKey(ctx context.Context, endpoin return result, nil } contents, err := io.ReadAll(io.LimitReader(response.Body, maxAuthenticationResponseBytes+1)) + if err != nil && ctx.Err() != nil { + return result, ctx.Err() + } if len(contents) > maxAuthenticationResponseBytes { result.Overflow = true return result, nil