graphql-go / graphql-go/graphql
ExecuteSubscription producer goroutine leaks permanently when the consumer stops reading resultChannel (unbuffered send ignores p.Context.Done())
- Dominant language
- Go
- Stars
- 10.1k
- Forks
- 845
- PR merge metrics
- No merged PRs in 30d
Description
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.
Contributor guide
Research direction
Start in subscription.go at ExecuteSubscription and the resultChannel send around line 91. Run the minimal reproduction from the issue to observe whether cancellation closes the result channel, then inspect the surrounding subscription flow. Done means a consumer that stops reading and cancels the context no longer leaves the producer blocked, while normal subscription results still flow and the channel closes.
Written by the indexing model from the issue text.
Assessment
- Tech stack
- go, graphql
- Domain
- api, backend
- Issue type
- Bug
- Difficulty
- 2/5
- Estimated time
- 1-3 hours
- Activity status
- Active
- Clarity
- Clearly specified
- Newbie friendliness
- 86/100