Skip to content

fix(realtime): repair hub shutdown deadlock and WaitGroup misuse - #221

Merged
aksOps merged 1 commit into
mainfrom
fix/hub-shutdown-deadlock
Aug 23, 2026
Merged

fix(realtime): repair hub shutdown deadlock and WaitGroup misuse#221
aksOps merged 1 commit into
mainfrom
fix/hub-shutdown-deadlock

Conversation

@aksOps

@aksOps aksOps commented Aug 23, 2026

Copy link
Copy Markdown
Contributor

Closes #212.

internal/realtime has timed out at 3m under -race on seven CI runs since 2026-08-21 — including three simultaneous hits in today's dependabot batch (#132, #141, #145), all on PRs that touch no Go code. It is currently blocking dependency merges.

This is not a test defect. Three real bugs in the Hub start/stop lifecycle:

1. Shutdown deadlock (the 3m hang). The writer goroutine's cleanup sent on h.unregister unconditionally, guarded only by a racy h.stopped pre-check. Stop() can flip that flag and the run loop can return between the load and the send — nothing drains h.unregister after that, so the writer blocks forever and writerWg.Wait() never returns. In production this hangs graceful shutdown at step 2 of the documented shutdown order until the container is killed. Fixed by selecting on stopCh and closing the client directly; the existing CAS guard keeps that safe against the run loop's own stop-path close. HandleWebSocket's h.register send had the identical shape and gets the same treatment.

2. wg Add-from-zero concurrent with Wait. Run() called h.wg.Add(1) from inside the goroutine while Stop() called h.wg.Wait(). Stop() could return before the loop had even started, leaving it running afterwards. The count is now taken in NewHub and consumed by whichever of Run/Stop claims runOwner first — Add always happens-before Wait, and a hub that is never Run (several tests do this) still stops cleanly.

3. writerWg same misuse. HandleWebSocket called h.writerWg.Add(1) with no ordering against Stop()'s writerWg.Wait(). Writer slots are now reserved via admitWriter() under lifecycleMu, which refuses once Stop has begun.

The dead h.stopped flag is removed along with its last reader.

Verification

  • TestStopDoesNotDeadlockAgainstChurn drives connect/disconnect churn against a concurrent Stop(). Fails on the unpatched hub (verified by checking out main's hub.go under the new test: WARNING: DATA RACE), passes on the fix.
  • GOMAXPROCS=2 go test -race -count=20 ./internal/realtime/ — 340 tests green, amplifying the starved-runner condition CI hits.
  • go test ./... — 1527 tests, 30 packages, green.

Honest scope note: the new test reproduces the WaitGroup race deterministically; the 3-minute CI hang is the rarer deadlock manifestation of defect 1, which is fixed by construction rather than by a test that reliably reproduces a timing window.

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).
@sonarqubecloud

Copy link
Copy Markdown

@aksOps
aksOps merged commit d01ce42 into main Aug 23, 2026
17 checks passed
@aksOps
aksOps deleted the fix/hub-shutdown-deadlock branch August 23, 2026 17:49
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Flaky: TestHub_MaxClientsZeroIsUnlimited hangs 3m under -race on CI runners

1 participant