Skip to content

fix(router): fix pubsub goroutine leak - #3047

Merged
dkorittki merged 10 commits into
mainfrom
dominik/router-11-pubsub-goroutine-leak
Jul 6, 2026
Merged

fix(router): fix pubsub goroutine leak#3047
dkorittki merged 10 commits into
mainfrom
dominik/router-11-pubsub-goroutine-leak

Conversation

@dkorittki

@dkorittki dkorittki commented Jul 3, 2026

Copy link
Copy Markdown
Contributor

Based on @endigma 's findings and fix in #3038. I took over to polish and fix tests.

Pubsub providers were stored on the graph server. The reality is that every graph mux has their own provider instances. This lead to the graph servers starting providers for each graph mux they spawn but only stopping the ones from the latest graph mux. This leaked provider goroutines on router hot reloads. It only happens with active feature flags and only if Cosmo Streams is active.

The order is as follow:

Situation: one base graph mux with a provider, one feature flag graph mux with a provider

  1. graph server is build
  2. graph mux for base graph is built, storing this muxes provider on the graph servers pubSubProviders field
  3. graph mux for ff graph is built, storing this muxes provider on the graph servers pubSubProviders field (overwriting the ones from the prior mux)
  4. router hot reload happens
  5. old graph server is shutdown
  6. only the providers from the ff graph mux are shut down, the ones from base graph are abandoned

This pull request fixes it by

  • moves ownership of providers to graphMux so each mux manages its own providers
  • on graph server shutdown it closes all providers of all graph muxes belonging to that graph server

Summary by CodeRabbit

Summary by CodeRabbit

  • Bug Fixes

    • Improved hot reload shutdown behavior so reused muxes keep their pub/sub providers running, preserving subscriptions across restarts.
    • Ensured pub/sub providers start and stop with the lifecycle of each mux for more reliable connection handling.
  • Tests

    • Added TestGraphServerShutdown to confirm providers are shut down or retained based on mux reuse.
    • Updated NATS connection lifecycle assertions to match the expected disconnect/close behavior.

Checklist

Open Source AI Manifesto

This project follows the principles of the Open Source AI Manifesto. Please ensure your contribution aligns with its principles.

@github-actions github-actions Bot added the router label Jul 3, 2026
@github-actions

github-actions Bot commented Jul 3, 2026

Copy link
Copy Markdown

Router-nonroot image scan passed

✅ No security vulnerabilities found in image:

ghcr.io/wundergraph/cosmo/router:sha-d3e7b3c6a9d74fa60663d3304adb01bb49274fdf-nonroot

@coderabbitai

coderabbitai Bot commented Jul 3, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 2b6e0e67-a2ee-4b4a-86d8-e0150fe54c3c

📥 Commits

Reviewing files that changed from the base of the PR and between c2b4376 and 897c71f.

📒 Files selected for processing (1)
  • router/core/graph_server.go
🚧 Files skipped from review as they are similar to previous changes (1)
  • router/core/graph_server.go

Walkthrough

Pub/sub provider lifecycle moves from graphServer to graphMux, so mux construction starts providers and mux shutdown stops them. A shutdown reuse test is added, and NATS event log assertions now expect two lifecycle entries per provider.

Changes

Pub/sub provider lifecycle relocation

Layer / File(s) Summary
graphMux and graphServer field changes
router/core/graph_server.go
pubSubProviders moves off graphServer and onto graphMux, and a 5-second provider timeout constant is added.
graphMux provider lifecycle wiring
router/core/graph_server.go
addPubsubProviders, startPubsubProviders, stopPubsubProviders, and providersActionWithTimeout add timed provider lifecycle handling at mux scope; buildGraphMux starts providers, graphMux.Shutdown stops them, and graphServer.Shutdown no longer does pub/sub cleanup.
Shutdown reuse test and NATS log assertions
router/core/graph_server_test.go, router-tests/events/nats_events_test.go
TestGraphServerShutdown adds a reuse-tracking provider to verify reused mux providers are not shut down, and the NATS event test updates its expected disconnect/close log counts from 1 to 2.

Estimated code review effort: 3 (Moderate) | ~25 minutes

Possibly related PRs

  • wundergraph/cosmo#2838: Both PRs refactor graphMux shutdown and hot-reload lifecycle so reused mux resources stay alive.
  • wundergraph/cosmo#3035: Both PRs move pub/sub provider lifecycle from graphServer to graphMux and adjust the shutdown reuse test pattern.
🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly matches the main change: moving pubsub lifecycle management to prevent a router goroutine leak.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch

Warning

There were issues while running some tools. Please review the errors and either fix the tool's configuration or disable the tool if it's a critical failure.

🔧 golangci-lint (2.12.2)

level=error msg="[linters_context] typechecking error: pattern ./...: directory prefix . does not contain main module or its selected dependencies"


Comment @coderabbitai help to get the list of available commands.

@codecov

codecov Bot commented Jul 3, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 86.36364% with 3 lines in your changes missing coverage. Please review.
✅ Project coverage is 61.44%. Comparing base (493cb0a) to head (3e730f5).

Files with missing lines Patch % Lines
router/core/graph_server.go 86.36% 2 Missing and 1 partial ⚠️
Additional details and impacted files
@@             Coverage Diff             @@
##             main    #3047       +/-   ##
===========================================
+ Coverage   48.40%   61.44%   +13.04%     
===========================================
  Files        1128      261      -867     
  Lines      155971    30596   -125375     
  Branches    10684        0    -10684     
===========================================
- Hits        75499    18800    -56699     
+ Misses      78618    10280    -68338     
+ Partials     1854     1516      -338     
Files with missing lines Coverage Δ
router/core/graph_server.go 85.38% <86.36%> (+0.82%) ⬆️

... and 883 files with indirect coverage changes

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
router/core/graph_server.go (1)

2314-2338: 🩺 Stability & Availability | 🔴 Critical | ⚡ Quick win

Shared time.Timer channel can deadlock providersActionWithTimeout when multiple providers time out concurrently.

timer.C only ever delivers a single value. When N providers race against the same timer and the timeout fires, only one of the N goroutines' select statements can receive from timer.C; the remaining N-1 goroutines block indefinitely on their select (since cancellableCtx is never canceled before providersGroup.Wait() returns, and their underlying action(cancellableCtx, provider) goroutine has no other exit signal). This deadlocks providersGroup.Wait() and leaks goroutines — the very failure mode this PR is meant to fix — whenever startPubsubProviders/stopPubsubProviders handles multiple providers that time out together (e.g., during shutdown with several hung pub/sub connections).

Use a cancellable timeout context instead of a shared timer so Done() broadcasts to all waiters, and propagate it into action so providers can actually observe the timeout:

🔧 Proposed fix
 func providersActionWithTimeout(ctx context.Context, providers []datasource.Provider, action func(ctx context.Context, provider datasource.Provider) error, timeout time.Duration, timeoutMessage string) error {
-	cancellableCtx, cancel := context.WithCancel(ctx)
+	timeoutCtx, cancel := context.WithTimeout(ctx, timeout)
 	defer cancel()

-	timer := time.NewTimer(timeout)
-	defer timer.Stop()
-
 	providersGroup := new(errgroup.Group)
 	for _, provider := range providers {
+		provider := provider
 		providersGroup.Go(func() error {
 			actionDone := make(chan error, 1)
 			go func() {
-				actionDone <- action(cancellableCtx, provider)
+				actionDone <- action(timeoutCtx, provider)
 			}()
 			select {
 			case err := <-actionDone:
 				return err
-			case <-timer.C:
+			case <-timeoutCtx.Done():
 				return errors.New(timeoutMessage)
 			}
 		})
 	}

 	return providersGroup.Wait()
 }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@router/core/graph_server.go` around lines 2314 - 2338,
providersActionWithTimeout is using a single shared time.Timer, so only one
goroutine can observe timer.C and the rest may block forever when several
providers time out together. Replace the timer/cancel setup with a
timeout-derived context in providersActionWithTimeout so Done() broadcasts to
every waiter, and pass that context through to action and the provider
goroutines. Keep the existing errgroup wait flow, but make sure each provider
select listens on the shared context cancellation rather than a shared timer
channel.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Outside diff comments:
In `@router/core/graph_server.go`:
- Around line 2314-2338: providersActionWithTimeout is using a single shared
time.Timer, so only one goroutine can observe timer.C and the rest may block
forever when several providers time out together. Replace the timer/cancel setup
with a timeout-derived context in providersActionWithTimeout so Done()
broadcasts to every waiter, and pass that context through to action and the
provider goroutines. Keep the existing errgroup wait flow, but make sure each
provider select listens on the shared context cancellation rather than a shared
timer channel.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: a30d51c8-3f34-4a2d-a851-3c29bbec4b2c

📥 Commits

Reviewing files that changed from the base of the PR and between e242571 and 4c2907e.

📒 Files selected for processing (3)
  • router-tests/events/nats_events_test.go
  • router/core/graph_server.go
  • router/core/graph_server_test.go

@dkorittki
dkorittki marked this pull request as ready for review July 3, 2026 14:24
@dkorittki
dkorittki requested a review from a team as a code owner July 3, 2026 14:24
@dkorittki

Copy link
Copy Markdown
Contributor Author

897c71f fixes what coderrabbit found in #3047 (review)

@endigma
endigma self-requested a review July 6, 2026 09:11
@dkorittki
dkorittki merged commit 3052621 into main Jul 6, 2026
37 checks passed
@dkorittki
dkorittki deleted the dominik/router-11-pubsub-goroutine-leak branch July 6, 2026 11:12
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants