- 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.
157 lines
4.6 KiB
Go
157 lines
4.6 KiB
Go
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)
|
|
}
|
|
})
|
|
}
|
|
}
|