fix(debugger): bound the Debug console by real lines and coalesce output events (#429)

Follow-up to #385. A single output event can carry thousands of lines, which became one console entry and escaped the line cap; output is now split at real line breaks (partial lines still merge across events) so the 50,000-line bound applies to physical lines. The host also coalesces debug output per session for up to 50 ms / 64 KB before it reaches desktop or remote IPC, flushing before any other session event so stops and exits never overtake earlier output.

Verified on macOS with a Java program printing ~480 lines/s for over a minute: the console stays responsive, follows the tail, and pause/step work.

Fixes #377
Цей коміт міститься в:
l0ng-ai authored and GitHub committed 2026-10-30 18:38:31 +08:00
1 джерело ea53e4f6fa
коміт f88b5d6f8c
4 files changed
+273 -22

No files matched your search

+55 -3
Переглянути файл
@@ -1,9 +1,15 @@
import { afterEach, describe, expect, it } from "vitest";
import { appendConsole, clearConsole, useDebug } from "../state";
import * as settings from "@/platform/workflowSettings";
import { afterEach, describe, expect, it, vi } from "vitest";
import { adoptSession, appendConsole, appendConsoleQueued, clearConsole, useDebug } from "../state";
const lines = (session: number) => useDebug.getState().console[session] ?? [];
afterEach(() => useDebug.setState({ console: {} }));
afterEach(() => {
vi.useRealTimers();
vi.restoreAllMocks();
for (const id of Object.keys(useDebug.getState().console)) clearConsole(+id);
useDebug.setState({ console: {}, sessions: {}, active: null });
});
describe("debug console", () => {
it("renders ANSI colours instead of showing the escapes", () => {
@@ -44,3 +50,49 @@ describe("debug console", () => {
expect(lines(6)[0]!.spans).toEqual([{ text: "after clear\n" }]);
});
});
describe("multiline output", () => {
it("splits styled chunks into physical lines and keeps partial-line identity", () => {
appendConsole(10, "stdout", "prefix ");
const first = lines(10)[0]!;
appendConsole(10, "stdout", "\x1b[31mred\n\nlast\x1b[0m");
expect(lines(10).map((line) => line.text)).toEqual(["prefix red\n", "\n", "last"]);
expect(lines(10)[0]!.id).toBe(first.id);
expect(first.text).toBe("prefix ");
expect(lines(10)[1]!.spans).toEqual([{ text: "\n", fg: "var(--ansi-red)" }]);
appendConsole(10, "stderr", "error\n");
appendConsole(10, "stdout", "next\n");
expect(lines(10).map((line) => line.category)).toEqual(["stdout", "stdout", "stdout", "stderr", "stdout"]);
});
it("keeps per-frame batching while bounding one event containing 60k lines", () => {
vi.useFakeTimers();
adoptSession(11);
const listener = vi.fn();
const unsubscribe = useDebug.subscribe(listener);
appendConsoleQueued(11, "stdout", "line\n".repeat(60_000));
appendConsoleQueued(11, "stdout", "tail\n");
expect(listener).not.toHaveBeenCalled();
vi.advanceTimersToNextFrame();
expect(listener).toHaveBeenCalledTimes(1);
unsubscribe();
expect(lines(11).length).toBeLessThanOrEqual(50_000);
expect(lines(11).length).toBeGreaterThan(40_000);
expect(lines(11).at(-1)!.text).toBe("tail\n");
expect(lines(11).every((line) => line.text === "line\n" || line.text === "tail\n")).toBe(true);
expect(new Set(lines(11).map((line) => line.id)).size).toBe(lines(11).length);
});
it("trims a multiline event by the configured character buffer", () => {
const current = settings.workflowSettings();
vi.spyOn(settings, "workflowSettings").mockReturnValue({
...current,
console: { ...current.console, overrideBuffer: true, bufferKb: 8 },
});
appendConsole(12, "stdout", ("x".repeat(4000) + "\n").repeat(6000));
const output = lines(12);
expect(output.length).toBeGreaterThan(0);
expect(output.reduce((n, line) => n + line.text.length + 1, 0)).toBeLessThanOrEqual(8 * 1024);
expect(output.every((line) => line.text.length === 4001)).toBe(true);
});
});
+29 -17
Переглянути файл
@@ -95,7 +95,7 @@ interface ExecutionPoint {
export const useExecutionPoint = create<ExecutionPoint>(() => ({ path: null, line: 0, top: true, values: {} }));
/** Console buffer: at most this many entries (merged output runs) per session. */
/** Console buffer: at most this many physical lines 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). */
@@ -269,21 +269,15 @@ export function appendConsole(session: DebugSessionId, category: string, raw: st
useDebug.setState((s) => {
const prev = s.console[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 });
}
appendConsoleLines(next, category, spans);
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
* Queues streamed debuggee output: ANSI parsing and the console store update happen
* 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) {
@@ -314,13 +308,7 @@ function flushConsoleOutput() {
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 });
}
appendConsoleLines(next, category, spans);
trimConsole(next);
consoleBySession[session] = next;
}
@@ -328,6 +316,30 @@ function flushConsoleOutput() {
});
}
/** Split styled output at real line breaks, retaining partial lines across events. */
function appendConsoleLines(lines: ConsoleLine[], category: string, spans: Span[]) {
const last = lines.at(-1);
let line =
last && last.category === category && !last.text.endsWith("\n") ? { ...last, spans: [...last.spans] } : undefined;
if (line) lines[lines.length - 1] = line;
for (const span of spans) {
let start = 0;
while (start < span.text.length) {
const newline = span.text.indexOf("\n", start);
const end = newline < 0 ? span.text.length : newline + 1;
const text = span.text.slice(start, end);
if (!line) {
line = { id: ++lineSeq, category, text: "", spans: [] };
lines.push(line);
}
line.text += text;
line.spans = appendSpans(line.spans, [{ ...span, text }]);
if (newline >= 0) line = undefined;
start = end;
}
}
}
/** Drops the oldest lines once past the line or configured character cap. */
function trimConsole(lines: ConsoleLine[]) {
let drop = 0;