Enhance subscription management by adding cancel functions and improving channel handling

- Update the Subscribe and SubscribeFrom methods to return a cancel function, ensuring proper resource cleanup when subscriptions are no longer needed.
- Modify unsubscribe logic to close channels safely, preventing potential panics from sending on closed channels.
- Enhance test cases to utilize the new cancel functionality, ensuring robust handling of subscriptions in various scenarios.
This commit is contained in:
Max 2026-02-24 00:22:08 +08:00
parent 7fa54c50f3
commit 1e4e1224e2
11 changed files with 252 additions and 40 deletions

View file

@ -54,14 +54,25 @@ func (sm *subManager) subscribe(pattern string, ch chan<- *types.Event, opts ...
return id return id
} }
// unsubscribe removes a subscriber by ID. // unsubscribe removes a subscriber by ID and closes its channel
// so that any goroutine blocked on `range ch` will unblock and exit.
func (sm *subManager) unsubscribe(id string) { func (sm *subManager) unsubscribe(id string) {
sm.mu.Lock() sm.mu.Lock()
defer sm.mu.Unlock() entry, ok := sm.entries[id]
delete(sm.entries, id) delete(sm.entries, id)
sm.mu.Unlock()
if ok && entry.ch != nil {
func() {
defer func() { recover() }()
close(entry.ch)
}()
}
} }
// notify sends an event to all matching subscribers (non-blocking). // notify sends an event to all matching subscribers (non-blocking).
// Recovers from send-on-closed-channel panics that may occur if
// unsubscribe closes a channel concurrently.
func (sm *subManager) notify(ev *types.Event) { func (sm *subManager) notify(ev *types.Event) {
sm.mu.RLock() sm.mu.RLock()
defer sm.mu.RUnlock() defer sm.mu.RUnlock()
@ -73,19 +84,31 @@ func (sm *subManager) notify(ev *types.Event) {
if entry.filter != nil && !entry.filter(ev) { if entry.filter != nil && !entry.filter(ev) {
continue continue
} }
select { func() {
case entry.ch <- ev: defer func() { recover() }()
default: select {
// Subscriber chan full, skip (non-blocking) case entry.ch <- ev:
} default:
}
}()
} }
} }
// clear removes all subscribers. Used during Stop. // clear removes all subscribers and closes their channels. Used during Stop.
func (sm *subManager) clear() { func (sm *subManager) clear() {
sm.mu.Lock() sm.mu.Lock()
defer sm.mu.Unlock() old := sm.entries
sm.entries = make(map[string]*subEntry) sm.entries = make(map[string]*subEntry)
sm.mu.Unlock()
for _, entry := range old {
if entry.ch != nil {
func() {
defer func() { recover() }()
close(entry.ch)
}()
}
}
} }
// Subscribe dynamically subscribes to events matching the given pattern. // Subscribe dynamically subscribes to events matching the given pattern.

View file

@ -164,6 +164,7 @@ func TestSubscribe_StopClearsSubscribers(t *testing.T) {
} }
// drainChan reads up to n events from ch within timeout. // drainChan reads up to n events from ch within timeout.
// Stops early if the channel is closed.
func drainChan(ch chan *types.Event, n int, timeout time.Duration) []*types.Event { func drainChan(ch chan *types.Event, n int, timeout time.Duration) []*types.Event {
var result []*types.Event var result []*types.Event
timer := time.NewTimer(timeout) timer := time.NewTimer(timeout)
@ -171,7 +172,10 @@ func drainChan(ch chan *types.Event, n int, timeout time.Duration) []*types.Even
for range n { for range n {
select { select {
case ev := <-ch: case ev, ok := <-ch:
if !ok {
return result
}
result = append(result, ev) result = append(result, ev)
case <-timer.C: case <-timer.C:
return result return result

View file

@ -58,13 +58,14 @@ func handleStreamMode(c *gin.Context, manager types.Manager, info *types.TraceIn
c.Header("X-Accel-Buffering", "no") c.Header("X-Accel-Buffering", "no")
// Subscribe to trace updates // Subscribe to trace updates
updates, err := manager.Subscribe() updates, cancel, err := manager.Subscribe()
if err != nil { if err != nil {
// Send error as SSE event // Send error as SSE event
fmt.Fprintf(c.Writer, "event: error\ndata: {\"error\":\"Failed to subscribe: %s\"}\n\n", err.Error()) fmt.Fprintf(c.Writer, "event: error\ndata: {\"error\":\"Failed to subscribe: %s\"}\n\n", err.Error())
c.Writer.Flush() c.Writer.Flush()
return return
} }
defer cancel()
// Stream events // Stream events
ctx := c.Request.Context() ctx := c.Request.Context()
@ -73,22 +74,18 @@ func handleStreamMode(c *gin.Context, manager types.Manager, info *types.TraceIn
for { for {
select { select {
case <-clientGone: case <-clientGone:
// Client disconnected
return return
case update, ok := <-updates: case update, ok := <-updates:
if !ok { if !ok {
// Channel closed
return return
} }
// Format and send SSE event
err := sendSSEEvent(c.Writer, *update) err := sendSSEEvent(c.Writer, *update)
if err != nil { if err != nil {
return return
} }
// Check if trace is complete
if update.Type == types.UpdateTypeComplete { if update.Type == types.UpdateTypeComplete {
return return
} }

View file

@ -2,6 +2,7 @@ package trace
import ( import (
"fmt" "fmt"
"sync"
"github.com/yaoapp/yao/event" "github.com/yaoapp/yao/event"
eventTypes "github.com/yaoapp/yao/event/types" eventTypes "github.com/yaoapp/yao/event/types"
@ -12,13 +13,16 @@ func dedupKey(u *types.TraceUpdate) string {
return fmt.Sprintf("%s:%s:%d", u.Type, u.NodeID, u.Timestamp) return fmt.Sprintf("%s:%s:%d", u.Type, u.NodeID, u.Timestamp)
} }
// Subscribe creates a new subscription for trace updates (replays all historical events from the beginning) // Subscribe creates a new subscription for trace updates (replays all historical events from the beginning).
func (m *manager) Subscribe() (<-chan *types.TraceUpdate, error) { // Returns the update channel and a cancel function. The caller MUST call
// cancel when done (e.g., client disconnect) to release the goroutine.
func (m *manager) Subscribe() (<-chan *types.TraceUpdate, func(), error) {
return m.subscribe(0) return m.subscribe(0)
} }
// SubscribeFrom creates a subscription starting from a specific timestamp // SubscribeFrom creates a subscription starting from a specific timestamp.
func (m *manager) SubscribeFrom(since int64) (<-chan *types.TraceUpdate, error) { // Returns the update channel and a cancel function.
func (m *manager) SubscribeFrom(since int64) (<-chan *types.TraceUpdate, func(), error) {
return m.subscribe(since) return m.subscribe(since)
} }
@ -26,12 +30,14 @@ func (m *manager) SubscribeFrom(since int64) (<-chan *types.TraceUpdate, error)
// updates, then streams live events via the event service's Subscriber. // updates, then streams live events via the event service's Subscriber.
// The subscriber is registered BEFORE reading historical state to prevent // The subscriber is registered BEFORE reading historical state to prevent
// missing events that occur between the state snapshot and subscriber setup. // missing events that occur between the state snapshot and subscriber setup.
func (m *manager) subscribe(since int64) (<-chan *types.TraceUpdate, error) { //
// The returned cancel function triggers event.Unsubscribe which closes
// liveCh, causing the goroutine to exit via `for range liveCh`.
func (m *manager) subscribe(since int64) (<-chan *types.TraceUpdate, func(), error) {
bufferSize := 1000 bufferSize := 1000
out := make(chan *types.TraceUpdate, bufferSize) out := make(chan *types.TraceUpdate, bufferSize)
// Register live subscriber FIRST to avoid missing events between snapshot and subscribe.
liveCh := make(chan *eventTypes.Event, bufferSize) liveCh := make(chan *eventTypes.Event, bufferSize)
traceID := m.traceID traceID := m.traceID
subID := event.Subscribe("trace.*", liveCh, event.Filter(func(ev *eventTypes.Event) bool { subID := event.Subscribe("trace.*", liveCh, event.Filter(func(ev *eventTypes.Event) bool {
@ -42,19 +48,23 @@ func (m *manager) subscribe(since int64) (<-chan *types.TraceUpdate, error) {
return update.TraceID == traceID return update.TraceID == traceID
})) }))
// THEN snapshot historical updates (may overlap with live events).
historical := m.stateGetUpdates(since) historical := m.stateGetUpdates(since)
// Build a set of historical event identifiers for dedup.
// Key: "type:nodeID:timestamp" is unique enough for trace events.
histSeen := make(map[string]struct{}, len(historical)) histSeen := make(map[string]struct{}, len(historical))
for _, u := range historical { for _, u := range historical {
histSeen[dedupKey(u)] = struct{}{} histSeen[dedupKey(u)] = struct{}{}
} }
var cancelOnce sync.Once
cancel := func() {
cancelOnce.Do(func() {
event.Unsubscribe(subID)
})
}
go func() { go func() {
defer close(out) defer close(out)
defer event.Unsubscribe(subID) defer cancel()
for _, update := range historical { for _, update := range historical {
out <- update out <- update
@ -77,5 +87,5 @@ func (m *manager) subscribe(since int64) (<-chan *types.TraceUpdate, error) {
} }
}() }()
return out, nil return out, cancel, nil
} }

View file

@ -331,8 +331,9 @@ func TestAutoCompleteParentEvents(t *testing.T) {
defer trace.Remove(ctx, d.DriverType, traceID, d.DriverOptions...) defer trace.Remove(ctx, d.DriverType, traceID, d.DriverOptions...)
// Subscribe to updates // Subscribe to updates
updates, err := manager.Subscribe() updates, cancel, err := manager.Subscribe()
assert.NoError(t, err) assert.NoError(t, err)
defer cancel()
// Collect updates in background // Collect updates in background
var receivedUpdates []*types.TraceUpdate var receivedUpdates []*types.TraceUpdate

View file

@ -266,7 +266,7 @@ func BenchmarkSubscription(b *testing.B) {
} }
// Subscribe // Subscribe
updates, err := manager.Subscribe() updates, cancel, err := manager.Subscribe()
if err != nil { if err != nil {
b.Fatalf("Failed to subscribe: %s", err.Error()) b.Fatalf("Failed to subscribe: %s", err.Error())
} }
@ -301,6 +301,7 @@ func BenchmarkSubscription(b *testing.B) {
} }
} }
cancel()
trace.Release(traceID) trace.Release(traceID)
trace.Remove(ctx, trace.Local, traceID) trace.Remove(ctx, trace.Local, traceID)
} }

View file

@ -179,6 +179,7 @@ func TestConcurrentSubscribers(t *testing.T) {
var wg sync.WaitGroup var wg sync.WaitGroup
numSubscribers := 5 numSubscribers := 5
subscribers := make([]<-chan *types.TraceUpdate, numSubscribers) subscribers := make([]<-chan *types.TraceUpdate, numSubscribers)
cancels := make([]func(), numSubscribers)
var mu sync.Mutex var mu sync.Mutex
for i := 0; i < numSubscribers; i++ { for i := 0; i < numSubscribers; i++ {
@ -186,14 +187,22 @@ func TestConcurrentSubscribers(t *testing.T) {
go func(idx int) { go func(idx int) {
defer wg.Done() defer wg.Done()
sub, err := manager.Subscribe() sub, cancelSub, err := manager.Subscribe()
assert.NoError(t, err) assert.NoError(t, err)
mu.Lock() mu.Lock()
subscribers[idx] = sub subscribers[idx] = sub
cancels[idx] = cancelSub
mu.Unlock() mu.Unlock()
}(i) }(i)
} }
defer func() {
for _, c := range cancels {
if c != nil {
c()
}
}
}()
wg.Wait() wg.Wait()
// Verify all subscriptions were created // Verify all subscriptions were created

View file

@ -259,10 +259,11 @@ func TestMemoryLeakComplexScenarios(t *testing.T) {
{ {
name: "WithSubscription", name: "WithSubscription",
execute: func(m types.Manager) error { execute: func(m types.Manager) error {
updates, err := m.Subscribe() updates, cancel, err := m.Subscribe()
if err != nil { if err != nil {
return err return err
} }
defer cancel()
// Drain updates in background with timeout // Drain updates in background with timeout
done := make(chan bool) done := make(chan bool)
@ -563,7 +564,7 @@ func TestGoroutineLeak(t *testing.T) {
} }
// Subscribe (creates goroutines) // Subscribe (creates goroutines)
updates, err := manager.Subscribe() updates, cancel, err := manager.Subscribe()
if err != nil { if err != nil {
t.Errorf("Subscribe failed at iteration %d: %s", i, err.Error()) t.Errorf("Subscribe failed at iteration %d: %s", i, err.Error())
} }
@ -598,6 +599,7 @@ func TestGoroutineLeak(t *testing.T) {
} }
} }
cancel()
trace.Release(traceID) trace.Release(traceID)
trace.Remove(ctx, trace.Local, traceID) trace.Remove(ctx, trace.Local, traceID)
} }

View file

@ -0,0 +1,157 @@
package trace_test
import (
"context"
"runtime"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/yaoapp/yao/event"
"github.com/yaoapp/yao/trace"
"github.com/yaoapp/yao/trace/types"
)
// stableGoroutines waits for runtime to settle and returns goroutine count.
func stableGoroutines() int {
for i := 0; i < 5; i++ {
runtime.GC()
runtime.Gosched()
time.Sleep(10 * time.Millisecond)
}
return runtime.NumGoroutine()
}
// TestLeak_SubscriptionClientDisconnect reproduces the goroutine leak that
// occurs when an SSE client subscribes to a trace and then disconnects
// without the trace ever completing (no UpdateTypeComplete sent).
//
// The subscription goroutine in subscription.go blocks on
// `for ev := range liveCh` and never exits because:
// 1. liveCh is never closed (event.Unsubscribe only deletes the map entry)
// 2. The goroutine only returns on UpdateTypeComplete
// 3. No context/cancellation mechanism exists
//
// This simulates the real-world scenario: SSE handler returns on client
// disconnect, but the subscription goroutine keeps running forever.
func TestLeak_SubscriptionClientDisconnect(t *testing.T) {
drivers := trace.GetTestDrivers()
for _, d := range drivers {
t.Run(d.Name, func(t *testing.T) {
ctx := context.Background()
before := stableGoroutines()
const numClients = 10
for i := 0; i < numClients; i++ {
traceID, manager, err := trace.New(ctx, d.DriverType, nil, d.DriverOptions...)
assert.NoError(t, err)
// Client subscribes (like SSE handler calling manager.Subscribe())
updates, cancel, err := manager.Subscribe()
assert.NoError(t, err)
assert.NotNil(t, updates)
// Simulate some trace activity
_, err = manager.Add("step", types.TraceNodeOption{Label: "Processing"})
assert.NoError(t, err)
// Read a couple of events (like the SSE handler would)
timeout := time.After(500 * time.Millisecond)
drain:
for {
select {
case _, ok := <-updates:
if !ok {
break drain
}
case <-timeout:
break drain
}
}
// Client disconnects: SSE handler calls cancel (deferred).
// This triggers event.Unsubscribe which closes liveCh,
// allowing the subscription goroutine to exit.
cancel()
trace.Release(traceID)
}
// Wait for goroutines to settle
time.Sleep(1 * time.Second)
after := stableGoroutines()
leaked := after - before
t.Logf("goroutines: before=%d after=%d leaked=%d (over %d simulated client disconnects)", before, after, leaked, numClients)
// Each Subscribe() spawns a goroutine that should eventually exit.
// If it doesn't, we'll see roughly numClients leaked goroutines.
if leaked >= numClients {
t.Errorf("goroutine leak detected: %d goroutines leaked after %d client disconnects. "+
"Subscription goroutines are not cleaned up when clients disconnect without trace completion.",
leaked, numClients)
}
})
}
}
// TestLeak_SubscriptionEventServiceStop reproduces the goroutine leak when
// event.Stop() is called (e.g., during shutdown) while subscriptions are active.
//
// event.Stop() calls smgr.clear() which deletes all subscriber entries but
// does NOT close their channels, leaving goroutines blocked on `range liveCh`.
func TestLeak_SubscriptionEventServiceStop(t *testing.T) {
drivers := trace.GetTestDrivers()
for _, d := range drivers {
t.Run(d.Name, func(t *testing.T) {
ctx := context.Background()
before := stableGoroutines()
const numSubs = 5
traceID, manager, err := trace.New(ctx, d.DriverType, nil, d.DriverOptions...)
assert.NoError(t, err)
// Create multiple subscriptions (simulating multiple SSE clients)
for i := 0; i < numSubs; i++ {
_, _, err := manager.Subscribe()
assert.NoError(t, err)
}
// Simulate some activity
_, err = manager.Add("work", types.TraceNodeOption{Label: "Working"})
assert.NoError(t, err)
time.Sleep(200 * time.Millisecond)
// Stop event service (like during server shutdown)
err = event.Stop(ctx)
assert.NoError(t, err)
// Restart event service for other tests
err = event.Start()
if err != nil && err != event.ErrAlreadyStart {
t.Fatalf("Failed to restart event service: %v", err)
}
trace.Release(traceID)
time.Sleep(1 * time.Second)
after := stableGoroutines()
leaked := after - before
t.Logf("goroutines: before=%d after=%d leaked=%d (over %d subscriptions + event.Stop)", before, after, leaked, numSubs)
if leaked >= numSubs {
t.Errorf("goroutine leak detected: %d goroutines leaked after event.Stop() with %d active subscriptions. "+
"smgr.clear() does not close subscriber channels.",
leaked, numSubs)
}
})
}
}

View file

@ -24,9 +24,10 @@ func TestSubscription(t *testing.T) {
defer trace.Remove(ctx, d.DriverType, traceID, d.DriverOptions...) defer trace.Remove(ctx, d.DriverType, traceID, d.DriverOptions...)
// Subscribe to updates // Subscribe to updates
updates, err := manager.Subscribe() updates, cancel, err := manager.Subscribe()
assert.NoError(t, err) assert.NoError(t, err)
assert.NotNil(t, updates) assert.NotNil(t, updates)
defer cancel()
// Collect updates in background // Collect updates in background
var receivedUpdates []*types.TraceUpdate var receivedUpdates []*types.TraceUpdate
@ -146,9 +147,10 @@ func TestSubscribeFrom(t *testing.T) {
// Real scenario: User refreshes page and resumes from last known timestamp // Real scenario: User refreshes page and resumes from last known timestamp
// This should replay events from resumeTimestamp onwards // This should replay events from resumeTimestamp onwards
updates, err := manager.SubscribeFrom(resumeTimestamp) updates, cancelSub, err := manager.SubscribeFrom(resumeTimestamp)
assert.NoError(t, err) assert.NoError(t, err)
assert.NotNil(t, updates) assert.NotNil(t, updates)
defer cancelSub()
// Collect updates // Collect updates
var receivedUpdates []*types.TraceUpdate var receivedUpdates []*types.TraceUpdate
@ -233,14 +235,17 @@ func TestMultipleSubscribers(t *testing.T) {
defer trace.Remove(ctx, d.DriverType, traceID, d.DriverOptions...) defer trace.Remove(ctx, d.DriverType, traceID, d.DriverOptions...)
// Create multiple subscribers // Create multiple subscribers
sub1, err := manager.Subscribe() sub1, cancel1, err := manager.Subscribe()
assert.NoError(t, err) assert.NoError(t, err)
defer cancel1()
sub2, err := manager.Subscribe() sub2, cancel2, err := manager.Subscribe()
assert.NoError(t, err) assert.NoError(t, err)
defer cancel2()
sub3, err := manager.Subscribe() sub3, cancel3, err := manager.Subscribe()
assert.NoError(t, err) assert.NoError(t, err)
defer cancel3()
// Collect updates from all subscribers // Collect updates from all subscribers
var wg sync.WaitGroup var wg sync.WaitGroup

View file

@ -48,10 +48,13 @@ type Manager interface {
MarkComplete() error MarkComplete() error
// Subscription Operations // Subscription Operations
// Subscribe subscribes to trace updates (replay history + real-time) // Subscribe subscribes to trace updates (replay history + real-time).
Subscribe() (<-chan *TraceUpdate, error) // Returns the update channel and a cancel function. The caller MUST call
// SubscribeFrom subscribes from a specific timestamp (for resume) // cancel when done (e.g., client disconnect) to release the goroutine.
SubscribeFrom(since int64) (<-chan *TraceUpdate, error) Subscribe() (<-chan *TraceUpdate, func(), error)
// SubscribeFrom subscribes from a specific timestamp (for resume).
// Returns the update channel and a cancel function.
SubscribeFrom(since int64) (<-chan *TraceUpdate, func(), error)
// IsComplete checks if the trace is completed // IsComplete checks if the trace is completed
IsComplete() bool IsComplete() bool