Skip to content

Commit d7f4546

Browse files
committed
fix(realtime): repair hub shutdown deadlock and WaitGroup misuse
internal/realtime has timed out at 3m under -race on seven CI runs since 2026-08-21 (#212), on PRs that touch no Go code. Three defects, all in the Hub start/stop lifecycle: 1. The writer goroutine's cleanup sent on h.unregister unconditionally after a racy h.stopped pre-check. Stop() can flip that flag and the run loop can return between the load and the send, leaving the writer blocked forever on a channel nobody drains — so writerWg.Wait() never returns and shutdown wedges. This is the 3m hang. Select on stopCh and close the client directly instead; the existing CAS guard keeps that safe against the run loop's own stop-path close. HandleWebSocket's h.register send had the same shape and gets the same treatment. 2. Run() called h.wg.Add(1) from inside the goroutine while Stop() called h.wg.Wait() — an Add from zero concurrent with Wait. Stop() could return before the loop had started, leaving it running afterwards. The count is now taken in NewHub and consumed by whichever of Run or Stop claims runOwner first, so Add always happens-before Wait and a hub that is never Run still stops cleanly. 3. HandleWebSocket called h.writerWg.Add(1) with no ordering against Stop()'s writerWg.Wait(), same misuse. Writer slots are now reserved through admitWriter() under lifecycleMu, which refuses once Stop has begun, so every Add is ordered before the Wait. The dead h.stopped flag is removed with its last reader. TestStopDoesNotDeadlockAgainstChurn drives connect/disconnect churn against a concurrent Stop() and fails on the unpatched hub (verified: DATA RACE) while passing on the fix. Also run: 20x internal/realtime under -race with GOMAXPROCS=2 (340 tests, green) and the full suite (1527 tests, 30 packages, green).
1 parent db7ac81 commit d7f4546

2 files changed

Lines changed: 147 additions & 14 deletions

File tree

internal/realtime/hub.go

Lines changed: 77 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -91,11 +91,21 @@ type Hub struct {
9191
// revision-driven publication the event hub performs (#164).
9292
aggregateMode atomic.Bool
9393

94-
stopCh chan struct{}
95-
stopped atomic.Bool
96-
wg sync.WaitGroup
97-
writerWg sync.WaitGroup // tracks writer goroutines
98-
devMode bool
94+
stopCh chan struct{}
95+
// runOwner is claimed exactly once, by whichever of Run or Stop gets there
96+
// first. It balances the wg count taken in NewHub: Run consumes it when it
97+
// starts the loop, Stop consumes it when the hub is stopped without ever
98+
// having been run. Taking the count at construction is what makes
99+
// wg.Add happen-before wg.Wait, which `go hub.Run()` cannot guarantee.
100+
runOwner atomic.Bool
101+
// lifecycleMu serialises writerWg.Add against Stop's writerWg.Wait.
102+
// Without it the handler can Add from zero while Stop is already
103+
// waiting, which is WaitGroup misuse and a real data race.
104+
lifecycleMu sync.Mutex
105+
closing bool
106+
wg sync.WaitGroup
107+
writerWg sync.WaitGroup // tracks writer goroutines
108+
devMode bool
99109

100110
// enforceOrigin and originHosts implement WS_ALLOWED_ORIGINS. Enforcement
101111
// is on whenever authentication is enabled or APP_ENV=production; the
@@ -150,12 +160,19 @@ func NewHub(onConnectionChange func(count int)) *Hub {
150160
h.logBuffer = h.logPool.Get().([]LogEntry)
151161
h.metricBuffer = h.metricPool.Get().([]MetricEntry)
152162

163+
// Balanced by Run (loop exit) or by Stop (hub never run). See runOwner.
164+
h.wg.Add(1)
165+
153166
return h
154167
}
155168

156169
// Run starts the hub's main event loop. Should be called in a goroutine.
157170
func (h *Hub) Run() {
158-
h.wg.Add(1)
171+
if !h.runOwner.CompareAndSwap(false, true) {
172+
// Stop already released the construction-time count, or Run was
173+
// called twice. Either way there is no loop to start.
174+
return
175+
}
159176
defer h.wg.Done()
160177

161178
flushTicker := time.NewTicker(h.flushInterval)
@@ -388,10 +405,31 @@ func (h *Hub) BroadcastMetric(entry MetricEntry) {
388405
}
389406
}
390407

408+
// admitWriter reserves a writer slot on writerWg, refusing once Stop has
409+
// begun. Every writerWg.Add goes through here so each one is ordered before
410+
// Stop's Wait by lifecycleMu.
411+
func (h *Hub) admitWriter() bool {
412+
h.lifecycleMu.Lock()
413+
defer h.lifecycleMu.Unlock()
414+
if h.closing {
415+
return false
416+
}
417+
h.writerWg.Add(1)
418+
return true
419+
}
420+
391421
// Stop gracefully shuts down the hub.
392422
func (h *Hub) Stop() {
393-
h.stopped.Store(true)
423+
h.lifecycleMu.Lock()
424+
h.closing = true
425+
h.lifecycleMu.Unlock()
426+
394427
close(h.stopCh)
428+
if h.runOwner.CompareAndSwap(false, true) {
429+
// Run was never called, so nothing will ever call Done for the
430+
// construction-time count. Release it here or Wait blocks forever.
431+
h.wg.Done()
432+
}
395433
h.wg.Wait()
396434
h.writerWg.Wait()
397435
slog.Info("🛑 WebSocket hub stopped")
@@ -448,22 +486,47 @@ func (h *Hub) HandleWebSocket(w http.ResponseWriter, r *http.Request) {
448486
tenant: connTenantScope(r),
449487
}
450488

451-
h.register <- c
489+
// Reserve the writer slot before the registration handshake so the
490+
// count is taken while the hub is provably still accepting.
491+
if !h.admitWriter() {
492+
releaseSlot()
493+
_ = conn.Close(websocket.StatusGoingAway, "server shutting down")
494+
return
495+
}
496+
497+
// Registration races Stop(): once the run loop returns on stopCh nothing
498+
// drains h.register, so an unconditional send blocks this goroutine
499+
// forever and Stop()'s wg.Wait() never returns. Refuse the connection
500+
// instead of joining a hub that is going away.
501+
select {
502+
case h.register <- c:
503+
case <-h.stopCh:
504+
h.writerWg.Done()
505+
releaseSlot()
506+
_ = conn.Close(websocket.StatusGoingAway, "server shutting down")
507+
return
508+
}
452509

453510
// Writer goroutine
454-
h.writerWg.Add(1)
455511
go func() { // #nosec G118 -- long-lived WS writer goroutine outlives HTTP request intentionally
456512
defer h.writerWg.Done()
457513
// Release the admission slot when the writer exits — the writer
458514
// outlives the HandleWebSocket reader loop, so this is the last
459515
// goroutine alive for this client.
460516
defer releaseSlot()
461517
defer func() {
462-
if !h.stopped.Load() {
463-
h.unregister <- c
464-
} else if c.closed.CompareAndSwap(false, true) {
465-
// Hub already stopped; clean up directly.
466-
close(c.send)
518+
// An unconditional send here is what wedged Stop(): the run loop
519+
// returns on stopCh and then nothing drains h.unregister, so this
520+
// goroutine blocks forever and writerWg.Wait() never returns.
521+
// Select on stopCh so the send is abandoned the moment the drain
522+
// side is gone; the CAS guard makes the direct close safe against
523+
// the run loop's own stop-path close.
524+
select {
525+
case h.unregister <- c:
526+
case <-h.stopCh:
527+
if c.closed.CompareAndSwap(false, true) {
528+
close(c.send)
529+
}
467530
}
468531
_ = conn.Close(websocket.StatusNormalClosure, "closing")
469532
}()
Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
//go:build !race_off
2+
3+
package realtime
4+
5+
import (
6+
"context"
7+
"net/http"
8+
"net/http/httptest"
9+
"sync"
10+
"testing"
11+
"time"
12+
13+
"github.com/coder/websocket"
14+
)
15+
16+
// TestStopDoesNotDeadlockAgainstChurn is the regression for the hub shutdown
17+
// deadlock (#212). Registration and unregistration both send on channels that
18+
// only the run loop drains; the run loop returns as soon as stopCh closes. A
19+
// client whose writer goroutine reaches its unregister send after that return
20+
// blocks forever, so Stop()'s writerWg.Wait() never comes back and the whole
21+
// graceful shutdown wedges.
22+
//
23+
// The test drives connect/disconnect churn while Stop() runs, so the send and
24+
// the run loop's exit interleave, and it fails by timeout rather than hanging
25+
// the suite for the full go test deadline.
26+
func TestStopDoesNotDeadlockAgainstChurn(t *testing.T) {
27+
for attempt := 0; attempt < 12; attempt++ {
28+
hub := NewHub(nil)
29+
go hub.Run()
30+
31+
srv := httptest.NewServer(http.HandlerFunc(hub.HandleWebSocket))
32+
wsURL := "ws" + srv.URL[len("http"):]
33+
34+
var wg sync.WaitGroup
35+
for i := 0; i < 8; i++ {
36+
wg.Add(1)
37+
go func() {
38+
defer wg.Done()
39+
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
40+
defer cancel()
41+
c, _, err := websocket.Dial(ctx, wsURL, nil)
42+
if err != nil {
43+
return // refused mid-shutdown is a valid outcome
44+
}
45+
// Close immediately: the writer goroutine's unregister send
46+
// then races the run loop's exit.
47+
_ = c.Close(websocket.StatusNormalClosure, "bye")
48+
}()
49+
}
50+
51+
// Stop concurrently with the churn — the window under test.
52+
time.Sleep(time.Duration(attempt) * time.Millisecond)
53+
done := make(chan struct{})
54+
go func() {
55+
hub.Stop()
56+
close(done)
57+
}()
58+
59+
select {
60+
case <-done:
61+
case <-time.After(20 * time.Second):
62+
srv.Close()
63+
t.Fatalf("attempt %d: Hub.Stop() deadlocked — a writer or handler is blocked "+
64+
"sending on register/unregister with the run loop already returned", attempt)
65+
}
66+
67+
wg.Wait()
68+
srv.Close()
69+
}
70+
}

0 commit comments

Comments
 (0)