Skip to content

Commit b8efeed

Browse files
committed
fix(stdio): preserve multi-byte UTF-8 across chunk boundaries
1 parent bf1e022 commit b8efeed

2 files changed

Lines changed: 59 additions & 7 deletions

File tree

src/shared/stdio.ts

Lines changed: 9 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -4,29 +4,31 @@ import { JSONRPCMessage, JSONRPCMessageSchema } from '../types.js';
44
* Buffers a continuous stdio stream into discrete JSON-RPC messages.
55
*/
66
export class ReadBuffer {
7-
private _buffer?: Buffer;
7+
private _decoder = new TextDecoder('utf-8');
8+
private _text = '';
89

910
append(chunk: Buffer): void {
10-
this._buffer = this._buffer ? Buffer.concat([this._buffer, chunk]) : chunk;
11+
this._text += this._decoder.decode(chunk, { stream: true });
1112
}
1213

1314
readMessage(): JSONRPCMessage | null {
14-
if (!this._buffer) {
15+
if (!this._text) {
1516
return null;
1617
}
1718

18-
const index = this._buffer.indexOf('\n');
19+
const index = this._text.indexOf('\n');
1920
if (index === -1) {
2021
return null;
2122
}
2223

23-
const line = this._buffer.toString('utf8', 0, index).replace(/\r$/, '');
24-
this._buffer = this._buffer.subarray(index + 1);
24+
const line = this._text.slice(0, index).replace(/\r$/, '');
25+
this._text = this._text.slice(index + 1);
2526
return deserializeMessage(line);
2627
}
2728

2829
clear(): void {
29-
this._buffer = undefined;
30+
this._decoder = new TextDecoder('utf-8');
31+
this._text = '';
3032
}
3133
}
3234

test/shared/stdio.test.ts

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,3 +33,53 @@ test('should be reusable after clearing', () => {
3333
readBuffer.append(Buffer.from('\n'));
3434
expect(readBuffer.readMessage()).toEqual(testMessage);
3535
});
36+
37+
describe('multi-byte UTF-8 across chunk boundaries', () => {
38+
test('should preserve em-dash split across two chunks', () => {
39+
const readBuffer = new ReadBuffer();
40+
const message: JSONRPCMessage = { jsonrpc: '2.0', method: 'test', params: { text: 'a—b' } };
41+
const fullBuffer = Buffer.from(JSON.stringify(message) + '\n');
42+
const splitIndex = fullBuffer.indexOf(0xe2) + 1;
43+
44+
readBuffer.append(fullBuffer.subarray(0, splitIndex));
45+
readBuffer.append(fullBuffer.subarray(splitIndex));
46+
47+
expect(readBuffer.readMessage()).toEqual(message);
48+
});
49+
50+
test('should preserve emoji split across two chunks', () => {
51+
const readBuffer = new ReadBuffer();
52+
const message: JSONRPCMessage = { jsonrpc: '2.0', method: 'test', params: { text: '✅' } };
53+
const fullBuffer = Buffer.from(JSON.stringify(message) + '\n');
54+
const splitIndex = fullBuffer.indexOf(0xe2) + 2;
55+
56+
readBuffer.append(fullBuffer.subarray(0, splitIndex));
57+
readBuffer.append(fullBuffer.subarray(splitIndex));
58+
59+
expect(readBuffer.readMessage()).toEqual(message);
60+
});
61+
62+
test('should preserve four-byte emoji split across chunks', () => {
63+
const readBuffer = new ReadBuffer();
64+
const message: JSONRPCMessage = { jsonrpc: '2.0', method: 'test', params: { text: '🎉' } };
65+
const fullBuffer = Buffer.from(JSON.stringify(message) + '\n');
66+
const splitIndex = fullBuffer.indexOf(0xf0) + 2;
67+
68+
readBuffer.append(fullBuffer.subarray(0, splitIndex));
69+
readBuffer.append(fullBuffer.subarray(splitIndex));
70+
71+
expect(readBuffer.readMessage()).toEqual(message);
72+
});
73+
74+
test('should preserve multi-byte chars across many chunks of size 1', () => {
75+
const readBuffer = new ReadBuffer();
76+
const message: JSONRPCMessage = { jsonrpc: '2.0', method: 'test', params: { text: 'em — dash and ✅ check' } };
77+
const fullBuffer = Buffer.from(JSON.stringify(message) + '\n');
78+
79+
for (let i = 0; i < fullBuffer.length; i++) {
80+
readBuffer.append(fullBuffer.subarray(i, i + 1));
81+
}
82+
83+
expect(readBuffer.readMessage()).toEqual(message);
84+
});
85+
});

0 commit comments

Comments
 (0)