diff --git a/src/shared/child-process/bounded-output-sink.test.ts b/src/shared/child-process/bounded-output-sink.test.ts new file mode 100644 index 00000000000..c94458e7211 --- /dev/null +++ b/src/shared/child-process/bounded-output-sink.test.ts @@ -0,0 +1,34 @@ +import { describe, expect, it } from 'vitest' +import { createOutputSink } from './bounded-output-sink' + +describe('bounded process output', () => { + it('can read empty output and continue collecting', () => { + const sink = createOutputSink(10) + expect(sink.text()).toBe('') + sink.write('one') + expect(sink.text()).toBe('one') + sink.write('two') + expect(sink.text()).toBe('onetwo') + expect(sink.truncated()).toBe(false) + }) + + it('decodes UTF-8 across every chunk boundary and byte limit', () => { + const bytes = Buffer.from('a💻é\r\nb') + for (let split = 0; split <= bytes.length; split += 1) { + for (let cap = 0; cap <= bytes.length + 1; cap += 1) { + const sink = createOutputSink(cap) + sink.write(bytes.subarray(0, split)) + sink.write(bytes.subarray(split)) + expect(sink.text()).toBe(bytes.subarray(0, cap).toString('utf8')) + expect(sink.truncated()).toBe(bytes.length > cap) + } + } + }) + + it('clips a single string chunk at the byte limit', () => { + const sink = createOutputSink(3) + sink.write('a💻') + expect(sink.text()).toBe('a�') + expect(sink.truncated()).toBe(true) + }) +}) diff --git a/src/shared/child-process/bounded-output-sink.ts b/src/shared/child-process/bounded-output-sink.ts index 195e466fdf6..8e1a9309978 100644 --- a/src/shared/child-process/bounded-output-sink.ts +++ b/src/shared/child-process/bounded-output-sink.ts @@ -25,7 +25,10 @@ export function createOutputSink(maxBytes: number): { chunks.push(chunk.length > remaining ? chunk.subarray(0, remaining) : chunk) bytes += chunk.length }, - text: () => Buffer.concat(chunks).toString('utf8'), + text: () => + chunks.length === 0 + ? '' + : (chunks.length === 1 ? chunks[0] : Buffer.concat(chunks)).toString('utf8'), // Why: callers that parse the output need to tell a short answer from a // clipped one -- truncated JSON or JSONL parses as a smaller valid result. truncated: () => bytes > maxBytes