graphql-go / graphql-go/graphql

ExecuteSubscription producer goroutine leaks permanently when the consumer stops reading resultChannel (unbuffered send ignores p.Context.Done())

Open Beginner friendly
#758 0 comments 0 reactions 0 assignees View on GitHub
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

Open the contributing 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

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.