Skip to content

Dashboard freezes / becomes unresponsive during long-horizon tasks #5

Closed

Description

@eastskyside

Environment

  • Antares version: latest (built from source / binary)
  • Browser: Brave
  • OS: macOS

Steps to reproduce

  1. Open dashboard and start a session
  2. Send a long-horizon coding task (e.g. "refactor this entire module with tests", or any task that triggers 20+ tool calls)
  3. Observe: dashboard gradually becomes unresponsive — scroll lags, text stops rendering, cursor freezes
  4. 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)

  • maybeCompact at 80% context window threshold means history can balloon before compression kicks in — consider compressing earlier for long sessions
  • patchAssistant could 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
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions