diff --git a/src/main/runtime/orchestration/worker-output-archive-bounding.test.ts b/src/main/runtime/orchestration/worker-output-archive-bounding.test.ts new file mode 100644 index 00000000000..a45cc6c0531 --- /dev/null +++ b/src/main/runtime/orchestration/worker-output-archive-bounding.test.ts @@ -0,0 +1,41 @@ +import { describe, expect, it } from 'vitest' +import { boundArchiveLines } from './worker-output-archive' + +const TERMINAL_ARCHIVE_MAX_CHARS = 262_144 + +function totalCost(lines: string[]): number { + return lines.reduce((sum, line) => sum + line.length + 1, 0) +} + +describe('boundArchiveLines', () => { + it('returns the original array untouched when the tail already fits', () => { + const lines = ['one', 'two', 'three'] + const bounded = boundArchiveLines(lines) + expect(bounded.truncated).toBe(false) + expect(bounded.lines).toBe(lines) + }) + + it('keeps the newest lines in order and reports truncation', () => { + const lines = Array.from({ length: 40_000 }, (_, index) => `line ${index}`) + const bounded = boundArchiveLines(lines) + expect(bounded.truncated).toBe(true) + expect(totalCost(bounded.lines)).toBeLessThanOrEqual(TERMINAL_ARCHIVE_MAX_CHARS) + expect(bounded.lines.at(-1)).toBe(lines.at(-1)) + expect(bounded.lines).toEqual(lines.slice(lines.length - bounded.lines.length)) + }) + + it('truncates a single oversized line from its tail', () => { + const bounded = boundArchiveLines(['x'.repeat(TERMINAL_ARCHIVE_MAX_CHARS * 2)]) + expect(bounded.truncated).toBe(true) + expect(bounded.lines).toHaveLength(1) + expect(bounded.lines[0]).toHaveLength(TERMINAL_ARCHIVE_MAX_CHARS - 1) + }) + + it('bounds a blank-line flood in linear time', () => { + // The char budget admits ~262k blank lines; an unshift-per-line build was ~4.3s here. + const startedAt = performance.now() + const bounded = boundArchiveLines(Array.from({ length: 300_000 }, () => '')) + expect(bounded.lines).toHaveLength(TERMINAL_ARCHIVE_MAX_CHARS) + expect(performance.now() - startedAt).toBeLessThan(500) + }) +}) diff --git a/src/main/runtime/orchestration/worker-output-archive.ts b/src/main/runtime/orchestration/worker-output-archive.ts index f6f2b52ecb1..55d16467269 100644 --- a/src/main/runtime/orchestration/worker-output-archive.ts +++ b/src/main/runtime/orchestration/worker-output-archive.ts @@ -114,7 +114,7 @@ export async function captureWorkerOutputArchive(args: { } } -function boundArchiveLines(lines: string[]): { lines: string[]; truncated: boolean } { +export function boundArchiveLines(lines: string[]): { lines: string[]; truncated: boolean } { let total = 0 for (const line of lines) { total += line.length + 1 @@ -122,18 +122,21 @@ function boundArchiveLines(lines: string[]): { lines: string[]; truncated: boole if (total <= TERMINAL_ARCHIVE_MAX_CHARS) { return { lines, truncated: false } } - const kept: string[] = [] + // Collected newest-first and reversed once: unshift per line is O(n^2) and the + // char budget admits ~260k blank lines. + const keptReversed: string[] = [] let budget = TERMINAL_ARCHIVE_MAX_CHARS for (let index = lines.length - 1; index >= 0; index -= 1) { const cost = lines[index].length + 1 if (cost > budget) { - if (kept.length === 0 && budget > 1) { - kept.unshift(lines[index].slice(-(budget - 1))) + if (keptReversed.length === 0 && budget > 1) { + keptReversed.push(lines[index].slice(-(budget - 1))) } break } - kept.unshift(lines[index]) + keptReversed.push(lines[index]) budget -= cost } - return { lines: kept, truncated: true } + keptReversed.reverse() + return { lines: keptReversed, truncated: true } }