diff --git a/src/shared/stdio.ts b/src/shared/stdio.ts index fe14612bd..0a59ea968 100644 --- a/src/shared/stdio.ts +++ b/src/shared/stdio.ts @@ -4,36 +4,62 @@ import { JSONRPCMessage, JSONRPCMessageSchema } from '../types.js'; * Buffers a continuous stdio stream into discrete JSON-RPC messages. */ export class ReadBuffer { - private _buffer?: Buffer; + private _validLines: string[] = []; + private _lastIncompleteLine: string = ''; append(chunk: Buffer): void { - this._buffer = this._buffer ? Buffer.concat([this._buffer, chunk]) : chunk; + this._processChunk(chunk); } readMessage(): JSONRPCMessage | null { - if (!this._buffer) { + if (this._validLines.length === 0) { return null; } - - const index = this._buffer.indexOf('\n'); - if (index === -1) { - return null; - } - - const line = this._buffer.toString('utf8', 0, index).replace(/\r$/, ''); - this._buffer = this._buffer.subarray(index + 1); + const line = this._validLines.shift()!; return deserializeMessage(line); } clear(): void { - this._buffer = undefined; + this._validLines = []; + this._lastIncompleteLine = ''; + } + + private _processChunk(newChunk: Buffer): void { + // Combine any previously incomplete line with the new chunk + const combinedText = this._lastIncompleteLine + newChunk.toString('utf8'); + const newLines = combinedText.split('\n'); + + // The last element may be incomplete, so store it for the next chunk + this._lastIncompleteLine = newLines.pop() ?? ''; + const completedLines = newLines.filter(looksLikeJson); + this._validLines.push(...completedLines); } } -export function deserializeMessage(line: string): JSONRPCMessage { +/** + * Checks if a line looks like a JSON object. + * @param line The line to check. + * @returns True if the line looks like a JSON object, false otherwise. + */ +function looksLikeJson(line: string): boolean { + const trimmed = line.trim(); + return trimmed.startsWith('{') && trimmed.endsWith('}'); +} + +/** + * Deserializes a JSON-RPC message from a string. + * @param line The string to deserialize. + * @returns The deserialized JSON-RPC message. + */ +export function deserializeMessage(line: string): JSONRPCMessage | null { return JSONRPCMessageSchema.parse(JSON.parse(line)); } +/** + * Serializes a JSON-RPC message to a string. + * @param message The JSON-RPC message to serialize. + * @returns The serialized JSON-RPC message string. + */ export function serializeMessage(message: JSONRPCMessage): string { return JSON.stringify(message) + '\n'; } diff --git a/test/shared/stdio.test.ts b/test/shared/stdio.test.ts index e8cbb5245..376283cba 100644 --- a/test/shared/stdio.test.ts +++ b/test/shared/stdio.test.ts @@ -1,4 +1,4 @@ -import { JSONRPCMessage } from '../../src/types.js'; +import type { JSONRPCMessage } from '../../src/types.js'; import { ReadBuffer } from '../../src/shared/stdio.js'; const testMessage: JSONRPCMessage = { @@ -33,3 +33,91 @@ test('should be reusable after clearing', () => { readBuffer.append(Buffer.from('\n')); expect(readBuffer.readMessage()).toEqual(testMessage); }); + +describe('non-JSON line filtering', () => { + test('should filter out non-JSON lines before a complete message', () => { + const readBuffer = new ReadBuffer(); + + // Append debug output followed by a valid JSON message + const mixedContent = 'Debug: Starting server\n' + 'Warning: Something happened\n' + JSON.stringify(testMessage) + '\n'; + + readBuffer.append(Buffer.from(mixedContent)); + + // Should only get the valid JSON message, debug lines filtered out + expect(readBuffer.readMessage()).toEqual(testMessage); + expect(readBuffer.readMessage()).toBeNull(); + }); + + test('should filter out non-JSON lines mixed with multiple valid messages', () => { + const readBuffer = new ReadBuffer(); + + const message1: JSONRPCMessage = { jsonrpc: '2.0', method: 'method1' }; + const message2: JSONRPCMessage = { jsonrpc: '2.0', method: 'method2' }; + + const mixedContent = + 'Debug line 1\n' + + JSON.stringify(message1) + + '\n' + + 'Debug line 2\n' + + 'Another non-JSON line\n' + + JSON.stringify(message2) + + '\n'; + + readBuffer.append(Buffer.from(mixedContent)); + + expect(readBuffer.readMessage()).toEqual(message1); + expect(readBuffer.readMessage()).toEqual(message2); + expect(readBuffer.readMessage()).toBeNull(); + }); + + test('should preserve incomplete JSON line at end of buffer', () => { + const readBuffer = new ReadBuffer(); + + // Append incomplete JSON (no closing brace or newline) + const incompleteJson = '{"jsonrpc": "2.0", "method": "test"'; + readBuffer.append(Buffer.from(incompleteJson)); + + expect(readBuffer.readMessage()).toBeNull(); + + // Complete the JSON in next chunk + readBuffer.append(Buffer.from('}\n')); + + const expectedMessage: JSONRPCMessage = { jsonrpc: '2.0', method: 'test' }; + expect(readBuffer.readMessage()).toEqual(expectedMessage); + }); + + test('should handle lines that start with { but do not end with }', () => { + const readBuffer = new ReadBuffer(); + + const content = '{incomplete\n' + JSON.stringify(testMessage) + '\n'; + + readBuffer.append(Buffer.from(content)); + + // Should only get the valid message, incomplete line filtered out + expect(readBuffer.readMessage()).toEqual(testMessage); + expect(readBuffer.readMessage()).toBeNull(); + }); + + test('should handle lines that end with } but do not start with {', () => { + const readBuffer = new ReadBuffer(); + + const content = 'incomplete}\n' + JSON.stringify(testMessage) + '\n'; + + readBuffer.append(Buffer.from(content)); + + // Should only get the valid message, incomplete line filtered out + expect(readBuffer.readMessage()).toEqual(testMessage); + expect(readBuffer.readMessage()).toBeNull(); + }); + + test('should handle lines with leading/trailing whitespace around valid JSON', () => { + const readBuffer = new ReadBuffer(); + + const message: JSONRPCMessage = { jsonrpc: '2.0', method: 'test' }; + const content = ' ' + JSON.stringify(message) + ' \n'; + + readBuffer.append(Buffer.from(content)); + + expect(readBuffer.readMessage()).toEqual(message); + }); +});