test: add goroutine leak and SSE behavior tests, remove refresh buttons
Add tests for subscriber cleanup on disconnect (goroutine leak prevention), Unsubscribe delivery stop, subscribe/unsubscribe cycle leak, Notify-driven subsequent events with mutating data, and diff dedup suppression. Remove now-redundant Refresh buttons from Plan and Session tabs since SSE provides real-time updates. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
2eaf6984a6
commit
9f6aa867bd
2 changed files with 205 additions and 24 deletions
|
|
@ -2,6 +2,7 @@ package miniapp
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"bufio"
|
"bufio"
|
||||||
|
"context"
|
||||||
"crypto/hmac"
|
"crypto/hmac"
|
||||||
"crypto/sha256"
|
"crypto/sha256"
|
||||||
"encoding/hex"
|
"encoding/hex"
|
||||||
|
|
@ -9,8 +10,10 @@ import (
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
"net/url"
|
"net/url"
|
||||||
|
"runtime"
|
||||||
"sort"
|
"sort"
|
||||||
"strings"
|
"strings"
|
||||||
|
"sync/atomic"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
|
@ -264,3 +267,205 @@ func TestSSE_InitialEvents(t *testing.T) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestStateNotifier_UnsubscribeStopsDelivery(t *testing.T) {
|
||||||
|
n := NewStateNotifier()
|
||||||
|
ch := n.Subscribe()
|
||||||
|
|
||||||
|
n.Unsubscribe(ch)
|
||||||
|
n.Notify()
|
||||||
|
|
||||||
|
select {
|
||||||
|
case <-ch:
|
||||||
|
t.Error("received notification after Unsubscribe")
|
||||||
|
case <-time.After(50 * time.Millisecond):
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestStateNotifier_SubscribeCycleNoLeak(t *testing.T) {
|
||||||
|
n := NewStateNotifier()
|
||||||
|
|
||||||
|
for i := 0; i < 100; i++ {
|
||||||
|
ch := n.Subscribe()
|
||||||
|
n.Unsubscribe(ch)
|
||||||
|
}
|
||||||
|
|
||||||
|
n.mu.Lock()
|
||||||
|
count := len(n.subs)
|
||||||
|
n.mu.Unlock()
|
||||||
|
|
||||||
|
if count != 0 {
|
||||||
|
t.Errorf("expected 0 subscribers after cycle, got %d", count)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSSE_ClientDisconnectCleansUp(t *testing.T) {
|
||||||
|
notifier := NewStateNotifier()
|
||||||
|
h := NewHandler(&mockDataProvider{}, &mockSender{}, testBotToken, notifier)
|
||||||
|
mux := http.NewServeMux()
|
||||||
|
h.RegisterRoutes(mux)
|
||||||
|
|
||||||
|
ts := httptest.NewServer(mux)
|
||||||
|
defer ts.Close()
|
||||||
|
|
||||||
|
// Baseline goroutine count
|
||||||
|
runtime.GC()
|
||||||
|
time.Sleep(50 * time.Millisecond)
|
||||||
|
baseGoroutines := runtime.NumGoroutine()
|
||||||
|
|
||||||
|
// Open and close several SSE connections
|
||||||
|
for i := 0; i < 5; i++ {
|
||||||
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
|
req, _ := http.NewRequestWithContext(ctx, "GET",
|
||||||
|
ts.URL+"/miniapp/api/events?initData="+url.QueryEscape(testInitData()), nil)
|
||||||
|
resp, err := http.DefaultClient.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("request %d failed: %v", i, err)
|
||||||
|
}
|
||||||
|
// Read at least one event line to confirm the handler is running
|
||||||
|
scanner := bufio.NewScanner(resp.Body)
|
||||||
|
scanner.Scan()
|
||||||
|
// Disconnect
|
||||||
|
cancel()
|
||||||
|
resp.Body.Close()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Wait for goroutines to wind down
|
||||||
|
time.Sleep(200 * time.Millisecond)
|
||||||
|
runtime.GC()
|
||||||
|
time.Sleep(50 * time.Millisecond)
|
||||||
|
|
||||||
|
finalGoroutines := runtime.NumGoroutine()
|
||||||
|
leaked := finalGoroutines - baseGoroutines
|
||||||
|
if leaked > 2 { // small tolerance for runtime jitter
|
||||||
|
t.Errorf("possible goroutine leak: baseline=%d, final=%d, leaked=%d",
|
||||||
|
baseGoroutines, finalGoroutines, leaked)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify all subscribers were cleaned up
|
||||||
|
notifier.mu.Lock()
|
||||||
|
subCount := len(notifier.subs)
|
||||||
|
notifier.mu.Unlock()
|
||||||
|
if subCount != 0 {
|
||||||
|
t.Errorf("expected 0 subscribers after disconnect, got %d", subCount)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSSE_NotifyDrivesSubsequentEvents(t *testing.T) {
|
||||||
|
notifier := NewStateNotifier()
|
||||||
|
provider := &mutatingDataProvider{}
|
||||||
|
h := NewHandler(provider, &mockSender{}, testBotToken, notifier)
|
||||||
|
mux := http.NewServeMux()
|
||||||
|
h.RegisterRoutes(mux)
|
||||||
|
|
||||||
|
ts := httptest.NewServer(mux)
|
||||||
|
defer ts.Close()
|
||||||
|
|
||||||
|
resp, err := http.Get(ts.URL + "/miniapp/api/events?initData=" + url.QueryEscape(testInitData()))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GET failed: %v", err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
scanner := bufio.NewScanner(resp.Body)
|
||||||
|
// Drain initial 3 events
|
||||||
|
drainEvents(t, scanner, 3, 2*time.Second)
|
||||||
|
|
||||||
|
// Mutate state and notify — diff dedup should detect the change and send a new event
|
||||||
|
provider.mutated.Store(true)
|
||||||
|
notifier.Notify()
|
||||||
|
|
||||||
|
events := drainEvents(t, scanner, 1, 2*time.Second)
|
||||||
|
if !events["plan"] {
|
||||||
|
t.Errorf("expected plan event after mutation, got: %v", events)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSSE_DiffDedupSuppressesDuplicate(t *testing.T) {
|
||||||
|
notifier := NewStateNotifier()
|
||||||
|
h := NewHandler(&mockDataProvider{}, &mockSender{}, testBotToken, notifier)
|
||||||
|
mux := http.NewServeMux()
|
||||||
|
h.RegisterRoutes(mux)
|
||||||
|
|
||||||
|
ts := httptest.NewServer(mux)
|
||||||
|
defer ts.Close()
|
||||||
|
|
||||||
|
resp, err := http.Get(ts.URL + "/miniapp/api/events?initData=" + url.QueryEscape(testInitData()))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GET failed: %v", err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
scanner := bufio.NewScanner(resp.Body)
|
||||||
|
// Drain initial events
|
||||||
|
drainEvents(t, scanner, 3, 2*time.Second)
|
||||||
|
|
||||||
|
// Notify with unchanged data — should produce zero new event lines
|
||||||
|
notifier.Notify()
|
||||||
|
|
||||||
|
// Give the handler time to process
|
||||||
|
time.Sleep(100 * time.Millisecond)
|
||||||
|
|
||||||
|
// Try to read — nothing should arrive since data hasn't changed
|
||||||
|
gotExtra := make(chan string, 1)
|
||||||
|
go func() {
|
||||||
|
if scanner.Scan() {
|
||||||
|
gotExtra <- scanner.Text()
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
select {
|
||||||
|
case line := <-gotExtra:
|
||||||
|
// Only fail if it's an actual event (not an empty keepalive)
|
||||||
|
if strings.HasPrefix(line, "event:") {
|
||||||
|
t.Errorf("received duplicate event despite unchanged data: %s", line)
|
||||||
|
}
|
||||||
|
case <-time.After(200 * time.Millisecond):
|
||||||
|
// Expected: no duplicate sent
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── helpers ──
|
||||||
|
|
||||||
|
// mutatingDataProvider returns different PlanInfo after mutate is set.
|
||||||
|
type mutatingDataProvider struct {
|
||||||
|
mutated atomic.Bool
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *mutatingDataProvider) ListSkills() []skills.SkillInfo {
|
||||||
|
return []skills.SkillInfo{{Name: "test-skill", Description: "A test", Source: "local"}}
|
||||||
|
}
|
||||||
|
func (m *mutatingDataProvider) GetPlanInfo() PlanInfo {
|
||||||
|
if m.mutated.Load() {
|
||||||
|
return PlanInfo{HasPlan: true, Status: "executing", CurrentPhase: 1, TotalPhases: 2}
|
||||||
|
}
|
||||||
|
return PlanInfo{HasPlan: false, Status: "none"}
|
||||||
|
}
|
||||||
|
func (m *mutatingDataProvider) GetSessionStats() *stats.Stats { return nil }
|
||||||
|
func (m *mutatingDataProvider) GetActiveSessions() []SessionInfo {
|
||||||
|
return []SessionInfo{}
|
||||||
|
}
|
||||||
|
|
||||||
|
// drainEvents reads SSE event lines until it collects `want` distinct event names or times out.
|
||||||
|
func drainEvents(t *testing.T, scanner *bufio.Scanner, want int, timeout time.Duration) map[string]bool {
|
||||||
|
t.Helper()
|
||||||
|
events := make(map[string]bool)
|
||||||
|
deadline := time.After(timeout)
|
||||||
|
for len(events) < want {
|
||||||
|
done := make(chan bool, 1)
|
||||||
|
go func() { done <- scanner.Scan() }()
|
||||||
|
select {
|
||||||
|
case ok := <-done:
|
||||||
|
if !ok {
|
||||||
|
return events
|
||||||
|
}
|
||||||
|
case <-deadline:
|
||||||
|
return events
|
||||||
|
}
|
||||||
|
line := scanner.Text()
|
||||||
|
if strings.HasPrefix(line, "event: ") {
|
||||||
|
events[strings.TrimPrefix(line, "event: ")] = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return events
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -403,28 +403,6 @@
|
||||||
border-color: var(--btn);
|
border-color: var(--btn);
|
||||||
}
|
}
|
||||||
|
|
||||||
.refresh-btn {
|
|
||||||
display: block;
|
|
||||||
width: 100%;
|
|
||||||
padding: 12px 16px;
|
|
||||||
border-radius: 14px;
|
|
||||||
border: 1px solid var(--glass-border-interactive);
|
|
||||||
background: var(--glass-bg);
|
|
||||||
color: var(--text);
|
|
||||||
font-size: 14px;
|
|
||||||
font-weight: 500;
|
|
||||||
cursor: pointer;
|
|
||||||
margin-top: 12px;
|
|
||||||
transition: transform 0.2s cubic-bezier(0.25, 1, 0.5, 1);
|
|
||||||
box-shadow: 0 1px 3px var(--glass-shadow);
|
|
||||||
-webkit-backdrop-filter: blur(12px);
|
|
||||||
backdrop-filter: blur(12px);
|
|
||||||
-webkit-tap-highlight-color: transparent;
|
|
||||||
}
|
|
||||||
|
|
||||||
.refresh-btn:active {
|
|
||||||
transform: scale(0.98);
|
|
||||||
}
|
|
||||||
</style>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
|
@ -611,7 +589,6 @@ function renderPlanFromData(data) {
|
||||||
html += renderPhases(data.phases, data.current_phase);
|
html += renderPhases(data.phases, data.current_phase);
|
||||||
}
|
}
|
||||||
|
|
||||||
html += '<button class="refresh-btn" onclick="loadPlan()">Refresh</button>';
|
|
||||||
el.innerHTML = html;
|
el.innerHTML = html;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -778,7 +755,6 @@ function renderActiveSessions(sessions) {
|
||||||
'<div style="color:var(--hint);font-size:12px;padding-left:16px">touch: ' + escapeHtml(touchDir) + '</div>' +
|
'<div style="color:var(--hint);font-size:12px;padding-left:16px">touch: ' + escapeHtml(touchDir) + '</div>' +
|
||||||
'</div>';
|
'</div>';
|
||||||
}
|
}
|
||||||
html += '<button class="refresh-btn" onclick="loadSession()" style="margin-top:8px">Refresh</button>';
|
|
||||||
html += '</div>';
|
html += '</div>';
|
||||||
return html;
|
return html;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue