Skip to content

Commit 0d66710

Browse files
committed
fix: parse lsp content length by byte
1 parent 2df8021 commit 0d66710

2 files changed

Lines changed: 41 additions & 17 deletions

File tree

src/agents/agent-bundle-lsp-runtime.test.ts

Lines changed: 21 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -46,7 +46,7 @@ class MockChildProcess extends EventEmitter {
4646
readonly stderr = new PassThrough();
4747
readonly stdin: Writable;
4848

49-
constructor() {
49+
constructor(private readonly initializeResponsePrefix = "") {
5050
super();
5151
this.stdin = new Writable({
5252
write: (chunk, _encoding, callback) => {
@@ -71,7 +71,9 @@ class MockChildProcess extends EventEmitter {
7171
}
7272
const result = body.method === "initialize" ? { capabilities: { hoverProvider: true } } : null;
7373
queueMicrotask(() => {
74-
this.stdout.write(encodeLspMessage({ jsonrpc: "2.0", id: body.id, result }));
74+
this.stdout.write(
75+
`${this.initializeResponsePrefix}${encodeLspMessage({ jsonrpc: "2.0", id: body.id, result })}`,
76+
);
7577
});
7678
}
7779
}
@@ -119,6 +121,23 @@ describe("bundle LSP runtime", () => {
119121
expect(killProcessTreeMock).toHaveBeenCalledWith(4321, { graceMs: 1000 });
120122
});
121123

124+
it("keeps LSP framing aligned after multibyte messages in the same chunk", async () => {
125+
configureSingleLspServer();
126+
const prefix = encodeLspMessage({
127+
jsonrpc: "2.0",
128+
method: "window/logMessage",
129+
params: { message: "ready té" },
130+
});
131+
const child = new MockChildProcess(prefix);
132+
spawnMock.mockReturnValue(child);
133+
const { createBundleLspToolRuntime } = await import("./agent-bundle-lsp-runtime.js");
134+
135+
const runtime = await createBundleLspToolRuntime({ workspaceDir: "/tmp/workspace" });
136+
137+
expect(runtime.tools.map((tool) => tool.name)).toContain("lsp_hover_typescript");
138+
await runtime.dispose();
139+
});
140+
122141
it("disposes active LSP sessions from the global shutdown sweep", async () => {
123142
configureSingleLspServer();
124143
const child = new MockChildProcess();

src/agents/agent-bundle-lsp-runtime.ts

Lines changed: 20 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@ type LspSession = {
2525
process: ChildProcess;
2626
requestId: number;
2727
pendingRequests: Map<number, PendingLspRequest>;
28-
buffer: string;
28+
buffer: Buffer;
2929
initialized: boolean;
3030
capabilities: LspServerCapabilities;
3131
disposed: boolean;
@@ -93,7 +93,7 @@ function createLspSession(serverName: string, child: ChildProcess): LspSession {
9393
process: child,
9494
requestId: 0,
9595
pendingRequests: new Map(),
96-
buffer: "",
96+
buffer: Buffer.alloc(0),
9797
initialized: false,
9898
capabilities: {},
9999
disposed: false,
@@ -105,8 +105,9 @@ function registerActiveLspSession(session: LspSession): void {
105105
}
106106

107107
function attachLspProcessHandlers(session: LspSession): void {
108-
session.process.stdout?.setEncoding("utf-8");
109-
session.process.stdout?.on("data", (chunk: string) => handleIncomingData(session, chunk));
108+
session.process.stdout?.on("data", (chunk: Buffer | string) =>
109+
handleIncomingData(session, chunk),
110+
);
110111
session.process.stderr?.setEncoding("utf-8");
111112
session.process.stderr?.on("data", (chunk: string) => {
112113
for (const line of chunk.split(/\r?\n/).filter(Boolean)) {
@@ -120,38 +121,39 @@ function encodeLspMessage(body: unknown): string {
120121
return `Content-Length: ${Buffer.byteLength(json, "utf-8")}\r\n\r\n${json}`;
121122
}
122123

123-
function parseLspMessages(buffer: string): { messages: unknown[]; remaining: string } {
124+
function parseLspMessages(buffer: Buffer): { messages: unknown[]; remaining: Buffer } {
124125
const messages: unknown[] = [];
125126
let remaining = buffer;
127+
const headerSeparator = Buffer.from("\r\n\r\n", "ascii");
126128

127129
while (true) {
128-
const headerEnd = remaining.indexOf("\r\n\r\n");
130+
const headerEnd = remaining.indexOf(headerSeparator);
129131
if (headerEnd === -1) {
130132
break;
131133
}
132134

133-
const header = remaining.slice(0, headerEnd);
135+
const header = remaining.subarray(0, headerEnd).toString("ascii");
134136
const match = header.match(/Content-Length:\s*(\d+)/i);
135137
if (!match) {
136-
remaining = remaining.slice(headerEnd + 4);
138+
remaining = remaining.subarray(headerEnd + headerSeparator.length);
137139
continue;
138140
}
139141

140142
const contentLength = Number.parseInt(match[1], 10);
141-
const bodyStart = headerEnd + 4;
143+
const bodyStart = headerEnd + headerSeparator.length;
142144
const bodyEnd = bodyStart + contentLength;
143145

144-
if (Buffer.byteLength(remaining.slice(bodyStart), "utf-8") < contentLength) {
146+
if (remaining.length < bodyEnd) {
145147
break;
146148
}
147149

148150
try {
149-
const body = remaining.slice(bodyStart, bodyStart + contentLength);
151+
const body = remaining.subarray(bodyStart, bodyEnd).toString("utf8");
150152
messages.push(JSON.parse(body));
151153
} catch {
152154
// skip malformed
153155
}
154-
remaining = remaining.slice(bodyEnd);
156+
remaining = remaining.subarray(bodyEnd);
155157
}
156158

157159
return { messages, remaining };
@@ -174,10 +176,13 @@ function sendRequest(session: LspSession, method: string, params?: unknown): Pro
174176
});
175177
}
176178

177-
function handleIncomingData(session: LspSession, chunk: string) {
178-
session.buffer += chunk;
179+
function handleIncomingData(session: LspSession, chunk: Buffer | string) {
180+
session.buffer = Buffer.concat([
181+
session.buffer,
182+
typeof chunk === "string" ? Buffer.from(chunk, "utf8") : chunk,
183+
]);
179184
const { messages, remaining } = parseLspMessages(session.buffer);
180-
session.buffer = remaining;
185+
session.buffer = remaining.length === 0 ? Buffer.alloc(0) : Buffer.from(remaining);
181186

182187
for (const msg of messages) {
183188
if (typeof msg !== "object" || msg === null) {

0 commit comments

Comments
 (0)