Skip to content

Commit c416c52

Browse files
authored
[TT-17921] Fix cross-request state leaks in subscriptions and the v2 execution engine (#449)
## Summary Five fixes for state that was shared between unrelated GraphQL requests: cached plans, pooled execution contexts, upstream websocket connections and resolver triggers. In each case one caller's headers, credentials or subscription data could end up serving another caller. Every fix is applied to both the `pkg/` (v1) and `v2/pkg/` trees and landed as its own commit. The common shape: things that are per-request — upstream headers, connection_init payloads, request contexts, the operation itself — were written into, keyed on, or handed to structures that outlive the request (the plan cache, a pooled `resolve.Context`, a client-wide field, a hash that doesn't identify what it groups). ## What changed ### 1. move header modification to the execution phase Upstream headers and header modifiers were applied by registering post-processors on the plan post-processor. `getCachedPlan` stores the post-processed plan in `executionPlanCache`, so the first request's headers were baked into the plan that every later request with the same operation reuses. `WithUpstreamHeaders` / `WithHeaderModifier` now write to the per-request `resolve.Context` (`UpstreamHeaders`, `HeaderModifier`) and are applied when the fetch input is built. ### 2. isolate request specific execution state Header work was split across `ApplyHeaderModifier` / `MergeInputHeader`, applied late in the load path and silently returning the input unchanged on any error; the pooled `resolve.Context` also kept `UpstreamHeaders` / `HeaderModifier` alive past `Free()`. Consolidated into `httpclient.FinalizeInputHeaders(input, modifier, upstreamHeaders)`, which reports errors instead of no-op'ing, applied per fetch in `Loader.finalizeInput` (single / entity / batch) and in `Resolver.subscriptionInput`. `Context.Free()` clears the request-scoped fields and `clone()` deep-copies them. ### 3. preserve the subscription request context `websocket.HandleWithOptions` called `subscriptionHandler.Handle(context.Background())`, so a websocket subscription's upstream calls were never tied to the incoming request: no cancellation, no deadline or trace propagation. Adds `HandleOptions.Context` and `WithContext(ctx)` (nil-safe, defaults to `context.Background()`). The context reaches the outbound upstream HTTP request, which lets a caller such as the gateway's websocket handoff forward the incoming request's context. ### 4. context ownership and orphaned goroutines in SSE Two defects: - `gqlSSEConnectionHandler.StartBlocking` never returned when the subscribe goroutine finished without reporting an error — upstream `event: complete`, end of stream, or a failed subscription request. The goroutine leaked and, because teardown happens in its deferred func, the client's subscription was never completed and stayed open until the client disconnected. A `done` channel now ends the loop. - `AsyncResolveGraphQLSubscription` handed the caller's pooled `*resolve.Context` to the resolver's event loop and returned immediately; the caller then freed it for the next request, so the trigger could be started with a freed or already-reused context. It now snapshots the context synchronously. `clone()` also copies `InitialPayload` / `Extensions`, and `Free()` clears `InitialPayload`. ### 5. make subscription grouping collision safe Three groupings that merged unrelated work: - **Upstream connection reuse** was keyed on `hash(URL, headers)`, computed *before* the `connection_init` message exists. That payload comes from `OnWsConnectionInitCallback(reqCtx, …)`, the hook used to forward the calling user's credentials, so two users with identical upstream headers were multiplexed onto the connection authenticated as the first one. The key is now an exact descriptor that includes the init message and, in v2, the resolved forwarded client headers. - **The negotiated sub-protocol** was written back onto the shared client (`c.wsSubProtocol = conn.Subprotocol()`), making the first upstream's choice a client-wide default that also narrowed the protocols offered on every later dial. It is a local now. - **Resolver triggers** were grouped purely by `triggerID`, which for the graphql datasource hashes the connection descriptor (URL, headers, forwarded headers, extensions) and never the operation. A second subscription with a different query was attached to the first trigger, never started upstream, and was fed the first subscription's messages. Triggers now carry their `input` and `source` and probe for an exact match before joining. ### 6. fix: compute the schema hash once at construction Schema.Hash() filled in s.hash on first use. A schema is shared by every request of an API, so two concurrent requests validating against it raced on that write — both callers, Request.ValidateForSchema and Request.IsValidated, are on the request path. The hash is now computed while the schema is built (which is what the v2 module already did) and Hash() is a pure read; Normalize() carries the hash of the normalized document across, in both trees. ### 7. fix: stop mutating the cached plan at resolve time ResolveGraphQLResponse filled in response.Info on first use. A GraphQLResponse is the cached execution plan, shared by every concurrent request resolving that operation, so the write raced with the read two lines below and with Loader.LoadGraphQLResponseData. The planner only populates Info when Config.IncludeInfo is set, which nothing in the engine wrapper or the gateway does, so this fired for live traffic on every operation. Resolving now reads through a shared immutable default instead of writing. The same pattern applied to request tracing: the loader wrote each fetch's DataSourceLoadTrace onto the plan's fetch structs, racing between concurrent traced requests. Traces now live in a per-request table on Resolvable; GetTrace's exported signature is unchanged and the Trace fields on the fetch structs are left in place but deprecated. Behaviour note: with Info no longer filled in, a subscription update's response falls back to the shared default (OperationTypeQuery) where the loader previously saw nil. Subgraph error paths reported during subscription updates therefore gain a query prefix (… at path 'query.field'). The query and mutation paths are unchanged. ### 8. `perf: cut the per-fetch cost of finalizing headers` (from review) Fix 2 moved header work out of the plan and onto the fetch path, so it stopped being computed once per cached plan and started being computed once per fetch. Same round trip as before — `ProcessModifyHeader` already did `jsonparser.Get` → `json.Unmarshal` → modifier → `json.Marshal` → `jsonparser.Set` — but no longer amortised, and the gateway installs a header modifier on every GraphQL request, so the early return in `FinalizeInputHeaders` never fires in production. Profiling the call split showed where it actually goes, per fetch with a five-header input: | stage | ns/op | allocs/op | | --- | --- | --- | | read the header object | 2877 | 54 | | modifier callback (caller's code) | 400 | 6 | | `json.Marshal` | 595 | 14 | | `sjson.SetRawBytes` | 378 | 2 | The read side is two thirds of it, and all of that is `encoding/json` building an intermediate `map[string]json.RawMessage` and a `[]string` per entry, only to throw them away. It now walks the object once with `jsonparser.ObjectEach`/`ArrayEach`. The write side hand-rolls the object instead of reflecting over `http.Header`, into a pooled buffer. `sjson.SetRawBytes` stays a single call — it copies the whole input document, so setting keys individually would have cost one copy per header. **4402 → 1947 ns/op, 77 → 27 allocs/op, 4163 → 1825 B/op** (Apple M4 Max, both trees). The `nothing to do` early return stays allocation-free. `http.Header` still has to be materialised — `HeaderModifier` is `func(http.Header)` and the gateway's implementation rewrites every entry — so the map itself is the remaining floor. ## Tests 47 new test functions (28 distinct names, most present in both trees), ~1.5k lines. Each fix's tests were written first and observed failing against the unpatched code. Highlights: | Area | Tests | | --- | --- | | Plan cache / header isolation | `TestExecutionEngineV2_Execute_PlanCacheHeaderIsRequestScoped`, `..._UpstreamHeadersAppliedPerRequest`, `..._HeaderModifierAppliedPerRequest` | | Header finalization | `TestFinalizeInputHeaders`, `TestApplyHeaderModifier*`, `TestMergeInputHeader`, `TestContextFreeClearsRequestHeaderOptions`, `TestContextCloneIsolatesRequestHeaderOptions` | | Subscription request context | `TestWithContext`, `TestHandleWithOptions_ContextValuePropagatesToUpstreamRequest`, `TestHandleWithOptions_CanceledContextAbortsOperation` | | SSE lifetime & context ownership | `TestSSEConnectionHandlerStartBlockingReturnsWhenUpstreamStops`, `TestAsyncResolveGraphQLSubscriptionOwnsCallerContext`, `...KeepsRequestContextAfterCallerFree`, `TestContextCloneCopiesInitialPayloadAndExtensions`, `TestContextFreeClearsInitialPayload` | | Connection & trigger grouping | `TestSubscriptionClientConnectionInitIsolation`, `TestSubscriptionClientDoesNotPinNegotiatedSubProtocol`, `TestResolver_SubscriptionsAreNotMergedByTriggerIDAlone`, plus direct unit tests for the new helpers: `TestConnectionKey`, `TestSameSubscriptionSource` | Existing contracts were deliberately kept green: `TestWebsocketSubscriptionClientDeDuplication` (v1) and `TestWebsocketConnectionReuse` (v2) still yield one handler for equivalent descriptors and two when forwarded headers differ, and `TestResolver_SubscriptionsAreNotMergedByTriggerIDAlone/identical_subscriptions_still_share_one_trigger` guards that multiplexing survives the trigger split. ## Notes for reviewers - Trigger reuse now additionally requires the same `Trigger.Source` pointer. Identical operations share a cached plan and therefore still multiplex; a plan-cache miss would open a second upstream connection instead of sharing one — more connections, never wrong data. - Connection keys are exact strings instead of 64-bit hashes, so they can only ever split connections the hash used to merge. Map keys are larger. - `FinalizeInputHeaders` returns an error where the previous helpers silently returned the input unchanged. - No public API removed. `websocket.HandleOptions` gains `Context`, and `WithContext` is additive. ## Related Follows #446 (TT-17442, upstream websocket connection reuse ignoring per-request auth headers) and #447 (TT-17578, websocket request context leaking into engine fetches), which fixed adjacent cases in the same area. Thanks to @chrisanderton and @LLe27 for initial research and patches for this issue(s)
1 parent 1330dc1 commit c416c52

46 files changed

Lines changed: 4545 additions & 278 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

pkg/engine/datasource/graphql_datasource/graphql_sse_handler.go

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -43,13 +43,17 @@ func (h *gqlSSEConnectionHandler) StartBlocking(sub Subscription) {
4343

4444
dataCh := make(chan []byte)
4545
errCh := make(chan []byte)
46+
done := make(chan struct{})
4647
defer func() {
4748
close(dataCh)
4849
close(errCh)
4950
close(sub.next)
5051
}()
5152

52-
go h.subscribe(reqCtx, sub, dataCh, errCh)
53+
go func() {
54+
defer close(done)
55+
h.subscribe(reqCtx, sub, dataCh, errCh)
56+
}()
5357

5458
for {
5559
select {
@@ -58,6 +62,8 @@ func (h *gqlSSEConnectionHandler) StartBlocking(sub Subscription) {
5862
case err := <-errCh:
5963
sub.next <- err
6064
return
65+
case <-done:
66+
return
6167
case <-reqCtx.Done():
6268
return
6369
}

pkg/engine/datasource/graphql_datasource/graphql_sse_handler_test.go

Lines changed: 119 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -499,3 +499,122 @@ func TestGraphQLSubscriptionClientSubscribe_SSE_Upstream_Dies(t *testing.T) {
499499
}, time.Second, time.Millisecond*10, "server did not close")
500500
serverCancel()
501501
}
502+
503+
// TestSSEConnectionHandlerStartBlockingReturnsWhenUpstreamStops guards against the SSE handler
504+
// outliving its upstream. The subscribe goroutine returns without reporting anything on errCh
505+
// whenever the upstream is done with us - it sent "event: complete", it ended the stream, or the
506+
// subscription request itself failed. StartBlocking has to notice that and return as well,
507+
// otherwise the goroutine leaks and, because closing the subscription happens in its deferred
508+
// func, the client's subscription is never completed and stays open until the client disconnects.
509+
func TestSSEConnectionHandlerStartBlockingReturnsWhenUpstreamStops(t *testing.T) {
510+
startBlocking := func(t *testing.T, serverURL string) chan []byte {
511+
t.Helper()
512+
513+
reqCtx, cancel := context.WithCancel(context.Background())
514+
t.Cleanup(cancel)
515+
516+
next := make(chan []byte)
517+
handler := newSSEConnectionHandler(reqCtx, http.DefaultClient, GraphQLSubscriptionOptions{
518+
URL: serverURL,
519+
Body: GraphQLBody{
520+
Query: `subscription {messageAdded(roomName: "room"){text}}`,
521+
},
522+
UseSSE: true,
523+
}, logger())
524+
525+
go handler.StartBlocking(Subscription{
526+
ctx: reqCtx,
527+
next: next,
528+
})
529+
530+
return next
531+
}
532+
533+
requireMessage := func(t *testing.T, next chan []byte, expected string) {
534+
t.Helper()
535+
536+
select {
537+
case message, ok := <-next:
538+
require.True(t, ok, "subscription was closed before the expected message arrived")
539+
require.Equal(t, expected, string(message))
540+
case <-time.After(time.Second):
541+
t.Fatal("timed out waiting for a message from the upstream")
542+
}
543+
}
544+
545+
requireSubscriptionClosed := func(t *testing.T, next chan []byte) {
546+
t.Helper()
547+
548+
select {
549+
case message, ok := <-next:
550+
assert.False(t, ok, "expected the subscription to be closed, got another message: %s", message)
551+
case <-time.After(time.Second):
552+
t.Fatal("StartBlocking did not return after the upstream stopped sending events")
553+
}
554+
}
555+
556+
t.Run("on upstream complete event", func(t *testing.T) {
557+
// Keeps the upstream connection open after the complete event, so the complete event
558+
// itself is the only thing that can end the handler.
559+
serverBlock := make(chan struct{})
560+
561+
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
562+
flusher, ok := w.(http.Flusher)
563+
require.True(t, ok)
564+
565+
w.Header().Set("Content-Type", "text/event-stream")
566+
w.Header().Set("Cache-Control", "no-cache")
567+
w.Header().Set("Connection", "keep-alive")
568+
569+
_, _ = fmt.Fprintf(w, "event: next\ndata: %s\n\n", `{"data":{"messageAdded":{"text":"first"}}}`)
570+
flusher.Flush()
571+
572+
_, _ = fmt.Fprint(w, "event: complete\n\n")
573+
flusher.Flush()
574+
575+
<-serverBlock
576+
}))
577+
// LIFO: release the server handler before server.Close() waits for it.
578+
defer server.Close()
579+
defer close(serverBlock)
580+
581+
next := startBlocking(t, server.URL)
582+
583+
requireMessage(t, next, `{"data":{"messageAdded":{"text":"first"}}}`)
584+
requireSubscriptionClosed(t, next)
585+
})
586+
587+
t.Run("on upstream end of stream", func(t *testing.T) {
588+
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
589+
flusher, ok := w.(http.Flusher)
590+
require.True(t, ok)
591+
592+
w.Header().Set("Content-Type", "text/event-stream")
593+
w.Header().Set("Cache-Control", "no-cache")
594+
w.Header().Set("Connection", "keep-alive")
595+
596+
_, _ = fmt.Fprintf(w, "data: %s\n\n", `{"data":{"messageAdded":{"text":"first"}}}`)
597+
flusher.Flush()
598+
599+
// Returning ends the response body, which the handler reads as io.EOF.
600+
}))
601+
defer server.Close()
602+
603+
next := startBlocking(t, server.URL)
604+
605+
requireMessage(t, next, `{"data":{"messageAdded":{"text":"first"}}}`)
606+
requireSubscriptionClosed(t, next)
607+
})
608+
609+
t.Run("on failed subscription request", func(t *testing.T) {
610+
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
611+
w.WriteHeader(http.StatusInternalServerError)
612+
}))
613+
defer server.Close()
614+
615+
next := startBlocking(t, server.URL)
616+
617+
requireMessage(t, next, internalError)
618+
requireSubscriptionClosed(t, next)
619+
})
620+
}

pkg/engine/datasource/graphql_datasource/graphql_subscription_client.go

Lines changed: 32 additions & 49 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
package graphql_datasource
22

33
import (
4+
"bytes"
45
"context"
56
"fmt"
67
"math"
@@ -9,7 +10,6 @@ import (
910
"time"
1011

1112
"github.com/buger/jsonparser"
12-
"github.com/cespare/xxhash/v2"
1313
nhooyrwebsocket "github.com/coder/websocket"
1414
"github.com/jensneuse/abstractlogger"
1515

@@ -20,14 +20,13 @@ const ackWaitTimeout = 30 * time.Second
2020

2121
// SubscriptionClient allows running multiple subscriptions via the same WebSocket either SSE connection
2222
// It takes care of de-duplicating connections to the same origin under certain circumstances
23-
// If Hash(URL,Body,Headers) result in the same result, an existing connection is re-used
23+
// If URL and final headers are identical, an existing connection is re-used.
2424
type SubscriptionClient struct {
2525
streamingClient *http.Client
2626
httpClient *http.Client
2727
engineCtx context.Context
2828
log abstractlogger.Logger
29-
hashPool sync.Pool
30-
handlers map[uint64]ConnectionHandler
29+
handlers map[string]ConnectionHandler
3130
handlersMu sync.Mutex
3231
wsSubProtocol string
3332
onWsConnectionInitCallback *OnWsConnectionInitCallback
@@ -89,17 +88,12 @@ func NewGraphQLSubscriptionClient(httpClient, streamingClient *http.Client, engi
8988
option(op)
9089
}
9190
return &SubscriptionClient{
92-
httpClient: httpClient,
93-
streamingClient: streamingClient,
94-
engineCtx: engineCtx,
95-
handlers: make(map[uint64]ConnectionHandler),
96-
log: op.log,
97-
readTimeout: op.readTimeout,
98-
hashPool: sync.Pool{
99-
New: func() interface{} {
100-
return xxhash.New()
101-
},
102-
},
91+
httpClient: httpClient,
92+
streamingClient: streamingClient,
93+
engineCtx: engineCtx,
94+
handlers: make(map[string]ConnectionHandler),
95+
log: op.log,
96+
readTimeout: op.readTimeout,
10397
wsSubProtocol: op.wsSubProtocol,
10498
onWsConnectionInitCallback: op.onWsConnectionInitCallback,
10599
}
@@ -158,8 +152,11 @@ func (c *SubscriptionClient) subscribeWS(reqCtx context.Context, options GraphQL
158152
next: next,
159153
}
160154

161-
// each WS connection to an origin is uniquely identified by the Hash(URL,Headers,Body)
162-
handlerID, err := c.generateHandlerIDHash(options)
155+
connectionInitMessage, err := c.getConnectionInitMessage(reqCtx, options.URL, options.Header)
156+
if err != nil {
157+
return err
158+
}
159+
handlerID, err := connectionKey(options, connectionInitMessage)
163160
if err != nil {
164161
return err
165162
}
@@ -175,14 +172,14 @@ func (c *SubscriptionClient) subscribeWS(reqCtx context.Context, options GraphQL
175172
return nil
176173
}
177174

178-
handler, err = c.newWSConnectionHandler(reqCtx, options)
175+
handler, err = c.newWSConnectionHandler(reqCtx, options, connectionInitMessage)
179176
if err != nil {
180177
return err
181178
}
182179

183180
c.handlers[handlerID] = handler
184181

185-
go func(handlerID uint64) {
182+
go func(handlerID string) {
186183
handler.StartBlocking(sub)
187184
c.handlersMu.Lock()
188185
delete(c.handlers, handlerID)
@@ -192,28 +189,19 @@ func (c *SubscriptionClient) subscribeWS(reqCtx context.Context, options GraphQL
192189
return nil
193190
}
194191

195-
// generateHandlerIDHash generates a Hash based on: URL and Headers to uniquely identify Upgrade Requests
196-
func (c *SubscriptionClient) generateHandlerIDHash(options GraphQLSubscriptionOptions) (uint64, error) {
197-
var (
198-
err error
199-
)
200-
xxh := c.hashPool.Get().(*xxhash.Digest)
201-
defer c.hashPool.Put(xxh)
202-
xxh.Reset()
203-
204-
_, err = xxh.WriteString(options.URL)
205-
if err != nil {
206-
return 0, err
207-
}
208-
err = options.Header.Write(xxh)
209-
if err != nil {
210-
return 0, err
192+
func connectionKey(options GraphQLSubscriptionOptions, connectionInitMessage []byte) (string, error) {
193+
var key bytes.Buffer
194+
key.WriteString(options.URL)
195+
key.WriteByte(0)
196+
if err := options.Header.Write(&key); err != nil {
197+
return "", err
211198
}
212-
213-
return xxh.Sum64(), nil
199+
key.WriteByte(0)
200+
key.Write(connectionInitMessage)
201+
return key.String(), nil
214202
}
215203

216-
func (c *SubscriptionClient) newWSConnectionHandler(reqCtx context.Context, options GraphQLSubscriptionOptions) (ConnectionHandler, error) {
204+
func (c *SubscriptionClient) newWSConnectionHandler(reqCtx context.Context, options GraphQLSubscriptionOptions, connectionInitMessage []byte) (ConnectionHandler, error) {
217205
subProtocols := []string{ProtocolGraphQLWS, ProtocolGraphQLTWS}
218206
if c.wsSubProtocol != "" {
219207
subProtocols = []string{c.wsSubProtocol}
@@ -235,32 +223,27 @@ func (c *SubscriptionClient) newWSConnectionHandler(reqCtx context.Context, opti
235223
return nil, fmt.Errorf("upgrade unsuccessful")
236224
}
237225

238-
connectionInitMessage, err := c.getConnectionInitMessage(reqCtx, options.URL, options.Header)
239-
if err != nil {
240-
return nil, err
241-
}
242-
243226
// init + ack
244227
err = conn.Write(reqCtx, nhooyrwebsocket.MessageText, connectionInitMessage)
245228
if err != nil {
246229
return nil, err
247230
}
248231

249-
if c.wsSubProtocol == "" {
250-
c.wsSubProtocol = conn.Subprotocol()
251-
}
252-
253232
if err := waitForAck(reqCtx, conn); err != nil {
254233
return nil, err
255234
}
256235

257-
switch c.wsSubProtocol {
236+
protocol := c.wsSubProtocol
237+
if protocol == "" {
238+
protocol = conn.Subprotocol()
239+
}
240+
switch protocol {
258241
case ProtocolGraphQLWS:
259242
return newGQLWSConnectionHandler(c.engineCtx, conn, c.readTimeout, c.log), nil
260243
case ProtocolGraphQLTWS:
261244
return newGQLTWSConnectionHandler(c.engineCtx, conn, c.readTimeout, c.log), nil
262245
default:
263-
return nil, fmt.Errorf("unknown protocol %s", conn.Subprotocol())
246+
return nil, fmt.Errorf("unknown protocol %s", protocol)
264247
}
265248
}
266249

0 commit comments

Comments
 (0)