From c5226d88879232f1f504f93fcc297b9ba64ebcab Mon Sep 17 00:00:00 2001 From: Sahar Shemesh Date: Wed, 3 Dec 2025 16:10:15 +0200 Subject: [PATCH 1/7] fix: improve error handling and add guard check to `deserializeMessage` --- src/shared/stdio.ts | 13 +++++++++++-- test/shared/stdio.test.ts | 7 +++++++ 2 files changed, 18 insertions(+), 2 deletions(-) diff --git a/src/shared/stdio.ts b/src/shared/stdio.ts index fe14612bd..9c7858bbf 100644 --- a/src/shared/stdio.ts +++ b/src/shared/stdio.ts @@ -1,3 +1,4 @@ +import { ZodError } from 'zod'; import { JSONRPCMessage, JSONRPCMessageSchema } from '../types.js'; /** @@ -30,8 +31,16 @@ export class ReadBuffer { } } -export function deserializeMessage(line: string): JSONRPCMessage { - return JSONRPCMessageSchema.parse(JSON.parse(line)); +export function deserializeMessage(line: string): JSONRPCMessage | null { + try { + return JSONRPCMessageSchema.parse(JSON.parse(line)); + } catch (error: unknown) { + // When Non JSONRPC message is received (parsing error or schema validation error), return null + if (error instanceof ZodError || error instanceof SyntaxError) { + return null; + } + throw error; + } } export function serializeMessage(message: JSONRPCMessage): string { diff --git a/test/shared/stdio.test.ts b/test/shared/stdio.test.ts index e8cbb5245..01120084e 100644 --- a/test/shared/stdio.test.ts +++ b/test/shared/stdio.test.ts @@ -33,3 +33,10 @@ test('should be reusable after clearing', () => { readBuffer.append(Buffer.from('\n')); expect(readBuffer.readMessage()).toEqual(testMessage); }); + +test('should override invalid messages and return null', () => { + const readBuffer = new ReadBuffer(); + + readBuffer.append(Buffer.from('invalid message\n')); + expect(readBuffer.readMessage()).toBeNull(); +}); From b22b281b37e2657ea94ef1c938f131f7f85218aa Mon Sep 17 00:00:00 2001 From: Sahar Shemesh Date: Thu, 4 Dec 2025 22:43:42 +0200 Subject: [PATCH 2/7] fix: ignore invalid json messages only --- src/shared/stdio.ts | 5 ++--- test/shared/stdio.test.ts | 10 +++++++++- 2 files changed, 11 insertions(+), 4 deletions(-) diff --git a/src/shared/stdio.ts b/src/shared/stdio.ts index 9c7858bbf..63d647be3 100644 --- a/src/shared/stdio.ts +++ b/src/shared/stdio.ts @@ -1,4 +1,3 @@ -import { ZodError } from 'zod'; import { JSONRPCMessage, JSONRPCMessageSchema } from '../types.js'; /** @@ -35,8 +34,8 @@ export function deserializeMessage(line: string): JSONRPCMessage | null { try { return JSONRPCMessageSchema.parse(JSON.parse(line)); } catch (error: unknown) { - // When Non JSONRPC message is received (parsing error or schema validation error), return null - if (error instanceof ZodError || error instanceof SyntaxError) { + // When non-JSON messages are received, we simply ignore them. + if (error instanceof SyntaxError) { return null; } throw error; diff --git a/test/shared/stdio.test.ts b/test/shared/stdio.test.ts index 01120084e..b1a234982 100644 --- a/test/shared/stdio.test.ts +++ b/test/shared/stdio.test.ts @@ -1,5 +1,6 @@ import { JSONRPCMessage } from '../../src/types.js'; import { ReadBuffer } from '../../src/shared/stdio.js'; +import { ZodError } from 'zod/v4'; const testMessage: JSONRPCMessage = { jsonrpc: '2.0', @@ -34,9 +35,16 @@ test('should be reusable after clearing', () => { expect(readBuffer.readMessage()).toEqual(testMessage); }); -test('should override invalid messages and return null', () => { +test('should override invalid json message and return null', () => { const readBuffer = new ReadBuffer(); readBuffer.append(Buffer.from('invalid message\n')); expect(readBuffer.readMessage()).toBeNull(); }); + +test('should throw validation error on invalid JSON-RPC message', () => { + const readBuffer = new ReadBuffer(); + const invalidJsonRpcMessage = '{"jsonrpc":"2.0","method":123}\n'; + readBuffer.append(Buffer.from(invalidJsonRpcMessage)); + expect(() => readBuffer.readMessage()).toThrowError(ZodError); +}); From c8e0e1a5b49289ed61aa78869d61c6419a6969ee Mon Sep 17 00:00:00 2001 From: Sahar Shemesh Date: Fri, 5 Dec 2025 15:46:06 +0200 Subject: [PATCH 3/7] refactor: filter non-json line while appending --- src/shared/stdio.ts | 49 +++++++++++++++---- test/shared/stdio.test.ts | 99 ++++++++++++++++++++++++++++++++++----- 2 files changed, 126 insertions(+), 22 deletions(-) diff --git a/src/shared/stdio.ts b/src/shared/stdio.ts index 63d647be3..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,18 +30,47 @@ export class ReadBuffer { } } +/** + * 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 { - try { - return JSONRPCMessageSchema.parse(JSON.parse(line)); - } catch (error: unknown) { - // When non-JSON messages are received, we simply ignore them. - if (error instanceof SyntaxError) { - return null; - } - throw error; - } + 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 b1a234982..c96ca12b1 100644 --- a/test/shared/stdio.test.ts +++ b/test/shared/stdio.test.ts @@ -1,6 +1,5 @@ -import { JSONRPCMessage } from '../../src/types.js'; +import type { JSONRPCMessage } from '../../src/types.js'; import { ReadBuffer } from '../../src/shared/stdio.js'; -import { ZodError } from 'zod/v4'; const testMessage: JSONRPCMessage = { jsonrpc: '2.0', @@ -35,16 +34,92 @@ test('should be reusable after clearing', () => { expect(readBuffer.readMessage()).toEqual(testMessage); }); -test('should override invalid json message and return null', () => { - const readBuffer = new ReadBuffer(); +describe('non-JSON line filtering', () => { + test('should filter out non-JSON lines before a complete message', () => { + const readBuffer = new ReadBuffer(); - readBuffer.append(Buffer.from('invalid message\n')); - expect(readBuffer.readMessage()).toBeNull(); -}); + // 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); + }); -test('should throw validation error on invalid JSON-RPC message', () => { - const readBuffer = new ReadBuffer(); - const invalidJsonRpcMessage = '{"jsonrpc":"2.0","method":123}\n'; - readBuffer.append(Buffer.from(invalidJsonRpcMessage)); - expect(() => readBuffer.readMessage()).toThrowError(ZodError); }); From 157bad05223a9922cf59e32f9be3d5d46cfb5d75 Mon Sep 17 00:00:00 2001 From: Sahar Shemesh Date: Fri, 5 Dec 2025 15:46:29 +0200 Subject: [PATCH 4/7] chore: lint --- test/shared/stdio.test.ts | 20 +++++++++----------- 1 file changed, 9 insertions(+), 11 deletions(-) diff --git a/test/shared/stdio.test.ts b/test/shared/stdio.test.ts index c96ca12b1..376283cba 100644 --- a/test/shared/stdio.test.ts +++ b/test/shared/stdio.test.ts @@ -39,9 +39,7 @@ describe('non-JSON line filtering', () => { 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'; + const mixedContent = 'Debug: Starting server\n' + 'Warning: Something happened\n' + JSON.stringify(testMessage) + '\n'; readBuffer.append(Buffer.from(mixedContent)); @@ -56,11 +54,14 @@ describe('non-JSON line filtering', () => { 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' + + const mixedContent = + 'Debug line 1\n' + + JSON.stringify(message1) + + '\n' + 'Debug line 2\n' + 'Another non-JSON line\n' + - JSON.stringify(message2) + '\n'; + JSON.stringify(message2) + + '\n'; readBuffer.append(Buffer.from(mixedContent)); @@ -88,8 +89,7 @@ describe('non-JSON line filtering', () => { test('should handle lines that start with { but do not end with }', () => { const readBuffer = new ReadBuffer(); - const content = '{incomplete\n' + - JSON.stringify(testMessage) + '\n'; + const content = '{incomplete\n' + JSON.stringify(testMessage) + '\n'; readBuffer.append(Buffer.from(content)); @@ -101,8 +101,7 @@ describe('non-JSON line filtering', () => { test('should handle lines that end with } but do not start with {', () => { const readBuffer = new ReadBuffer(); - const content = 'incomplete}\n' + - JSON.stringify(testMessage) + '\n'; + const content = 'incomplete}\n' + JSON.stringify(testMessage) + '\n'; readBuffer.append(Buffer.from(content)); @@ -121,5 +120,4 @@ describe('non-JSON line filtering', () => { expect(readBuffer.readMessage()).toEqual(message); }); - }); From 52a6c053b731d9c24f208deb23985f4972443f7b Mon Sep 17 00:00:00 2001 From: Sahar Shemesh Date: Sat, 6 Dec 2025 20:01:34 +0200 Subject: [PATCH 5/7] refactor: improve `ReadBuffer` internal structure & logic --- src/shared/stdio.ts | 53 ++++++++++++++++++--------------------------- 1 file changed, 21 insertions(+), 32 deletions(-) diff --git a/src/shared/stdio.ts b/src/shared/stdio.ts index 45ee61bd3..95efe9e3f 100644 --- a/src/shared/stdio.ts +++ b/src/shared/stdio.ts @@ -4,54 +4,43 @@ 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 = filterNonJsonLines(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); } } /** - * 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. + * 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 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('}'); From 003d4f7b6508bfc893c2628df270e6a3efedad67 Mon Sep 17 00:00:00 2001 From: Sahar Shemesh Date: Sat, 6 Dec 2025 20:03:47 +0200 Subject: [PATCH 6/7] chore: lint --- src/shared/stdio.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/shared/stdio.ts b/src/shared/stdio.ts index 95efe9e3f..0a59ea968 100644 --- a/src/shared/stdio.ts +++ b/src/shared/stdio.ts @@ -23,7 +23,7 @@ export class ReadBuffer { 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'); From 8f2a360355f4b787aae851c5407afc6929cc0c78 Mon Sep 17 00:00:00 2001 From: Sahar Shemesh Date: Tue, 9 Dec 2025 00:32:40 +0200 Subject: [PATCH 7/7] refactor: update ReadBuffer to store the line objects & improve filtering logic --- src/shared/stdio.ts | 27 +++++++++++++++------------ test/shared/stdio.test.ts | 11 +++++++++++ 2 files changed, 26 insertions(+), 12 deletions(-) diff --git a/src/shared/stdio.ts b/src/shared/stdio.ts index 0a59ea968..50224c9bc 100644 --- a/src/shared/stdio.ts +++ b/src/shared/stdio.ts @@ -4,7 +4,7 @@ import { JSONRPCMessage, JSONRPCMessageSchema } from '../types.js'; * Buffers a continuous stdio stream into discrete JSON-RPC messages. */ export class ReadBuffer { - private _validLines: string[] = []; + private _validLines: object[] = []; private _lastIncompleteLine: string = ''; append(chunk: Buffer): void { @@ -31,28 +31,31 @@ export class ReadBuffer { // The last element may be incomplete, so store it for the next chunk this._lastIncompleteLine = newLines.pop() ?? ''; - const completedLines = newLines.filter(looksLikeJson); + const completedLines = newLines.map(safeJsonParse).filter(Boolean) as object[]; this._validLines.push(...completedLines); } } /** - * 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. + * Safely parses a JSON string, returning false if parsing fails. + * @param line The JSON string to parse. + * @returns The parsed object, or false if parsing failed. */ -function looksLikeJson(line: string): boolean { - const trimmed = line.trim(); - return trimmed.startsWith('{') && trimmed.endsWith('}'); +function safeJsonParse(line: string): object | false { + try { + return JSON.parse(line); + } catch { + return false; + } } /** - * Deserializes a JSON-RPC message from a string. - * @param line The string to deserialize. + * Deserializes a JSON-RPC message from object. + * @param line The object to deserialize. * @returns The deserialized JSON-RPC message. */ -export function deserializeMessage(line: string): JSONRPCMessage | null { - return JSONRPCMessageSchema.parse(JSON.parse(line)); +export function deserializeMessage(line: object): JSONRPCMessage | null { + return JSONRPCMessageSchema.parse(line); } /** diff --git a/test/shared/stdio.test.ts b/test/shared/stdio.test.ts index 376283cba..336794b97 100644 --- a/test/shared/stdio.test.ts +++ b/test/shared/stdio.test.ts @@ -110,6 +110,17 @@ describe('non-JSON line filtering', () => { expect(readBuffer.readMessage()).toBeNull(); }); + test('should filter out lines which looks like JSON but are not valid JSON', () => { + const readBuffer = new ReadBuffer(); + + const content = '{invalidJson: true}\n' + JSON.stringify(testMessage) + '\n'; + readBuffer.append(Buffer.from(content)); + + // Should only get the valid message, invalid JSON 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();