дзеркало
https://github.com/l0ng-ai/openidea.git
синхронізовано 2026-11-09 11:15:40 +02:00
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
Цей коміт міститься в:
1 джерело
ea53e4f6fa
коміт
f88b5d6f8c
4 files changed
+273
-22
No files matched your search
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
|
||||
Reference in new issue
Заблокувати користувача