Repository navigation
Description
Environment
- Antares version: latest (built from source / binary)
- Browser: Brave
- OS: macOS
Steps to reproduce
- Open dashboard and start a session
- Send a long-horizon coding task (e.g. "refactor this entire module with tests", or any task that triggers 20+ tool calls)
- Observe: dashboard gradually becomes unresponsive — scroll lags, text stops rendering, cursor freezes
- The backend continues working (visible in terminal logs), but the UI appears hung
Expected behavior
Dashboard stays responsive regardless of how many tool calls the agent makes.
Root cause analysis
The hang is caused by a React render storm on the frontend, plus several contributing factors:
1. Frontend render storm (PRIMARY)
File: web/src/pages/ChatPage.tsx
Every SSE event triggers setMessages() which maps the entire messages array:
// applyEvent called on EVERY event:
case 'text':
patchAssistant((m) => appendSeg(m, 'text', String(event.delta ?? '')))
case 'tool_call':
patchAssistant((m) => pushToolSeg(m, ...))
case 'tool_result':
patchAssistant((m) => updateToolSeg(m, ...))
// patchAssistant maps ALL messages every time:
const patchAssistant = (fn: (m: ChatMessage) => ChatMessage) =>
setMessages((prev) => prev.map((m) => (m.id === assistantId ? fn(m) : m)))Each await reader.read() in the SSE loop creates a separate async boundary → a separate React render cycle. A long-horizon task with 50+ tool calls can produce 200-500+ discrete render cycles, each doing O(n) array map. Main thread saturates → UI freeze.
Suggested fix: Batch SSE events on a ~16ms window (one frame) and apply them together:
// In streamPost loop:
let batch: StreamEvent[] = []
let frameId: number | null = null
for (;;) {
const { done, value } = await reader.read()
if (done) break
// ... parse frames into batch ...
if (!frameId) {
frameId = requestAnimationFrame(() => {
for (const evt of batch) onEvent(evt)
batch = []
frameId = null
})
}
}
// Flush remaining after loop
if (batch.length > 0) onEvent?.(batch[batch.length-1])2. liveRun.events unbounded growth during a turn
File: internal/server/livechat.go
func (lr *liveRun) publish(e agent.Event) {
lr.mu.Lock()
lr.events = append(lr.events, e) // grows without bound until finish()
lr.signal()
lr.mu.Unlock()
}For long turns, lr.events can hold thousands of objects → memory pressure + mutex contention. The follow() method replays from cursor, so it does not need every event — only the ones since the last follower.
Suggested fix: Cap events to N most recent (e.g. 1000) via ring buffer, or trim after each follower drains.
3. Polling reconnect loop
File: web/src/pages/ChatPage.tsx — attachLive()
// After done, reconnect every 1.5s:
if (alive) window.setTimeout(connect, 1500)This creates a new EventSource ~40 times per minute per open session tab. Each one opens a new HTTP connection + goroutine on the server.
Suggested fix: Use a single persistent SSE connection that stays open until the tab closes, rather than a reconnect loop. When a wake turn starts, the server can push events on the existing connection.
4. Missing panic recovery in agent goroutine
File: internal/server/handlers_chat.go
go func() {
// agent.Run(...) — if this panics:
lr.finish() // ← skipped
s.hub.remove(..., lr) // ← skipped → liveRun leak
}()No defer recover(). If the agent goroutine panics (e.g. nil deref during tool execution), the liveRun is never removed from liveHub.runs → permanent memory leak.
Suggested fix:
go func() {
defer func() {
if r := recover(); r != nil {
slog.Error("agent panic", "session", sessionKey, "panic", r)
lr.publish(agent.Event{Type: agent.EventError, Message: fmt.Sprintf("internal error: %v", r)})
}
lr.finish()
s.hub.remove(sessionKey, lr)
}()
// ... existing logic ...
}()Additional notes (lower priority)
maybeCompactat 80% context window threshold means history can balloon before compression kicks in — consider compressing earlier for long sessionspatchAssistantcould use an index-based update instead of mapping the entire array
Suggested priority
| # | Fix | Priority |
|---|---|---|
| 1 | Batch SSE events in frontend (rAF batching) | Critical |
| 2 | liveRun ring buffer / cap |
High |
| 3 | Persistent SSE instead of reconnect loop | Medium |
| 4 | Panic recovery in agent goroutine | Medium |
Activity
Fix runaway context gauge and dashboard freeze on long turns (#5)