From ea53e4f6fa7dd4635e738e1130f4a74a1b32a5db Mon Sep 17 00:00:00 2001 From: arookieofc Date: Fri, 30 Oct 2026 18:18:01 +0800 Subject: [PATCH] fix(debugger): cap and virtualise the Debug console, batch output events (#385) Each DAP output event copied the whole console array and re-rendered every buffered line, so a debuggee writing continuously to stdout froze the window after tens of thousands of lines. Output is now queued and applied once per animation frame (merged per session and category), the buffer is capped (50,000 entries, plus the console buffer-size setting), and the Debug console renders only the visible rows. Refs #377 Co-authored-by: a-rookie-of-C-language <186315048+a-rookie-of-C-language@users.noreply.github.com> --- .../debugger/__tests__/editor.test.ts | 7 +- src/features/debugger/state.ts | 90 ++++++++++++++++++- .../debugger/ui/DebugToolWindow.module.css | 10 +++ src/features/debugger/ui/DebugToolWindow.tsx | 51 ++++++++--- 4 files changed, 141 insertions(+), 17 deletions(-) diff --git a/src/features/debugger/__tests__/editor.test.ts b/src/features/debugger/__tests__/editor.test.ts index feb4a5be..57d96067 100644 --- a/src/features/debugger/__tests__/editor.test.ts +++ b/src/features/debugger/__tests__/editor.test.ts @@ -21,6 +21,7 @@ function makeView(doc: string) { } const flush = () => new Promise((r) => setTimeout(r, 0)); +const flushFrame = () => new Promise((r) => requestAnimationFrame(r)); function gutterClasses(view: EditorView): string[] { return [...view.dom.querySelectorAll(".cm-lineNumbers .cm-gutterElement")].map((e) => e.className); @@ -154,11 +155,12 @@ describe("execution point", () => { }); describe("session events", () => { - it("tracks pause / continue / termination and publishes context keys", () => { + it("tracks pause / continue / termination and publishes context keys", async () => { vi.spyOn(console, "warn").mockImplementation(() => {}); adoptSession(77); handleDebugEvent(77, { kind: "output", category: "stdout", output: "hel" }); handleDebugEvent(77, { kind: "output", category: "stdout", output: "lo\n" }); + await flushFrame(); expect(useDebug.getState().console[77]!.map((l) => l.text)).toEqual(["hello\n"]); expect(getContext().debugSessionActive).toBe(true); handleDebugEvent(77, { @@ -181,7 +183,7 @@ describe("session events", () => { expect(getContext().debugSessionActive).toBe(false); }); - it("ignores sessions this window did not start, but adopts children of its own", () => { + it("ignores sessions this window did not start, but adopts children of its own", async () => { vi.spyOn(console, "warn").mockImplementation(() => {}); adoptSession(90, { name: "app", adapter: "js-debug" }); // Another window's session: no session, no console, no tool window. @@ -202,6 +204,7 @@ describe("session events", () => { // A child the adapter of our session started is adopted with its parent. handleDebugEvent(92, { kind: "started", name: "worker", adapter: "js-debug", parent: 90 }); handleDebugEvent(92, { kind: "output", category: "stdout", output: "child\n" }); + await flushFrame(); const child = useDebug.getState().sessions[92]!; expect([child.name, child.parent, child.status]).toEqual(["worker", 90, "running"]); expect(useDebug.getState().console[92]!.map((l) => l.text)).toEqual(["child\n"]); diff --git a/src/features/debugger/state.ts b/src/features/debugger/state.ts index 5a8c36a6..46a6bdfa 100644 --- a/src/features/debugger/state.ts +++ b/src/features/debugger/state.ts @@ -95,11 +95,21 @@ interface ExecutionPoint { export const useExecutionPoint = create(() => ({ path: null, line: 0, top: true, values: {} })); -const MAX_CONSOLE_LINES = 5000; +/** Console buffer: at most this many entries (merged output runs) per session. */ +const MAX_CONSOLE_LINES = 50_000; let lineSeq = 0; /** ANSI state per session and output category (a colour can span several output events). */ const ansiStreams = new Map>(); +/** Streamed output buffered until the next animation frame (one setState per frame). */ +interface PendingOutput { + session: DebugSessionId; + category: string; + raw: string; +} +const pendingOutput: PendingOutput[] = []; +let outputFlushHandle: number | null = null; + function ansiStream(session: DebugSessionId, category: string): AnsiStream { let streams = ansiStreams.get(session); if (!streams) ansiStreams.set(session, (streams = new Map())); @@ -252,6 +262,7 @@ export function publishContext() { export function appendConsole(session: DebugSessionId, category: string, raw: string) { if (!raw) return; + flushConsoleOutput(); const spans = ansiStream(session, category).parse(raw); const text = spans.map((sp) => sp.text).join(""); if (!text) return; @@ -265,11 +276,79 @@ export function appendConsole(session: DebugSessionId, category: string, raw: st } else { next.push({ id: ++lineSeq, category, text, spans }); } - if (next.length > MAX_CONSOLE_LINES) next.splice(0, next.length - MAX_CONSOLE_LINES); + trimConsole(next); return { console: { ...s.console, [session]: next } }; }); } +/** + * Queues streamed debuggee output: ANSI parsing happens now, the console store + * is updated once per animation frame with all pending chunks merged per + * (session, category), so a flood of `output` events can't saturate React. + */ +export function appendConsoleQueued(session: DebugSessionId, category: string, raw: string) { + if (!raw) return; + const last = pendingOutput[pendingOutput.length - 1]; + if (last && last.session === session && last.category === category) { + last.raw += raw; + } else { + pendingOutput.push({ session, category, raw }); + } + if (outputFlushHandle === null) { + outputFlushHandle = requestAnimationFrame(() => { + outputFlushHandle = null; + flushConsoleOutput(); + }); + } +} + +function flushConsoleOutput() { + if (pendingOutput.length === 0) return; + const chunks = pendingOutput.splice(0); + useDebug.setState((s) => { + const consoleBySession = { ...s.console }; + for (const { session, category, raw } of chunks) { + if (!s.sessions[session]) continue; + const spans = ansiStream(session, category).parse(raw); + const text = spans.map((sp) => sp.text).join(""); + if (!text) continue; + const prev = consoleBySession[session] ?? []; + const next = [...prev]; + const last = next[next.length - 1]; + // Merge partial lines of the same category. + if (last && last.category === category && !last.text.endsWith("\n")) { + next[next.length - 1] = { ...last, text: last.text + text, spans: appendSpans(last.spans, spans) }; + } else { + next.push({ id: ++lineSeq, category, text, spans }); + } + trimConsole(next); + consoleBySession[session] = next; + } + return { console: consoleBySession }; + }); +} + +/** Drops the oldest lines once past the line or configured character cap. */ +function trimConsole(lines: ConsoleLine[]) { + let drop = 0; + if (lines.length > MAX_CONSOLE_LINES) { + drop = lines.length - MAX_CONSOLE_LINES + Math.ceil(MAX_CONSOLE_LINES / 10); + } + const c = workflowSettings().console; + if (c.overrideBuffer) { + const limit = c.bufferKb * 1024; + let chars = 0; + for (let i = lines.length - 1; i >= 0; i--) { + chars += lines[i]!.text.length + 1; + if (chars > limit) { + drop = Math.max(drop, i + 1); + break; + } + } + } + if (drop > 0) lines.splice(0, drop); +} + export function clearConsole(session: DebugSessionId) { ansiStreams.delete(session); useDebug.setState((s) => ({ console: { ...s.console, [session]: [] } })); @@ -685,7 +764,7 @@ export function handleDebugEvent(id: DebugSessionId, event: DebugEvent) { break; } case "output": - appendConsole(id, event.category, event.output); + appendConsoleQueued(id, event.category, event.output); break; case "thread": if (s.status === "paused") void loadThreads(id); @@ -707,6 +786,11 @@ export function handleDebugEvent(id: DebugSessionId, event: DebugEvent) { export function resetSessions() { const { sessions } = useDebug.getState(); for (const s of Object.values(sessions)) if (s.parent == null && isLive(s)) void stopSession(s.id); + pendingOutput.length = 0; + if (outputFlushHandle !== null) { + cancelAnimationFrame(outputFlushHandle); + outputFlushHandle = null; + } ansiStreams.clear(); useDebug.setState({ sessions: {}, active: null, console: {}, stopSeq: 0 }); useExecutionPoint.setState({ path: null, line: 0, top: true, values: {} }); diff --git a/src/features/debugger/ui/DebugToolWindow.module.css b/src/features/debugger/ui/DebugToolWindow.module.css index e9cf9c70..a5e878be 100644 --- a/src/features/debugger/ui/DebugToolWindow.module.css +++ b/src/features/debugger/ui/DebugToolWindow.module.css @@ -230,6 +230,7 @@ flex: 1; min-height: 0; overflow: auto; + position: relative; padding: 6px 14px; font-family: var(--font-mono); font-size: var(--fs-ui); @@ -244,7 +245,16 @@ color: var(--fg); } +.consoleInner { + position: relative; + width: 100%; +} + .out { + position: absolute; + top: 0; + left: 0; + min-width: 100%; color: var(--fg); } .cat-stderr { diff --git a/src/features/debugger/ui/DebugToolWindow.tsx b/src/features/debugger/ui/DebugToolWindow.tsx index 75c668a4..3746a54b 100644 --- a/src/features/debugger/ui/DebugToolWindow.tsx +++ b/src/features/debugger/ui/DebugToolWindow.tsx @@ -1,7 +1,8 @@ /** Debug tool window: toolbar, Threads & Variables (frames + variables), Console. */ import { copySelectionOnKeyDown } from "@/ui/selectableText"; -import { useEffect, useMemo, useRef, useState, type KeyboardEvent } from "react"; +import { useEffect, useLayoutEffect, useMemo, useRef, useState, type KeyboardEvent } from "react"; import clsx from "clsx"; +import { useVirtualizer } from "@tanstack/react-virtual"; import { executeAction, getAction } from "@/platform/actions"; import { evaluateWhen, useContextKeys } from "@/platform/context"; import { basename, useProject } from "@/platform/project"; @@ -550,15 +551,28 @@ function ConsoleView({ session }: { session: DebugSession }) { const lines = useDebug((s) => s.console[session.id]); const scroller = useRef(null); const stick = useRef(true); + const programmatic = useRef(false); const [input, setInput] = useState(""); const history = useRef(readHistory("console")); const historyPos = useRef(-1); const inputRef = useRef(null); - useEffect(() => { + const virtualizer = useVirtualizer({ + count: lines?.length ?? 0, + getScrollElement: () => scroller.current, + estimateSize: () => 18, + overscan: 30, + getItemKey: (i) => lines?.[i]?.id ?? i, + }); + + useLayoutEffect(() => { const el = scroller.current; - if (el && stick.current) el.scrollTop = el.scrollHeight; - }, [lines]); + if (!el || !stick.current || !lines?.length) return; + programmatic.current = true; + virtualizer.scrollToIndex(lines.length - 1, { align: "end" }); + el.scrollTop = el.scrollHeight; + requestAnimationFrame(() => (programmatic.current = false)); + }, [lines, virtualizer]); const submit = async () => { const e = input.trim(); @@ -614,6 +628,7 @@ function ConsoleView({ session }: { session: DebugSession }) { ref={scroller} className={clsx(styles.consoleOutput, ansi.palette)} onScroll={(e) => { + if (programmatic.current) return; const el = e.currentTarget; stick.current = el.scrollHeight - el.scrollTop - el.clientHeight < 24; }} @@ -626,15 +641,27 @@ function ConsoleView({ session }: { session: DebugSession }) { aria-label={tx("Debug console")} tabIndex={0} > - {(lines ?? []).map((l) => ( - - {l.spans.map((sp, i) => ( - - {sp.text} +
+ {virtualizer.getVirtualItems().map((item) => { + const l = lines?.[item.index]; + if (!l) return null; + return ( + + {l.spans.map((sp, i) => ( + + {sp.text} + + ))} - ))} - - ))} + ); + })} +