diff --git a/packages/core/src/config/config.ts b/packages/core/src/config/config.ts index f7c8f7c7318..d14854aa4c4 100644 --- a/packages/core/src/config/config.ts +++ b/packages/core/src/config/config.ts @@ -1297,6 +1297,8 @@ export class Config { terminalHeight: params.shellExecutionConfig?.terminalHeight ?? 24, showColor: params.shellExecutionConfig?.showColor ?? false, pager: params.shellExecutionConfig?.pager ?? 'cat', + maxBufferedOutputBytes: + params.shellExecutionConfig?.maxBufferedOutputBytes, }; this.truncateToolOutputThreshold = params.truncateToolOutputThreshold ?? @@ -3637,6 +3639,9 @@ export class Config { config.terminalHeight ?? this.shellExecutionConfig.terminalHeight, showColor: config.showColor ?? this.shellExecutionConfig.showColor, pager: config.pager ?? this.shellExecutionConfig.pager, + maxBufferedOutputBytes: + config.maxBufferedOutputBytes ?? + this.shellExecutionConfig.maxBufferedOutputBytes, }; } getScreenReader(): boolean { diff --git a/packages/core/src/services/shellExecutionService.test.ts b/packages/core/src/services/shellExecutionService.test.ts index a8dc34ed85e..311da8bf399 100644 --- a/packages/core/src/services/shellExecutionService.test.ts +++ b/packages/core/src/services/shellExecutionService.test.ts @@ -19,6 +19,7 @@ import { type ChildProcess } from 'node:child_process'; import pkg from '@xterm/headless'; import type { ShellAbortReason, + ShellExecutionConfig, ShellExecuteOptions, ShellOutputEvent, ShellPostPromoteSettleInfo, @@ -122,7 +123,7 @@ const shellExecutionConfig = { pager: 'cat', showColor: false, disableDynamicLineTrimming: true, -}; +} satisfies ShellExecutionConfig; const WINDOWS_SYSTEM_PATH = 'C:\\Windows\\System32;C:\\Shared\\Tools'; const WINDOWS_USER_PATH = 'C:\\Users\\tester\\bin;C:\\Shared\\Tools'; @@ -267,7 +268,7 @@ describe('ShellExecutionService', () => { ptyProcess: typeof mockPtyProcess, ac: AbortController, ) => void, - config = shellExecutionConfig, + config: ShellExecutionConfig = shellExecutionConfig, options: ShellExecuteOptions = {}, ) => { const abortController = new AbortController(); @@ -337,6 +338,65 @@ describe('ShellExecutionService', () => { expect(result.output.trim()).toBe('你好'); }); + it('bounds buffered PTY output before building the final string', async () => { + const { result } = await simulateExecution( + 'large-output', + (pty) => { + pty.onData.mock.calls[0][0]('12345678'); + pty.onData.mock.calls[0][0]('abcdefg'); + pty.onExit.mock.calls[0][0]({ exitCode: 0, signal: null }); + }, + { ...shellExecutionConfig, maxBufferedOutputBytes: 10 }, + ); + + expect(result.rawOutput.length).toBe(10); + expect(result.output).toContain('12345678ab'); + expect(result.output).toContain( + 'Output exceeded the maximum captured size', + ); + expect(result.output).not.toContain('cdefg'); + }); + + it('keeps PTY replay fallback bounded after the capture limit is exceeded', async () => { + mockSerializeTerminalToText.mockImplementationOnce(() => { + throw new Error('replay failed'); + }); + + const { result } = await simulateExecution( + 'large-output-replay-fallback', + (pty) => { + pty.onData.mock.calls[0][0]('12345678'); + pty.onData.mock.calls[0][0]('abcdefg'); + pty.onExit.mock.calls[0][0]({ exitCode: 0, signal: null }); + }, + { ...shellExecutionConfig, maxBufferedOutputBytes: 10 }, + ); + + expect(result.rawOutput.toString()).toBe('12345678ab'); + expect(result.output).toContain('12345678ab'); + expect(result.output).toContain( + 'Output exceeded the maximum captured size', + ); + expect(result.output).not.toContain('cdefg'); + }); + + it('does not add a capture-limit notice at the exact PTY buffer boundary', async () => { + const { result } = await simulateExecution( + 'exact-output', + (pty) => { + pty.onData.mock.calls[0][0]('1234567890'); + pty.onExit.mock.calls[0][0]({ exitCode: 0, signal: null }); + }, + { ...shellExecutionConfig, maxBufferedOutputBytes: 10 }, + ); + + expect(result.rawOutput.length).toBe(10); + expect(result.output).toBe('1234567890'); + expect(result.output).not.toContain( + 'Output exceeded the maximum captured size', + ); + }); + it('should handle commands with no output', async () => { await simulateExecution('touch file', (pty) => { pty.onExit.mock.calls[0][0]({ exitCode: 0, signal: null }); @@ -1510,6 +1570,29 @@ describe('ShellExecutionService child_process fallback', () => { return { result, handle, abortController }; }; + const simulateExecutionWithConfig = async ( + command: string, + simulation: (cp: typeof mockChildProcess, ac: AbortController) => void, + config: ShellExecutionConfig, + options: ShellExecuteOptions = {}, + ) => { + const abortController = new AbortController(); + const handle = await ShellExecutionService.execute( + command, + '/test/dir', + onOutputEventMock, + abortController.signal, + true, + config, + options, + ); + + await new Promise((resolve) => process.nextTick(resolve)); + simulation(mockChildProcess, abortController); + const result = await handle.result; + return { result, handle, abortController }; + }; + describe('Successful Execution', () => { it('should execute a command and capture stdout and stderr', async () => { const { result, handle } = await simulateExecution('ls -l', (cp) => { @@ -1566,6 +1649,128 @@ describe('ShellExecutionService child_process fallback', () => { expect(result.output.trim()).toBe('你好'); }); + it('bounds buffered child_process output before building the final string', async () => { + const abortController = new AbortController(); + const handle = await ShellExecutionService.execute( + 'large-output', + '/test/dir', + onOutputEventMock, + abortController.signal, + false, + { ...shellExecutionConfig, maxBufferedOutputBytes: 10 }, + ); + + await new Promise((resolve) => process.nextTick(resolve)); + mockChildProcess.stdout?.emit('data', Buffer.from('12345678')); + mockChildProcess.stdout?.emit('data', Buffer.from('abcdefg')); + mockChildProcess.emit('exit', 0, null); + mockChildProcess.emit('close', 0, null); + + const result = await handle.result; + + expect(result.rawOutput.length).toBe(10); + expect(result.output).toContain('12345678ab'); + expect(result.output).toContain( + 'Output exceeded the maximum captured size', + ); + expect(result.output).not.toContain('cdefg'); + expect(onOutputEventMock).toHaveBeenCalledWith({ + type: 'data', + chunk: expect.stringContaining( + 'Output exceeded the maximum captured size', + ), + }); + }); + + it('does not add a capture-limit notice at the exact child_process buffer boundary', async () => { + const { result } = await simulateExecutionWithConfig( + 'exact-output', + (cp) => { + cp.stdout?.emit('data', Buffer.from('1234567890')); + cp.emit('exit', 0, null); + cp.emit('close', 0, null); + }, + { ...shellExecutionConfig, maxBufferedOutputBytes: 10 }, + ); + + expect(result.rawOutput.length).toBe(10); + expect(result.output).toBe('1234567890'); + expect(result.output).not.toContain( + 'Output exceeded the maximum captured size', + ); + }); + + it.each([ + 0, + 0.5, + -1, + Number.NaN, + Number.POSITIVE_INFINITY, + 'abc', + undefined, + ])( + 'falls back to the default capture limit for invalid maxBufferedOutputBytes: %s', + async (configuredValue) => { + const { result } = await simulateExecutionWithConfig( + 'invalid-limit', + (cp) => { + cp.stdout?.emit('data', Buffer.from('1234567890abcde')); + cp.emit('exit', 0, null); + cp.emit('close', 0, null); + }, + { + ...shellExecutionConfig, + maxBufferedOutputBytes: configuredValue as unknown as number, + }, + ); + + expect(result.rawOutput.length).toBe(15); + expect(result.output).toBe('1234567890abcde'); + expect(result.output).not.toContain( + 'Output exceeded the maximum captured size', + ); + }, + ); + + it('reports capture-limit notice for streaming child_process output', async () => { + const { result } = await simulateExecutionWithConfig( + 'streaming-large-output', + (cp) => { + cp.stdout?.emit('data', Buffer.from('abcdef')); + cp.emit('exit', 0, null); + cp.emit('close', 0, null); + }, + { ...shellExecutionConfig, maxBufferedOutputBytes: 1 }, + { streamStdout: true }, + ); + + expect(onOutputEventMock).toHaveBeenCalledWith({ + type: 'data', + chunk: 'abcdef', + }); + expect(result.rawOutput.length).toBe(1); + expect(result.output).toContain( + 'Output exceeded the maximum captured size', + ); + }); + + it('emits only the capture-limit notice when stripped captured output is empty', async () => { + const { result } = await simulateExecutionWithConfig( + 'empty-captured-output', + (cp) => { + cp.stdout?.emit('data', Buffer.from('\nabc')); + cp.emit('exit', 0, null); + cp.emit('close', 0, null); + }, + { ...shellExecutionConfig, maxBufferedOutputBytes: 1 }, + ); + + expect(result.rawOutput.length).toBe(1); + expect(result.output).toMatch( + /^\[Output exceeded the maximum captured size/, + ); + }); + it('should handle commands with no output', async () => { const { result } = await simulateExecution('touch file', (cp) => { cp.emit('exit', 0, null); diff --git a/packages/core/src/services/shellExecutionService.ts b/packages/core/src/services/shellExecutionService.ts index d6be9185dc1..4faefd308da 100644 --- a/packages/core/src/services/shellExecutionService.ts +++ b/packages/core/src/services/shellExecutionService.ts @@ -21,12 +21,15 @@ import { type AnsiOutput, } from '../utils/terminalSerializer.js'; import { normalizePathEnvForWindows } from '../utils/windowsPath.js'; +import { formatMemoryUsage } from '../utils/formatters.js'; import { getShellContextEnvVars } from '../utils/shellContextEnv.js'; import { createDebugLogger } from '../utils/debugLogger.js'; const { Terminal } = pkg; const debugLogger = createDebugLogger('SHELL_EXECUTION'); +const DEFAULT_MAX_BUFFERED_OUTPUT_BYTES = 64 * 1024 * 1024; +const MAX_BUFFERED_OUTPUT_BYTES_CEILING = 256 * 1024 * 1024; const SIGKILL_TIMEOUT_MS = 200; /** * Bound on how long the background-promote drain waits for in-flight @@ -124,7 +127,12 @@ export type ShellAbortReason = /** A structured result from a shell command execution. */ export interface ShellExecutionResult { - /** The raw, unprocessed output buffer. */ + /** + * Buffered raw output captured for callers that need bytes instead of the + * decoded display string. This buffer is bounded by maxBufferedOutputBytes, + * so it may contain only the retained prefix when the capture limit is + * exceeded. + */ rawOutput: Buffer; /** The combined, decoded output as a string. */ output: string; @@ -174,10 +182,53 @@ export interface ShellExecutionConfig { showColor?: boolean; defaultFg?: string; defaultBg?: string; + /** + * Upper bound for foreground output retained in memory for the final + * ShellExecutionResult. The process stream is still drained after this + * limit, but additional bytes are discarded instead of decoded into one + * giant JavaScript string. + */ + maxBufferedOutputBytes?: number; // Used for testing disableDynamicLineTrimming?: boolean; } +function getMaxBufferedOutputBytes(config: ShellExecutionConfig): number { + const configured = config.maxBufferedOutputBytes; + const floored = + typeof configured === 'number' && Number.isFinite(configured) + ? Math.floor(configured) + : 0; + return floored > 0 + ? Math.min(floored, MAX_BUFFERED_OUTPUT_BYTES_CEILING) + : DEFAULT_MAX_BUFFERED_OUTPUT_BYTES; +} + +function decodeBufferedOutput(finalBuffer: Buffer): string { + const fallbackEncoding = getCachedEncodingForBuffer(finalBuffer); + return new TextDecoder(fallbackEncoding).decode(finalBuffer); +} + +function appendOutputCaptureLimitNotice( + output: string, + didExceedLimit: boolean, + totalBytesReceived: number, + maxBufferedOutputBytes: number, +): string { + if (!didExceedLimit) { + return output; + } + + const notice = + `[Output exceeded the maximum captured size of ` + + `${formatMemoryUsage(maxBufferedOutputBytes)}; captured the first ` + + `${formatMemoryUsage(maxBufferedOutputBytes)} of ` + + `${formatMemoryUsage(totalBytesReceived)} and discarded the rest to ` + + `avoid constructing an oversized JavaScript string.]`; + + return output ? `${output}\n\n${notice}` : notice; +} + /** * Optional caller-side handlers for the *post-promote* lifetime of a * background-promoted child process. PR-2 (#3894) detached every @@ -515,6 +566,7 @@ export class ShellExecutionService { onOutputEvent, abortSignal, options.streamStdout ?? false, + getMaxBufferedOutputBytes(shellExecutionConfig), options.postPromote, ); } @@ -525,6 +577,7 @@ export class ShellExecutionService { onOutputEvent: (event: ShellOutputEvent) => void, abortSignal: AbortSignal, streamStdout: boolean, + maxBufferedOutputBytes: number, postPromote?: ShellPostPromoteHandlers, ): ShellExecutionHandle { try { @@ -562,12 +615,54 @@ export class ShellExecutionService { let stdout = ''; let stderr = ''; const outputChunks: Buffer[] = []; + const sniffChunks: Buffer[] = []; let error: Error | null = null; let exited = false; let isStreamingRawContent = true; const MAX_SNIFF_SIZE = 4096; let sniffedBytes = 0; + let capturedOutputBytes = 0; + let totalOutputBytes = 0; + let outputCaptureLimitExceeded = false; + let outputCaptureLimitWarningEmitted = false; + + const markOutputCaptureLimitExceeded = () => { + outputCaptureLimitExceeded = true; + if (outputCaptureLimitWarningEmitted) { + return; + } + outputCaptureLimitWarningEmitted = true; + debugLogger.warn( + `Shell output capture exceeded maxBufferedOutputBytes ` + + `(${maxBufferedOutputBytes} bytes). Total bytes: ` + + `${totalOutputBytes}. Discarding excess.`, + ); + }; + + const captureOutputData = (data: Buffer): Buffer | null => { + if (capturedOutputBytes >= maxBufferedOutputBytes) { + markOutputCaptureLimitExceeded(); + return null; + } + + const remainingBytes = maxBufferedOutputBytes - capturedOutputBytes; + const captured = + data.length > remainingBytes + ? data.subarray(0, remainingBytes) + : data; + + if (captured.length > 0) { + outputChunks.push(captured); + capturedOutputBytes += captured.length; + } + + if (captured.length < data.length) { + markOutputCaptureLimitExceeded(); + } + + return captured.length > 0 ? captured : null; + }; const handleOutput = (data: Buffer, stream: 'stdout' | 'stderr') => { if (!stdoutDecoder || !stderrDecoder) { @@ -590,29 +685,34 @@ export class ShellExecutionService { // past the first 20 chunks' total and let the chunk array leak on // line-sized streams. if (isStreamingRawContent && sniffedBytes < MAX_SNIFF_SIZE) { - outputChunks.push(data); - sniffedBytes += data.length; - const sniffBuffer = Buffer.concat(outputChunks); + const sniffSlice = data.subarray( + 0, + Math.min(data.length, MAX_SNIFF_SIZE - sniffedBytes), + ); + if (sniffSlice.length > 0) { + sniffChunks.push(sniffSlice); + sniffedBytes += sniffSlice.length; + } + const sniffBuffer = Buffer.concat(sniffChunks); if (isBinary(sniffBuffer)) { isStreamingRawContent = false; if (streamStdout) { // Tell the streaming consumer to stop writing text chunks; // drop the sniff accumulator now so it can be GC'd. onOutputEvent({ type: 'binary_detected' }); - outputChunks.length = 0; + sniffChunks.length = 0; } } else if (streamStdout && sniffedBytes >= MAX_SNIFF_SIZE) { // Sniff passed in streaming mode — text confirmed, drop the // accumulator. Subsequent chunks fall through to the streaming // emit path below without ever touching outputChunks. - outputChunks.length = 0; + sniffChunks.length = 0; } - } else if (!streamStdout) { - // Buffered (foreground) mode past sniff: keep accumulating for - // the final emit at exit. Streaming mode does not accumulate. - outputChunks.push(data); } + totalOutputBytes += data.length; + const capturedData = captureOutputData(data); + if (!isStreamingRawContent) { // Binary mode: drop further data. Foreground emits the // binary_detected event from handleExit (existing behavior); @@ -621,16 +721,21 @@ export class ShellExecutionService { } const decoder = stream === 'stdout' ? stdoutDecoder : stderrDecoder; - const decodedChunk = decoder.decode(data, { stream: true }); - if (streamStdout) { // Streaming text mode: push through immediately, no string // accumulation. (Up to ~4KB may already have been emitted // before binary detection trips — bounded, acceptable.) + const decodedChunk = decoder.decode(data, { stream: true }); onOutputEvent({ type: 'data', chunk: decodedChunk }); return; } + if (!capturedData) { + return; + } + + const decodedChunk = decoder.decode(capturedData, { stream: true }); + // Buffered text mode: accumulate for the final cleaned-blob emit. if (stream === 'stdout') { stdout += decodedChunk; @@ -650,12 +755,18 @@ export class ShellExecutionService { stdout + (stderr ? (stdout ? separator : '') + stderr : ''); const finalStrippedOutput = stripAnsi(combinedOutput).trim(); + const boundedOutput = appendOutputCaptureLimitNotice( + finalStrippedOutput, + outputCaptureLimitExceeded, + totalOutputBytes, + maxBufferedOutputBytes, + ); if (isStreamingRawContent) { // In streaming mode chunks were already emitted as they arrived; // re-emitting the final blob would duplicate everything. - if (!streamStdout && finalStrippedOutput) { - onOutputEvent({ type: 'data', chunk: finalStrippedOutput }); + if (!streamStdout && boundedOutput) { + onOutputEvent({ type: 'data', chunk: boundedOutput }); } } else { onOutputEvent({ type: 'binary_detected' }); @@ -663,7 +774,7 @@ export class ShellExecutionService { resolve({ rawOutput: finalBuffer, - output: finalStrippedOutput, + output: boundedOutput, exitCode: code, signal: signal ? os.constants.signals[signal] : null, error, @@ -764,6 +875,12 @@ export class ShellExecutionService { const combined = snapStdout + (snapStderr ? (snapStdout ? separator : '') + snapStderr : ''); + const boundedOutput = appendOutputCaptureLimitNotice( + stripAnsi(combined).trim(), + outputCaptureLimitExceeded, + totalOutputBytes, + maxBufferedOutputBytes, + ); // PR-2.5: re-attach post-promote listeners that forward to the // caller's handlers. Attach AFTER `detachServiceListeners()` // so we don't double-up on stdout/stderr 'data' events with @@ -989,7 +1106,7 @@ export class ShellExecutionService { } resolve({ rawOutput: finalBuffer, - output: stripAnsi(combined).trim(), + output: boundedOutput, exitCode: null, signal: null, error: null, @@ -1173,6 +1290,7 @@ export class ShellExecutionService { let decoder: TextDecoder | null = null; let outputComparison: AnsiOutput | null = null; const outputChunks: Buffer[] = []; + const sniffChunks: Buffer[] = []; const error: Error | null = null; let exited = false; @@ -1180,6 +1298,11 @@ export class ShellExecutionService { const MAX_SNIFF_SIZE = 4096; let sniffedBytes = 0; let totalBytesReceived = 0; + const maxBufferedOutputBytes = + getMaxBufferedOutputBytes(shellExecutionConfig); + let capturedOutputBytes = 0; + let outputCaptureLimitExceeded = false; + let outputCaptureLimitWarningEmitted = false; let isWriting = false; let hasStartedOutput = false; let renderTimeout: NodeJS.Timeout | null = null; @@ -1193,6 +1316,19 @@ export class ShellExecutionService { const RENDER_THROTTLE_MS = 100; + const markOutputCaptureLimitExceeded = () => { + outputCaptureLimitExceeded = true; + if (outputCaptureLimitWarningEmitted) { + return; + } + outputCaptureLimitWarningEmitted = true; + debugLogger.warn( + `Shell output capture exceeded maxBufferedOutputBytes ` + + `(${maxBufferedOutputBytes} bytes). Total bytes: ` + + `${totalBytesReceived}. Discarding excess.`, + ); + }; + const renderFn = () => { if (!isStreamingRawContent || listenersDetached) { return; @@ -1297,21 +1433,50 @@ export class ShellExecutionService { } }; + const captureOutputData = (data: Buffer): void => { + if (capturedOutputBytes >= maxBufferedOutputBytes) { + markOutputCaptureLimitExceeded(); + return; + } + + const remainingBytes = maxBufferedOutputBytes - capturedOutputBytes; + const captured = + data.length > remainingBytes + ? data.subarray(0, remainingBytes) + : data; + + if (captured.length > 0) { + outputChunks.push(captured); + capturedOutputBytes += captured.length; + } + + if (captured.length < data.length) { + markOutputCaptureLimitExceeded(); + } + }; + const handleOutput = (data: Buffer) => { // Capture raw output immediately. Rendering the headless terminal is // slower than appending a Buffer, and rapid PTY output can otherwise // overrun the render queue before finalize() races on exit. ensureDecoder(data); - outputChunks.push(data); totalBytesReceived += data.length; + captureOutputData(data); const bytesReceived = totalBytesReceived; processingChain = processingChain.then( () => new Promise((resolve) => { if (isStreamingRawContent && sniffedBytes < MAX_SNIFF_SIZE) { - const sniffBuffer = Buffer.concat(outputChunks.slice(0, 20)); - sniffedBytes = sniffBuffer.length; + const sniffSlice = data.subarray( + 0, + Math.min(data.length, MAX_SNIFF_SIZE - sniffedBytes), + ); + if (sniffSlice.length > 0) { + sniffChunks.push(sniffSlice); + sniffedBytes += sniffSlice.length; + } + const sniffBuffer = Buffer.concat(sniffChunks); if (isBinary(sniffBuffer)) { isStreamingRawContent = false; @@ -1387,7 +1552,7 @@ export class ShellExecutionService { try { if (isStreamingRawContent) { - // Re-decode the full buffer with proper encoding detection. + // Re-decode the captured buffer with proper encoding detection. // The streaming decoder used the first-chunk heuristic which // can misdetect when early output is ASCII-only but later // output is in a different encoding (e.g. GBK). @@ -1405,11 +1570,17 @@ export class ShellExecutionService { } } catch { try { - fullOutput = serializeTerminalToText(headlessTerminal); + fullOutput = decodeBufferedOutput(finalBuffer); } catch { // Ignore fallback rendering errors and resolve with empty text. } } + fullOutput = appendOutputCaptureLimitNotice( + fullOutput, + outputCaptureLimitExceeded, + totalBytesReceived, + maxBufferedOutputBytes, + ); resolve({ rawOutput: finalBuffer, @@ -1703,8 +1874,8 @@ export class ShellExecutionService { `Background-promote drain hit the ${PROMOTE_DRAIN_TIMEOUT_MS}ms ` + `timeout before processingChain settled. The output snapshot ` + `may be missing the very last batch of bytes the PTY emitted ` + - `before promote (rawOutput in the result still has the full ` + - `buffer the caller can re-render).`, + `before promote (rawOutput in the result still has the ` + + `bounded captured buffer the caller can re-render).`, ); } @@ -1712,7 +1883,7 @@ export class ShellExecutionService { let snapshot = ''; try { // Mirror the normal exit path's snapshot logic: re-decode - // the full buffer with the final encoding (the streaming + // the captured buffer with the final encoding (the streaming // decoder fed `headlessTerminal` from a first-chunk // heuristic, which can mis-detect when early output is // ASCII-only but later output is in a different encoding, @@ -1736,18 +1907,24 @@ export class ShellExecutionService { // is acceptable since the caller has rawOutput, but log so // the failure leaves a diagnostic trail (otherwise an empty // `output` is indistinguishable from "command produced no - // output"). Try the simpler direct-serialize path as a - // last-ditch fallback before giving up. + // output"). Fall back to the bounded captured buffer so the + // snapshot cannot exceed maxBufferedOutputBytes. debugLogger.warn( `Background-promote snapshot replay failed: ${serErr instanceof Error ? serErr.message : String(serErr)}. ` + - `Falling back to direct headlessTerminal serialize; if that also fails, output stays empty.`, + `Falling back to bounded raw buffer decode; if that also fails, output stays empty.`, ); try { - snapshot = serializeTerminalToText(headlessTerminal) ?? ''; + snapshot = decodeBufferedOutput(finalBuffer); } catch { // Both paths failed — leave snapshot empty. } } + snapshot = appendOutputCaptureLimitNotice( + snapshot, + outputCaptureLimitExceeded, + totalBytesReceived, + maxBufferedOutputBytes, + ); resolve({ rawOutput: finalBuffer, output: snapshot,