From 7262dc137f4d8f5ae46db866add3e8f8788cea9c Mon Sep 17 00:00:00 2001 From: Jihad Irfansyah Date: Sat, 8 Aug 2026 09:17:55 +0700 Subject: [PATCH] Fix dashboard hangs from MCP deadlock and long reasoning streams MCP stdio held a lock for the full RPC duration, so Close/Refresh blocked when IDA or another backend hung, leaving zombie children and freezing the web UI. Chat also re-mapped the entire transcript every frame and grew unbounded reasoning strings during high-effort turns. Release the send lock while waiting for responses, kill+Wait on Close, batch stream patches by message only, and make live reasoning display configurable via display.show_reasoning and display.max_live_reasoning_chars. --- internal/config/config.go | 16 ++- internal/config/defaults.go | 5 +- internal/config/load.go | 4 + internal/config/schema.go | 8 +- internal/mcp/client.go | 64 ++++++++++-- web/src/components/chat/SubAgentPanel.tsx | 70 +++++++++++-- web/src/pages/ChatPage.tsx | 120 ++++++++++++++++++---- 7 files changed, 244 insertions(+), 43 deletions(-) diff --git a/internal/config/config.go b/internal/config/config.go index e641a7f..3681f0b 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -811,11 +811,17 @@ type Display struct { Compact bool `yaml:"compact" json:"compact"` ToolProgress bool `yaml:"tool_progress" json:"tool_progress"` ShowReasoning bool `yaml:"show_reasoning" json:"show_reasoning"` - Theme string `yaml:"theme" json:"theme"` - Skin string `yaml:"skin" json:"skin"` - Language string `yaml:"language" json:"language"` - BellOnComplete bool `yaml:"bell_on_complete" json:"bell_on_complete"` - InterimAssistant bool `yaml:"interim_assistant_messages" json:"interim_assistant_messages"` + // MaxLiveReasoningChars caps how much of a streaming reasoning trace the + // dashboard keeps in React state (trailing window). High-effort models can + // emit hundreds of KB per turn; unbounded string growth freezes the tab. + // 0 means unlimited. The full text is still persisted server-side and is + // restored on hydrate after the turn completes. + MaxLiveReasoningChars int `yaml:"max_live_reasoning_chars" json:"max_live_reasoning_chars"` + Theme string `yaml:"theme" json:"theme"` + Skin string `yaml:"skin" json:"skin"` + Language string `yaml:"language" json:"language"` + BellOnComplete bool `yaml:"bell_on_complete" json:"bell_on_complete"` + InterimAssistant bool `yaml:"interim_assistant_messages" json:"interim_assistant_messages"` } // Logging controls log level and sinks. diff --git a/internal/config/defaults.go b/internal/config/defaults.go index b88cb01..9959453 100644 --- a/internal/config/defaults.go +++ b/internal/config/defaults.go @@ -123,8 +123,9 @@ func Default() *Config { SessionReset: SessionReset{Mode: "never", IdleMinutes: 180, AtHour: 4}, Streaming: Streaming{Enabled: true}, Display: Display{ - ToolProgress: true, ShowReasoning: true, Theme: "system", - Skin: "antares", Language: "auto", InterimAssistant: true, + ToolProgress: true, ShowReasoning: true, + MaxLiveReasoningChars: 48_000, + Theme: "system", Skin: "antares", Language: "auto", InterimAssistant: true, }, Logging: Logging{Level: "info", File: filepath.Join(Home(), "logs", "antares.log")}, MCP: MCP{Enabled: true, Servers: map[string]MCPServer{}}, diff --git a/internal/config/load.go b/internal/config/load.go index 090e66b..1afea33 100644 --- a/internal/config/load.go +++ b/internal/config/load.go @@ -246,6 +246,10 @@ func normalize(c *Config) { if c.Agent.MaxTurns <= 0 { c.Agent.MaxTurns = 200 } + // Negative is meaningless; treat as default cap. 0 stays unlimited. + if c.Display.MaxLiveReasoningChars < 0 { + c.Display.MaxLiveReasoningChars = 48_000 + } } // ResolveProvider returns the provider entry used for a model call, falling back diff --git a/internal/config/schema.go b/internal/config/schema.go index cec2ce6..4c10d88 100644 --- a/internal/config/schema.go +++ b/internal/config/schema.go @@ -79,8 +79,9 @@ var common = map[string]bool{ "compression.enabled": true, "streaming.enabled": true, "delegation.enabled": true, - "display.show_reasoning": true, - "display.tool_progress": true, + "display.show_reasoning": true, + "display.tool_progress": true, + "display.max_live_reasoning_chars": true, "logging.level": true, "server.host": true, } @@ -138,6 +139,9 @@ var help = map[string]string{ "memory.memory_enabled": "Lets the agent store durable facts between sessions.", "skills.auto_create": "Allows the agent to write new skills on its own.", "osint.google_cookie": "Optional. A logged-in Google Cookie header enables osint_google to resolve an email to its public profile. ToS-sensitive; uses your own session. Leave empty to disable.", + "display.show_reasoning": "Stream and show model reasoning/thinking in the dashboard (and TUI). Off skips emitting reasoning events so long thinking traces never hit the UI.", + "display.tool_progress": "Show live tool progress lines while a tool runs.", + "display.max_live_reasoning_chars": "Max characters of reasoning kept in the browser while a turn streams (trailing window). Prevents tab freezes on long thinking. Default 48000. 0 = unlimited. Full text is still saved server-side and restored after the turn.", } func secretKey(path string) bool { diff --git a/internal/mcp/client.go b/internal/mcp/client.go index edce43c..b364b82 100644 --- a/internal/mcp/client.go +++ b/internal/mcp/client.go @@ -333,13 +333,26 @@ func (c *Client) Close() error { return c.transport.Close() } // stdioTransport runs the server as a child process and exchanges // newline-delimited JSON-RPC frames over its pipes. +// +// Concurrency model: +// - sendMu serialises request/response pairs (stdio MCP is half-duplex). +// - mu protects closed/stdin so Close can mark the transport dead and kill the +// child without waiting for a hung RPC to finish writing. +// +// Previously send held one mutex for the entire RPC duration. If the child hung +// (e.g. IDA Pro MCP waiting on a dead IDA RPC port), Close/Refresh blocked +// forever on that lock — the dashboard MCP page and any reload path appeared to +// hang. Close must be able to kill the process so the blocked read returns EOF. type stdioTransport struct { cmd *exec.Cmd stdin io.WriteCloser stdout *bufio.Reader - mu sync.Mutex + sendMu sync.Mutex // serialises send/notify request cycles + mu sync.Mutex // protects closed + stdin write against Close closed bool + waitOnce sync.Once + waitErr error } func newStdioTransport(cfg ServerConfig) (transport, error) { @@ -375,20 +388,41 @@ func newStdioTransport(cfg ServerConfig) (transport, error) { } }() - return &stdioTransport{cmd: cmd, stdin: stdin, stdout: bufio.NewReaderSize(stdout, 1<<20)}, nil + t := &stdioTransport{cmd: cmd, stdin: stdin, stdout: bufio.NewReaderSize(stdout, 1<<20)} + // Reap the child if it exits on its own so it never sits as a zombie until + // the next Close/Refresh. Wait is idempotent via waitOnce. + go func() { _ = t.reap() }() + return t, nil +} + +func (t *stdioTransport) reap() error { + t.waitOnce.Do(func() { + if t.cmd != nil { + t.waitErr = t.cmd.Wait() + } + }) + return t.waitErr } func (t *stdioTransport) send(ctx context.Context, req rpcRequest) (*rpcResponse, error) { + // One in-flight request at a time — required for line-delimited stdio. + t.sendMu.Lock() + defer t.sendMu.Unlock() + t.mu.Lock() - defer t.mu.Unlock() if t.closed { + t.mu.Unlock() return nil, fmt.Errorf("mcp connection closed") } - if err := t.writeFrame(req); err != nil { + err := t.writeFrame(req) + t.mu.Unlock() + if err != nil { return nil, err } // Skip any notification the server interleaves before our response. + // Do NOT hold mu while waiting: Close must be able to kill the child so a + // hung tools/call (dead IDA backend, wedged proxy, …) unblocks. type result struct { resp *rpcResponse err error @@ -419,6 +453,9 @@ func (t *stdioTransport) send(ctx context.Context, req rpcRequest) (*rpcResponse select { case <-ctx.Done(): + // Tear down the child so the reader goroutine unblocks on EOF and the + // next send cannot talk to a half-dead process with a stolen reply. + _ = t.Close() return nil, ctx.Err() case r := <-ch: return r.resp, r.err @@ -426,8 +463,13 @@ func (t *stdioTransport) send(ctx context.Context, req rpcRequest) (*rpcResponse } func (t *stdioTransport) notify(_ context.Context, req rpcRequest) error { + t.sendMu.Lock() + defer t.sendMu.Unlock() t.mu.Lock() defer t.mu.Unlock() + if t.closed { + return fmt.Errorf("mcp connection closed") + } return t.writeFrame(req) } @@ -444,16 +486,20 @@ func (t *stdioTransport) writeFrame(req rpcRequest) error { func (t *stdioTransport) Close() error { t.mu.Lock() - defer t.mu.Unlock() if t.closed { - return nil + t.mu.Unlock() + return t.reap() } t.closed = true _ = t.stdin.Close() - if t.cmd.Process != nil { - _ = t.cmd.Process.Kill() + proc := t.cmd.Process + t.mu.Unlock() + + if proc != nil { + _ = proc.Kill() } - return nil + // Always Wait so the child does not linger as a zombie under antares. + return t.reap() } // ---- http transport ---------------------------------------------------------- diff --git a/web/src/components/chat/SubAgentPanel.tsx b/web/src/components/chat/SubAgentPanel.tsx index ac48c42..1a7bd51 100644 --- a/web/src/components/chat/SubAgentPanel.tsx +++ b/web/src/components/chat/SubAgentPanel.tsx @@ -1,8 +1,9 @@ import { useEffect, useRef, useState } from 'react' import { ArrowLeft, CircleNotch, UsersThree } from '@phosphor-icons/react' -import { streamGet, type StreamEvent } from '@/lib/api' +import { get, streamGet, type StreamEvent } from '@/lib/api' import { useI18n } from '@/lib/i18n' import { + DEFAULT_MAX_LIVE_REASONING_CHARS, MessageBubble, appendSeg, pushToolSeg, @@ -28,8 +29,30 @@ export function SubAgentPanel({ agent, onBack }: { agent: ActiveAgent; onBack: ( const { t } = useI18n() const [messages, setMessages] = useState([]) const [done, setDone] = useState(false) + const [showReasoning, setShowReasoning] = useState(true) + const showReasoningRef = useRef(true) + const maxLiveReasoningRef = useRef(DEFAULT_MAX_LIVE_REASONING_CHARS) const bottomRef = useRef(null) + useEffect(() => { + showReasoningRef.current = showReasoning + }, [showReasoning]) + + useEffect(() => { + get<{ + values?: { display?: { show_reasoning?: boolean; max_live_reasoning_chars?: number } } + }>('/config') + .then((d) => { + const disp = d.values?.display + if (disp && typeof disp.show_reasoning === 'boolean') setShowReasoning(disp.show_reasoning) + const n = Number(disp?.max_live_reasoning_chars) + if (Number.isFinite(n)) { + maxLiveReasoningRef.current = n < 0 ? DEFAULT_MAX_LIVE_REASONING_CHARS : n + } + }) + .catch(() => {}) + }, []) + useEffect(() => { // Reset when switching between sub-agents. setMessages([]) @@ -39,18 +62,43 @@ export function SubAgentPanel({ agent, onBack }: { agent: ActiveAgent; onBack: ( // main transcript renders one streaming turn. setMessages([{ id, role: 'assistant', content: '' }]) - const patch = (fn: (m: ChatMessage) => ChatMessage) => - setMessages((prev) => prev.map((m) => (m.id === id ? fn(m) : m))) + // Batch stream patches once per frame — sub-agents stream the same dense + // reasoning token firehose as the main chat and used to setState per token. + let pending: ((m: ChatMessage) => ChatMessage) | null = null + let raf: number | null = null + const flush = () => { + raf = null + const fn = pending + pending = null + if (!fn) return + setMessages((prev) => { + const idx = prev.findIndex((m) => m.id === id) + if (idx < 0) return prev + const next = prev.slice() + next[idx] = fn(prev[idx]) + return next + }) + } + const patch = (fn: (m: ChatMessage) => ChatMessage) => { + const prev = pending + pending = prev ? (m) => fn(prev(m)) : fn + if (raf == null) raf = requestAnimationFrame(flush) + } const close = streamGet( `/subagent/${encodeURIComponent(agent.id)}/attach`, (event: StreamEvent) => { switch (event.type) { case 'text': - patch((m) => appendSeg(m, 'text', String(event.delta ?? ''))) + patch((m) => + appendSeg(m, 'text', String(event.delta ?? ''), maxLiveReasoningRef.current), + ) break case 'reasoning': - patch((m) => appendSeg(m, 'reasoning', String(event.delta ?? ''))) + if (!showReasoningRef.current) break + patch((m) => + appendSeg(m, 'reasoning', String(event.delta ?? ''), maxLiveReasoningRef.current), + ) break case 'tool_call': patch((m) => @@ -88,13 +136,21 @@ export function SubAgentPanel({ agent, onBack }: { agent: ActiveAgent; onBack: ( patch((m) => ({ ...m, error: String(event.error ?? '') })) break case 'done': + if (raf != null) { + cancelAnimationFrame(raf) + raf = null + } + if (pending) flush() setDone(true) break } }, () => setDone(true), ) - return close + return () => { + if (raf != null) cancelAnimationFrame(raf) + close() + } }, [agent.id]) // Keep the newest output in view as it streams. @@ -138,7 +194,7 @@ export function SubAgentPanel({ agent, onBack }: { agent: ActiveAgent; onBack: ( ) : null} {messages.map((m) => ( - + ))}
diff --git a/web/src/pages/ChatPage.tsx b/web/src/pages/ChatPage.tsx index ab5cd58..d67042f 100644 --- a/web/src/pages/ChatPage.tsx +++ b/web/src/pages/ChatPage.tsx @@ -84,21 +84,53 @@ export interface ChatMessage { docs?: { path: string; name: string }[] } +/** + * Default soft cap for live reasoning text in React state while a turn streams. + * Overridden by `display.max_live_reasoning_chars` from config (0 = unlimited). + * High-effort models can emit hundreds of KB of reasoning per turn; unbounded + * string growth freezes the dashboard main thread. Full text is still saved + * server-side and restored on attach `done` / hydrate. + */ +export const DEFAULT_MAX_LIVE_REASONING_CHARS = 48_000 + /** Append a text or reasoning delta, extending the last segment when it is the - * same kind so a streamed sentence stays one block. */ -export function appendSeg(m: ChatMessage, kind: 'text' | 'reasoning', delta: string): ChatMessage { - const segs = m.segments ? [...m.segments] : [] + * same kind so a streamed sentence stays one block. + * `maxLiveReasoningChars`: trailing-window cap; ≤0 means unlimited. */ +export function appendSeg( + m: ChatMessage, + kind: 'text' | 'reasoning', + delta: string, + maxLiveReasoningChars: number = DEFAULT_MAX_LIVE_REASONING_CHARS, +): ChatMessage { + const segs = m.segments ? m.segments.slice() : [] const last = segs[segs.length - 1] if (last && last.kind === kind) { segs[segs.length - 1] = { kind, text: last.text + delta } } else { segs.push({ kind, text: delta }) } + let content = m.content + let reasoning = m.reasoning + if (kind === 'text') { + content = m.content + delta + } else { + const next = (m.reasoning ?? '') + delta + const cap = maxLiveReasoningChars + // Keep a trailing window so the bubble stays usable and string growth is O(cap). + reasoning = cap > 0 && next.length > cap ? next.slice(next.length - cap) : next + const segLast = segs[segs.length - 1] + if (cap > 0 && segLast?.kind === 'reasoning' && segLast.text.length > cap) { + segs[segs.length - 1] = { + kind: 'reasoning', + text: segLast.text.slice(segLast.text.length - cap), + } + } + } return { ...m, segments: segs, - content: kind === 'text' ? m.content + delta : m.content, - reasoning: kind === 'reasoning' ? (m.reasoning ?? '') + delta : m.reasoning, + content, + reasoning, } } @@ -290,6 +322,36 @@ export default function ChatPage() { .then((d) => setCtxWindow((w) => w || Number(d.context_window ?? 0))) .catch(() => {}) }, []) + // display.* prefs from config: whether to show reasoning at all, and the + // live-stream character cap (trailing window). Defaults match server defaults. + const [showReasoning, setShowReasoning] = useState(true) + const showReasoningRef = useRef(true) + const maxLiveReasoningRef = useRef(DEFAULT_MAX_LIVE_REASONING_CHARS) + useEffect(() => { + showReasoningRef.current = showReasoning + }, [showReasoning]) + useEffect(() => { + get<{ + values?: { + display?: { + show_reasoning?: boolean + max_live_reasoning_chars?: number + } + } + }>('/config') + .then((d) => { + const disp = d.values?.display + if (disp && typeof disp.show_reasoning === 'boolean') { + setShowReasoning(disp.show_reasoning) + } + const n = Number(disp?.max_live_reasoning_chars) + if (Number.isFinite(n)) { + // 0 = unlimited; negative is normalized server-side to default. + maxLiveReasoningRef.current = n < 0 ? DEFAULT_MAX_LIVE_REASONING_CHARS : n + } + }) + .catch(() => {}) + }, []) // When set, an overlay shows this sub-agent's live transcript instead of the // main one; clearing it returns to the main agent. const [viewingAgent, setViewingAgent] = useState(null) @@ -396,20 +458,31 @@ export default function ChatPage() { if (queued.length === 0) return patchQueue.current = [] const byMessage = groupStreamPatches(queued) - setMessages((prev) => - prev.map((message) => { - const patches = byMessage.get(message.id) - if (!patches) return message - let next = message + // Only clone/replace messages that actually received patches. Mapping the + // entire transcript every frame re-renders hundreds of bubbles on long + // sessions and was a major source of dashboard freezes during long turns. + setMessages((prev) => { + if (byMessage.size === 0) return prev + let next = prev + let cloned = false + for (const [id, patches] of byMessage) { + const idx = next.findIndex((m) => m.id === id) + if (idx < 0) continue + let message = next[idx] for (const patch of patches) { - next = + message = patch.kind === 'delta' - ? appendSeg(next, patch.segment, patch.delta) - : patch.fn(next) + ? appendSeg(message, patch.segment, patch.delta, maxLiveReasoningRef.current) + : patch.fn(message) } - return next - }), - ) + if (!cloned) { + next = prev.slice() + cloned = true + } + next[idx] = message + } + return next + }) }, []) const enqueuePatch = useCallback( (id: string, fn: (m: ChatMessage) => ChatMessage) => { @@ -512,7 +585,11 @@ export default function ChatPage() { enqueueDelta(assistantId, 'text', String(event.delta ?? '')) break case 'reasoning': - enqueueDelta(assistantId, 'reasoning', String(event.delta ?? '')) + // Honour display.show_reasoning even if a stale event arrives (server + // also suppresses when false; this keeps the UI consistent). + if (showReasoningRef.current) { + enqueueDelta(assistantId, 'reasoning', String(event.delta ?? '')) + } break case 'tool_call': setLive((s) => ({ turn: s.turn + 1, tool: String(event.name ?? '') })) @@ -1338,6 +1415,7 @@ export default function ChatPage() {
0 ? message.segments.map((seg, i) => { if (seg.kind === 'reasoning') { + if (!showReasoning) return null return } if (seg.kind === 'tool') { @@ -1918,7 +2000,9 @@ export const MessageBubble = memo(function MessageBubble({ }) : // Fallback for any message that predates the timeline model. <> - {message.reasoning ? : null} + {showReasoning && message.reasoning ? ( + + ) : null} {message.toolCalls?.map((call) => call.name === 'todo' ? null : call.name === 'ask_user' ? ( {})} />