#758·graphql

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

Author: hsiehCreated Aug 26, 2026Updated Aug 26, 2026

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.