ExecuteSubscription producer goroutine leaks permanently when the consumer stops reading resultChannel (unbuffered send ignores p.Context.Done())
Summary
In ExecuteSubscription, the resultChannel is unbuffered (make(chan *Result) at subscription.go:91), and the producer sends results with a bare send that does not select on p.Context.Done():
for {
select {
case <-p.Context.Done():
return
case res, more := <-sub:
if !more {
return
}
resultChannel <- mapSourceToResponse(res) // ← bare unbuffered send, no ctx check
}
}If the consumer (the caller of Subscribe) stops reading resultChannel — which is exactly what happens on unsubscribe / client disconnect — the producer blocks forever on this send. Cancelling the context does not help: the producer is not in a select at that moment, so <-p.Context.Done() never gets a chance to fire. The goroutine leaks permanently.
Environment
- github.com/graphql-go/graphql v0.8.0 / v0.8.1
- Verified also present on current main (commit 6acef3563ff7, 2026-06-23): subscription.go is byte-identical to v0.8.1. The subscription execution path introduced in #495 has never been modified.
Minimal reproduction
package main
import (
"context"
"fmt"
"time"
"github.com/graphql-go/graphql"
)
func main() {
subType := graphql.NewObject(graphql.ObjectConfig{
Name: "Subscription",
Fields: graphql.Fields{
"tick": &graphql.Field{
Type: graphql.Int,
Resolve: func(p graphql.ResolveParams) (interface{}, error) {
return p.Source, nil
},
Subscribe: func(p graphql.ResolveParams) (interface{}, error) {
ch := make(chan interface{}, 1)
ch <- 42
return ch, nil
},
},
},
})
queryType := graphql.NewObject(graphql.ObjectConfig{
Name: "Query",
Fields: graphql.Fields{"ok": &graphql.Field{Type: graphql.Boolean}},
})
schema, _ := graphql.NewSchema(graphql.SchemaConfig{Query: queryType, Subscription: subType})
ctx, cancel := context.WithCancel(context.Background())
resultChannel := graphql.Subscribe(graphql.Params{
Schema: schema,
Context: ctx,
RequestString: "subscription { tick }",
})
// Simulate the consumer going away: stop reading resultChannel, then cancel.
cancel()
select {
case <-resultChannel:
fmt.Println("ok: result channel closed (producer exited)")
case <-time.After(1 * time.Second):
fmt.Println("BUG: result channel NOT closed - producer goroutine leaked")
}
}Expected: after cancel(), the producer exits and resultChannel is closed. Actual: the producer is stuck in the unbuffered send; resultChannel never closes; goroutine leak.
Impact
In real servers, every unsubscribe / client disconnect that races with an in-flight result leaks one goroutine. Under high subscribe/unsubscribe churn this accumulates unboundedly. (This is also the root cause behind graph-gophers/graphql-go#626 "Fixed goroutine leak in subscriptions", which already fixed the same pattern on that side.)
Suggested fix
Wrap the send so cancellation is observed even while blocked on the send:
case res, more := <-sub:
if !more {
return
}
select {
case <-p.Context.Done():
return
case resultChannel <- mapSourceToResponse(res):
}Scope note
The fix above solves the send-blocked leak. It does not solve a producer blocked inside mapSourceToResponse (i.e. a Resolve that synchronously blocks on DB/network): since mapSourceToResponse is evaluated as the select-branch expression before the send is attempted, <-p.Context.Done() cannot preempt it. Fully cancelling an in-flight resolver would be a larger change. The send fix is a strict, low-risk improvement on its own.
Verification performed
- Diffed subscription.go across v0.8.0, v0.8.1, and current main: identical.
- Confirmed no subscription-related changes in the release history since #495.
Source: graphql-go/graphql