on main help sign in
fix(app): replace single events channel with pubsub.Broker for fan-out (#2663)

Fixes the SSE multi-consumer bug where multiple clients sharing the same workspace received only a fraction of events (competing-consumer semantics on a plain channel). Each caller to Events()/SubscribeEvents() now gets its own dedicated channel via Broker.Subscribe(ctx). ๐Ÿ’˜ Generated with Crush Assisted-by: AWS Claude Sonnet 4.6 via Crush <crush@charm.land>

commit4d16e96bd30016f674f162fc47128d3c108353eb authorTai Groot <tai@taigrr.com> dateTue 28 Apr 2026, 17:03 (6 months ago) parentc84ca2d
4 files changed+157โˆ’152
internal/app/app.go+16โˆ’39 internal/app/app_test.go+133โˆ’107 internal/backend/events.go+5โˆ’3 internal/server/proto.go+3โˆ’3
internal/app/app.go+16โˆ’39
@@ -66,7 +66,7 @@ type App struct {
6666
6767 serviceEventsWG *sync.WaitGroup
6868 eventsCtx context.Context
69โˆ’ events chan tea.Msg
69+ events *pubsub.Broker[tea.Msg]
7070 tuiWG *sync.WaitGroup
7171
7272 // global context and cleanup functions
@@ -100,7 +100,7 @@ func New(ctx context.Context, conn *sql.DB, store *config.ConfigStore) (*App, er
100100
101101 config: store,
102102
103โˆ’ events: make(chan tea.Msg, 100),
103+ events: pubsub.NewBroker[tea.Msg](),
104104 serviceEventsWG: &sync.WaitGroup{},
105105 tuiWG: &sync.WaitGroup{},
106106 agentNotifications: pubsub.NewBroker[notify.Notification](),
@@ -153,18 +153,15 @@ func (app *App) Store() *config.ConfigStore {
153153 return app.config
154154}
155155
156โˆ’// Events returns the events channel for the application.
157โˆ’func (app *App) Events() <-chan tea.Msg {
158โˆ’ return app.events
156+// Events returns a per-caller subscription channel for application events.
157+// Each caller receives its own channel; all callers receive every event.
158+func (app *App) Events(ctx context.Context) <-chan pubsub.Event[tea.Msg] {
159+ return app.events.Subscribe(ctx)
159160}
160161
161โˆ’// SendEvent pushes a message into the application's events channel.
162โˆ’// It is non-blocking; the message is dropped if the channel is full.
162+// SendEvent publishes a message to all event subscribers.
163163func (app *App) SendEvent(msg tea.Msg) {
164โˆ’ select {
165โˆ’ case app.events <- msg:
166โˆ’ default:
167โˆ’ }
164+ app.events.Publish(pubsub.UpdatedEvent, msg)
168165}
169166
170167// AgentNotifications returns the broker for agent notification events.
@@ -486,26 +483,21 @@ func (app *App) setupEvents() {
486483 cleanupFunc := func(context.Context) error {
487484 cancel()
488485 app.serviceEventsWG.Wait()
486+ app.events.Shutdown()
489487 return nil
490488 }
491489 app.cleanupFuncs = append(app.cleanupFuncs, cleanupFunc)
492490}
493491
494โˆ’const subscriberSendTimeout = 2 * time.Second
495โˆ’
496492func setupSubscriber[T any](
497493 ctx context.Context,
498494 wg *sync.WaitGroup,
499495 name string,
500496 subscriber func(context.Context) <-chan pubsub.Event[T],
501โˆ’ outputCh chan<- tea.Msg,
497+ broker *pubsub.Broker[tea.Msg],
502498) {
503499 wg.Go(func() {
504500 subCh := subscriber(ctx)
505โˆ’ sendTimer := time.NewTimer(0)
506โˆ’ <-sendTimer.C
507โˆ’ defer sendTimer.Stop()
508โˆ’
509501 for {
510502 select {
511503 case event, ok := <-subCh:
@@ -513,23 +505,7 @@ func setupSubscriber[T any](
513505 slog.Debug("Subscription channel closed", "name", name)
514506 return
515507 }
516โˆ’ var msg tea.Msg = event
517โˆ’ if !sendTimer.Stop() {
518โˆ’ select {
519โˆ’ case <-sendTimer.C:
520โˆ’ default:
521โˆ’ }
522โˆ’ }
523โˆ’ sendTimer.Reset(subscriberSendTimeout)
524โˆ’
525โˆ’ select {
526โˆ’ case outputCh <- msg:
527โˆ’ case <-sendTimer.C:
528โˆ’ slog.Debug("Message dropped due to slow consumer", "name", name)
529โˆ’ case <-ctx.Done():
530โˆ’ slog.Debug("Subscription cancelled", "name", name)
531โˆ’ return
532โˆ’ }
508+ broker.Publish(pubsub.UpdatedEvent, tea.Msg(event))
533509 case <-ctx.Done():
534510 slog.Debug("Subscription cancelled", "name", name)
535511 return
@@ -579,17 +555,18 @@ func (app *App) Subscribe(program *tea.Program) {
579555 })
580556 defer app.tuiWG.Done()
581557
558+ events := app.events.Subscribe(tuiCtx)
582559 for {
583560 select {
584561 case <-tuiCtx.Done():
585562 slog.Debug("TUI message handler shutting down")
586563 return
587โˆ’ case msg, ok := <-app.events:
564+ case ev, ok := <-events:
588565 if !ok {
589566 slog.Debug("TUI message channel closed")
590567 return
591568 }
592โˆ’ program.Send(msg)
569+ program.Send(ev.Payload)
593570 }
594571 }
595572}
@@ -649,9 +626,9 @@ func (app *App) checkForUpdates(ctx context.Context) {
649626 if err != nil || !info.Available() {
650627 return
651628 }
652โˆ’ app.events <- UpdateAvailableMsg{
629+ app.events.Publish(pubsub.UpdatedEvent, UpdateAvailableMsg{
653630 CurrentVersion: info.Current,
654631 LatestVersion: info.Latest,
655632 IsDevelopment: info.IsDevelopment(),
656โˆ’ }
633+ })
657634}
internal/app/app_test.go+133โˆ’107
@@ -2,156 +2,182 @@ package app
22
33import (
44 "context"
5+ "fmt"
56 "sync"
67 "testing"
7โˆ’ "testing/synctest"
88 "time"
99
1010 tea "charm.land/bubbletea/v2"
1111 "github.com/charmbracelet/crush/internal/pubsub"
1212 "github.com/stretchr/testify/require"
13โˆ’ "go.uber.org/goleak"
1413)
1514
15+// TestSetupSubscriber_NormalFlow verifies that events published to the source
16+// broker are forwarded to the output broker.
1617func TestSetupSubscriber_NormalFlow(t *testing.T) {
17โˆ’ synctest.Test(t, func(t *testing.T) {
18โˆ’ f := newSubscriberFixture(t, 10)
18+ t.Parallel()
1919
20โˆ’ time.Sleep(10 * time.Millisecond)
21โˆ’ synctest.Wait()
20+ ctx, cancel := context.WithCancel(t.Context())
21+ defer cancel()
2222
23โˆ’ f.broker.Publish(pubsub.CreatedEvent, "event1")
24โˆ’ f.broker.Publish(pubsub.CreatedEvent, "event2")
23+ src := pubsub.NewBroker[string]()
24+ defer src.Shutdown()
25+ out := pubsub.NewBroker[tea.Msg]()
26+ defer out.Shutdown()
2527
26โˆ’ for range 2 {
27โˆ’ select {
28โˆ’ case <-f.outputCh:
29โˆ’ case <-time.After(5 * time.Second):
30โˆ’ t.Fatal("Timed out waiting for messages")
31โˆ’ }
28+ ch := out.Subscribe(ctx)
29+
30+ var wg sync.WaitGroup
31+ setupSubscriber(ctx, &wg, "test", src.Subscribe, out)
32+
33+ // Yield so the subscriber goroutine can call src.Subscribe before we publish.
34+ time.Sleep(10 * time.Millisecond)
35+
36+ src.Publish(pubsub.CreatedEvent, "hello")
37+ src.Publish(pubsub.CreatedEvent, "world")
38+
39+ for range 2 {
40+ select {
41+ case <-ch:
42+ case <-time.After(5 * time.Second):
43+ t.Fatal("timed out waiting for forwarded event")
3244 }
45+ }
3346
34โˆ’ f.cancel()
35โˆ’ f.wg.Wait()
36โˆ’ })
47+ cancel()
48+ wg.Wait()
3749}
3850
39โˆ’func TestSetupSubscriber_SlowConsumer(t *testing.T) {
40โˆ’ synctest.Test(t, func(t *testing.T) {
41โˆ’ f := newSubscriberFixture(t, 0)
51+// TestSetupSubscriber_ContextCancellation verifies the goroutine exits cleanly
52+// when the context is cancelled.
53+func TestSetupSubscriber_ContextCancellation(t *testing.T) {
54+ t.Parallel()
4255
43โˆ’ const numEvents = 5
56+ ctx, cancel := context.WithCancel(t.Context())
4457
45โˆ’ var pubWg sync.WaitGroup
46โˆ’ pubWg.Go(func() {
47โˆ’ for range numEvents {
48โˆ’ f.broker.Publish(pubsub.CreatedEvent, "event")
49โˆ’ time.Sleep(10 * time.Millisecond)
50โˆ’ synctest.Wait()
51โˆ’ }
52โˆ’ })
58+ src := pubsub.NewBroker[string]()
59+ defer src.Shutdown()
60+ out := pubsub.NewBroker[tea.Msg]()
61+ defer out.Shutdown()
5362
54โˆ’ time.Sleep(time.Duration(numEvents) * (subscriberSendTimeout + 20*time.Millisecond))
55โˆ’ synctest.Wait()
56โˆ’
57โˆ’ received := 0
58โˆ’ for {
59โˆ’ select {
60โˆ’ case <-f.outputCh:
61โˆ’ received++
62โˆ’ default:
63โˆ’ pubWg.Wait()
64โˆ’ f.cancel()
65โˆ’ f.wg.Wait()
66โˆ’ require.Less(t, received, numEvents, "Slow consumer should have dropped some messages")
67โˆ’ return
68โˆ’ }
69โˆ’ }
70โˆ’ })
63+ var wg sync.WaitGroup
64+ setupSubscriber(ctx, &wg, "test", src.Subscribe, out)
65+
66+ src.Publish(pubsub.CreatedEvent, "event")
67+ cancel()
68+
69+ done := make(chan struct{})
70+ go func() { wg.Wait(); close(done) }()
71+
72+ select {
73+ case <-done:
74+ case <-time.After(5 * time.Second):
75+ t.Fatal("setupSubscriber goroutine did not exit after context cancellation")
76+ }
7177}
7278
73โˆ’func TestSetupSubscriber_ContextCancellation(t *testing.T) {
74โˆ’ synctest.Test(t, func(t *testing.T) {
75โˆ’ f := newSubscriberFixture(t, 10)
79+// TestEvents_ZeroConsumers verifies that publishing with no subscribers does
80+// not block or panic.
81+func TestEvents_ZeroConsumers(t *testing.T) {
82+ t.Parallel()
83+
84+ broker := pubsub.NewBroker[tea.Msg]()
85+ defer broker.Shutdown()
86+
87+ require.Equal(t, 0, broker.GetSubscriberCount())
7688
77โˆ’ f.broker.Publish(pubsub.CreatedEvent, "event1")
78โˆ’ time.Sleep(100 * time.Millisecond)
79โˆ’ synctest.Wait()
89+ // Must not block.
90+ done := make(chan struct{})
91+ go func() {
92+ broker.Publish(pubsub.UpdatedEvent, tea.Msg("msg1"))
93+ broker.Publish(pubsub.UpdatedEvent, tea.Msg("msg2"))
94+ close(done)
95+ }()
8096
81โˆ’ f.cancel()
82โˆ’ f.wg.Wait()
83โˆ’ })
97+ select {
98+ case <-done:
99+ case <-time.After(time.Second):
100+ t.Fatal("Publish with zero consumers blocked")
101+ }
84102}
85103
86โˆ’func TestSetupSubscriber_DrainAfterDrop(t *testing.T) {
87โˆ’ synctest.Test(t, func(t *testing.T) {
88โˆ’ f := newSubscriberFixture(t, 0)
104+// TestEvents_OneConsumer verifies that a single subscriber receives every event
105+// exactly once.
106+func TestEvents_OneConsumer(t *testing.T) {
107+ t.Parallel()
89108
90โˆ’ time.Sleep(10 * time.Millisecond)
91โˆ’ synctest.Wait()
109+ ctx, cancel := context.WithCancel(t.Context())
110+ defer cancel()
92111
93โˆ’ // First event: nobody reads outputCh so the timer fires (message dropped).
94โˆ’ f.broker.Publish(pubsub.CreatedEvent, "event1")
95โˆ’ time.Sleep(subscriberSendTimeout + 25*time.Millisecond)
96โˆ’ synctest.Wait()
112+ broker := pubsub.NewBroker[tea.Msg]()
113+ defer broker.Shutdown()
97114
98โˆ’ // Second event: triggers Stop()==false path; without the fix this deadlocks.
99โˆ’ f.broker.Publish(pubsub.CreatedEvent, "event2")
115+ ch := broker.Subscribe(ctx)
100116
101โˆ’ // If the timer drain deadlocks, wg.Wait never returns.
102โˆ’ done := make(chan struct{})
103โˆ’ go func() {
104โˆ’ f.cancel()
105โˆ’ f.wg.Wait()
106โˆ’ close(done)
107โˆ’ }()
117+ const n = 10
118+ for i := range n {
119+ broker.Publish(pubsub.UpdatedEvent, tea.Msg(i))
120+ }
108121
122+ for i := range n {
109123 select {
110โˆ’ case <-done:
124+ case ev := <-ch:
125+ require.Equal(t, tea.Msg(i), ev.Payload)
111126 case <-time.After(5 * time.Second):
112โˆ’ t.Fatal("setupSubscriber goroutine hung โ€” likely timer drain deadlock")
127+ t.Fatalf("timed out waiting for event %d", i)
113128 }
114โˆ’ })
129+ }
115130}
116131
117โˆ’func TestSetupSubscriber_NoTimerLeak(t *testing.T) {
118โˆ’ defer goleak.VerifyNone(t)
119โˆ’ synctest.Test(t, func(t *testing.T) {
120โˆ’ f := newSubscriberFixture(t, 100)
132+// TestEvents_NConsumers verifies that every subscriber receives every event
133+// exactly once, regardless of how many concurrent consumers are attached.
134+func TestEvents_NConsumers(t *testing.T) {
135+ t.Parallel()
121136
122โˆ’ for range 100 {
123โˆ’ f.broker.Publish(pubsub.CreatedEvent, "event")
124โˆ’ time.Sleep(5 * time.Millisecond)
125โˆ’ synctest.Wait()
126โˆ’ }
127โˆ’
128โˆ’ f.cancel()
129โˆ’ f.wg.Wait()
130โˆ’ })
131โˆ’}
132โˆ’
133โˆ’type subscriberFixture struct {
134โˆ’ broker *pubsub.Broker[string]
135โˆ’ wg sync.WaitGroup
136โˆ’ outputCh chan tea.Msg
137โˆ’ cancel context.CancelFunc
137+ for _, n := range []int{2, 5, 10} {
138+ t.Run(fmt.Sprintf("consumers=%d", n), func(t *testing.T) {
139+ t.Parallel()
140+ testNConsumers(t, n)
141+ })
142+ }
138143}
139144
140โˆ’func newSubscriberFixture(t *testing.T, bufSize int) *subscriberFixture {
145+func testNConsumers(t *testing.T, n int) {
141146 t.Helper()
147+
142148 ctx, cancel := context.WithCancel(t.Context())
143โˆ’ t.Cleanup(cancel)
149+ defer cancel()
150+
151+ broker := pubsub.NewBroker[tea.Msg]()
152+ defer broker.Shutdown()
144153
145โˆ’ f := &subscriberFixture{
146โˆ’ broker: pubsub.NewBroker[string](),
147โˆ’ outputCh: make(chan tea.Msg, bufSize),
148โˆ’ cancel: cancel,
154+ // Subscribe all N consumers before publishing.
155+ channels := make([]<-chan pubsub.Event[tea.Msg], n)
156+ for i := range n {
157+ channels[i] = broker.Subscribe(ctx)
149158 }
150โˆ’ t.Cleanup(f.broker.Shutdown)
159+ require.Equal(t, n, broker.GetSubscriberCount())
151160
152โˆ’ setupSubscriber(ctx, &f.wg, "test", func(ctx context.Context) <-chan pubsub.Event[string] {
153โˆ’ return f.broker.Subscribe(ctx)
154โˆ’ }, f.outputCh)
161+ const numEvents = 20
162+ for i := range numEvents {
163+ broker.Publish(pubsub.UpdatedEvent, tea.Msg(i))
164+ }
155165
156โˆ’ return f
166+ // Each consumer must receive all numEvents messages.
167+ var wg sync.WaitGroup
168+ for i, ch := range channels {
169+ wg.Go(func() {
170+ for j := range numEvents {
171+ select {
172+ case ev := <-ch:
173+ require.Equal(t, tea.Msg(j), ev.Payload,
174+ "consumer %d: wrong payload for event %d", i, j)
175+ case <-time.After(5 * time.Second):
176+ t.Errorf("consumer %d: timed out waiting for event %d", i, j)
177+ return
178+ }
179+ }
180+ })
181+ }
182+ wg.Wait()
157183}
internal/backend/events.go+5โˆ’3
@@ -8,16 +8,18 @@ import (
88 mcptools "github.com/charmbracelet/crush/internal/agent/tools/mcp"
99 "github.com/charmbracelet/crush/internal/app"
1010 "github.com/charmbracelet/crush/internal/config"
11+ "github.com/charmbracelet/crush/internal/pubsub"
1112)
1213
13โˆ’// SubscribeEvents returns the event channel for a workspace's app.
14โˆ’func (b *Backend) SubscribeEvents(workspaceID string) (<-chan tea.Msg, error) {
14+// SubscribeEvents returns a per-caller event channel for a workspace.
15+// Each caller receives all events; multiple callers do not compete.
16+func (b *Backend) SubscribeEvents(ctx context.Context, workspaceID string) (<-chan pubsub.Event[tea.Msg], error) {
1517 ws, err := b.GetWorkspace(workspaceID)
1618 if err != nil {
1719 return nil, err
1820 }
1921
20โˆ’ return ws.Events(), nil
22+ return ws.Events(ctx), nil
2123}
2224
2325// GetLSPStates returns the state of all LSP clients.
internal/server/proto.go+3โˆ’3
@@ -199,7 +199,7 @@ func (c *controllerV1) handleGetWorkspaceProviders(w http.ResponseWriter, r *htt
199199func (c *controllerV1) handleGetWorkspaceEvents(w http.ResponseWriter, r *http.Request) {
200200 flusher := http.NewResponseController(w)
201201 id := r.PathValue("id")
202โˆ’ events, err := c.backend.SubscribeEvents(id)
202+ events, err := c.backend.SubscribeEvents(r.Context(), id)
203203 if err != nil {
204204 c.handleError(w, r, err)
205205 return
@@ -218,8 +218,8 @@ func (c *controllerV1) handleGetWorkspaceEvents(w http.ResponseWriter, r *http.R
218218 if !ok {
219219 return
220220 }
221โˆ’ c.server.logDebug(r, "Sending event", "event", fmt.Sprintf("%T %+v", ev, ev))
222โˆ’ wrapped := wrapEvent(ev)
221+ c.server.logDebug(r, "Sending event", "event", fmt.Sprintf("%T %+v", ev.Payload, ev.Payload))
222+ wrapped := wrapEvent(ev.Payload)
223223 if wrapped == nil {
224224 continue
225225 }