diff --git a/src/shared/stdio.test.ts b/src/shared/stdio.test.ts index e41c938b6..21242f454 100644 --- a/src/shared/stdio.test.ts +++ b/src/shared/stdio.test.ts @@ -1,4 +1,4 @@ -import { JSONRPCMessage } from '../types.js'; +import type { JSONRPCMessage } from '../types.js'; import { ReadBuffer } from './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); + }); +}); diff --git a/src/shared/stdio.ts b/src/shared/stdio.ts index fe14612bd..45ee61bd3 100644 --- a/src/shared/stdio.ts +++ b/src/shared/stdio.ts @@ -7,7 +7,7 @@ export class ReadBuffer { private _buffer?: Buffer; append(chunk: Buffer): void { - this._buffer = this._buffer ? Buffer.concat([this._buffer, chunk]) : chunk; + this._buffer = filterNonJsonLines(this._buffer ? Buffer.concat([this._buffer, chunk]) : chunk); } readMessage(): JSONRPCMessage | null { @@ -30,10 +30,47 @@ export class ReadBuffer { } } -export function deserializeMessage(line: string): JSONRPCMessage { +/** + * Filters out any lines that are not valid JSON objects from the given buffer. + * Retains the last line in case it is incomplete. + * @param buffer The buffer to filter. + * @returns A new buffer containing only valid JSON object lines and the last line. + */ +function filterNonJsonLines(buffer: Buffer): Buffer { + const text = buffer.toString('utf8'); + const lines = text.split('\n'); + + // Pop the last line - it may be incomplete (no trailing newline yet) + const incompleteLine = lines.pop() ?? ''; + + // Filter complete lines to only keep those that look like JSON objects + const validLines = lines.filter(looksLikeJson); + + // Reconstruct: valid JSON lines + incomplete line + const filteredText = validLines.length > 0 ? validLines.join('\n') + '\n' + incompleteLine : incompleteLine; + + return Buffer.from(filteredText, 'utf8'); +} + +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'; }