Skip to content

Commit 028ee3f

Browse files
advaitbenbrandt
andauthored
fix: Handle ACP connection transport failures cleanly (#103)
* Handle ACP connection transport failures cleanly * Harden a few more cases --------- Co-authored-by: Ben Brandt <benjamin.j.brandt@gmail.com>
1 parent a51950f commit 028ee3f

3 files changed

Lines changed: 173 additions & 40 deletions

File tree

src/acp.test.ts

Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,7 @@ import {
4444
ResumeSessionRequest,
4545
ResumeSessionResponse,
4646
} from "./acp.js";
47+
import type { AnyMessage } from "./acp.js";
4748

4849
describe("Connection", () => {
4950
let clientToAgent: TransformStream<Uint8Array, Uint8Array>;
@@ -988,6 +989,107 @@ describe("Connection", () => {
988989
expect(closeLog).toContain("client connection closed (signal)");
989990
});
990991

992+
class MinimalTestClient implements Client {
993+
async writeTextFile(
994+
_: WriteTextFileRequest,
995+
): Promise<WriteTextFileResponse> {
996+
return {};
997+
}
998+
async readTextFile(_: ReadTextFileRequest): Promise<ReadTextFileResponse> {
999+
return { content: "test" };
1000+
}
1001+
async requestPermission(
1002+
_: RequestPermissionRequest,
1003+
): Promise<RequestPermissionResponse> {
1004+
return {
1005+
outcome: {
1006+
outcome: "selected",
1007+
optionId: "allow",
1008+
},
1009+
};
1010+
}
1011+
async sessionUpdate(_: SessionNotification): Promise<void> {
1012+
// no-op
1013+
}
1014+
}
1015+
1016+
it("rejects pending requests when the stream errors", async () => {
1017+
let readableController!: ReadableStreamDefaultController<AnyMessage>;
1018+
1019+
const connection = new ClientSideConnection(() => new MinimalTestClient(), {
1020+
readable: new ReadableStream<AnyMessage>({
1021+
start(controller) {
1022+
readableController = controller;
1023+
},
1024+
}),
1025+
writable: new WritableStream<AnyMessage>({
1026+
async write() {
1027+
// no-op
1028+
},
1029+
}),
1030+
});
1031+
1032+
const requestPromise = connection.newSession({
1033+
cwd: "/test",
1034+
mcpServers: [],
1035+
});
1036+
const error = new Error("stream exploded");
1037+
1038+
readableController.error(error);
1039+
1040+
await expect(requestPromise).rejects.toThrow("stream exploded");
1041+
await expect(connection.closed).resolves.toBeUndefined();
1042+
expect(connection.signal.aborted).toBe(true);
1043+
});
1044+
1045+
it("rejects pending requests when the writable stream errors", async () => {
1046+
const writeError = new Error("write failed");
1047+
1048+
const connection = new ClientSideConnection(() => new MinimalTestClient(), {
1049+
readable: new ReadableStream<AnyMessage>({
1050+
// Never produces messages; stays open.
1051+
start() {},
1052+
}),
1053+
writable: new WritableStream<AnyMessage>({
1054+
async write() {
1055+
throw writeError;
1056+
},
1057+
}),
1058+
});
1059+
1060+
const requestPromise = connection.newSession({
1061+
cwd: "/test",
1062+
mcpServers: [],
1063+
});
1064+
1065+
await expect(requestPromise).rejects.toThrow("write failed");
1066+
await expect(connection.closed).resolves.toBeUndefined();
1067+
expect(connection.signal.aborted).toBe(true);
1068+
});
1069+
1070+
it("rejects requests issued after the connection is closed", async () => {
1071+
const connection = new ClientSideConnection(() => new MinimalTestClient(), {
1072+
readable: new ReadableStream<AnyMessage>({
1073+
start(controller) {
1074+
// Close the readable stream immediately so the connection closes.
1075+
controller.close();
1076+
},
1077+
}),
1078+
writable: new WritableStream<AnyMessage>({
1079+
async write() {
1080+
// no-op
1081+
},
1082+
}),
1083+
});
1084+
1085+
await connection.closed;
1086+
expect(connection.signal.aborted).toBe(true);
1087+
1088+
await expect(
1089+
connection.newSession({ cwd: "/test", mcpServers: [] }),
1090+
).rejects.toThrow("ACP connection closed");
1091+
});
1092+
9911093
it("supports removing signal event listeners", async () => {
9921094
const closeLog: string[] = [];
9931095

src/acp.ts

Lines changed: 71 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -11,11 +11,15 @@ import type {
1111
AnyResponse,
1212
Result,
1313
ErrorResponse,
14-
PendingResponse,
1514
RequestHandler,
1615
NotificationHandler,
1716
} from "./jsonrpc.js";
1817

18+
type ConnectionPendingResponse = {
19+
resolve: (response: unknown) => void;
20+
reject: (error: unknown) => void;
21+
};
22+
1923
/**
2024
* An agent-side connection to a client.
2125
*
@@ -1167,7 +1171,8 @@ export class ClientSideConnection implements Agent {
11671171
export type { AnyMessage } from "./jsonrpc.js";
11681172

11691173
class Connection {
1170-
#pendingResponses: Map<string | number | null, PendingResponse> = new Map();
1174+
#pendingResponses: Map<string | number | null, ConnectionPendingResponse> =
1175+
new Map();
11711176
#nextRequestId: number = 0;
11721177
#requestHandler: RequestHandler;
11731178
#notificationHandler: NotificationHandler;
@@ -1187,7 +1192,7 @@ class Connection {
11871192
this.#closedPromise = new Promise((resolve) => {
11881193
this.#abortController.signal.addEventListener("abort", () => resolve());
11891194
});
1190-
this.#receive();
1195+
void this.#receive();
11911196
}
11921197

11931198
/**
@@ -1221,44 +1226,64 @@ class Connection {
12211226
}
12221227

12231228
async #receive() {
1224-
const reader = this.#stream.readable.getReader();
1229+
let closeError: unknown = undefined;
1230+
12251231
try {
1226-
while (true) {
1227-
const { value: message, done } = await reader.read();
1228-
if (done) {
1229-
break;
1230-
}
1231-
if (!message) {
1232-
continue;
1233-
}
1232+
const reader = this.#stream.readable.getReader();
1233+
try {
1234+
while (!this.#abortController.signal.aborted) {
1235+
const { value: message, done } = await reader.read();
1236+
if (done) {
1237+
break;
1238+
}
1239+
if (!message) {
1240+
continue;
1241+
}
12341242

1235-
try {
1236-
this.#processMessage(message);
1237-
} catch (err) {
1238-
console.error(
1239-
"Unexpected error during message processing:",
1240-
message,
1241-
err,
1242-
);
1243-
// Only send error response if the message had an id (was a request)
1244-
if ("id" in message && message.id !== undefined) {
1245-
this.#sendMessage({
1246-
jsonrpc: "2.0",
1247-
id: message.id,
1248-
error: {
1249-
code: -32700,
1250-
message: "Parse error",
1251-
},
1252-
});
1243+
try {
1244+
this.#processMessage(message);
1245+
} catch (err) {
1246+
console.error(
1247+
"Unexpected error during message processing:",
1248+
message,
1249+
err,
1250+
);
1251+
// Only send error response if the message had an id (was a request)
1252+
if ("id" in message && message.id !== undefined) {
1253+
this.#sendMessage({
1254+
jsonrpc: "2.0",
1255+
id: message.id,
1256+
error: {
1257+
code: -32700,
1258+
message: "Parse error",
1259+
},
1260+
});
1261+
}
12531262
}
12541263
}
1264+
} finally {
1265+
reader.releaseLock();
12551266
}
1267+
} catch (error) {
1268+
closeError = error;
12561269
} finally {
1257-
reader.releaseLock();
1258-
this.#abortController.abort();
1270+
this.#close(closeError);
12591271
}
12601272
}
12611273

1274+
#close(error?: unknown) {
1275+
if (this.#abortController.signal.aborted) {
1276+
return;
1277+
}
1278+
1279+
const closeError: unknown = error ?? new Error("ACP connection closed");
1280+
for (const pendingResponse of this.#pendingResponses.values()) {
1281+
pendingResponse.reject(closeError);
1282+
}
1283+
this.#pendingResponses.clear();
1284+
this.#abortController.abort(closeError);
1285+
}
1286+
12621287
async #processMessage(message: AnyMessage) {
12631288
if ("method" in message && "id" in message) {
12641289
// It's a request
@@ -1376,7 +1401,8 @@ class Connection {
13761401
if ("result" in response) {
13771402
pendingResponse.resolve(response.result);
13781403
} else if ("error" in response) {
1379-
pendingResponse.reject(response.error);
1404+
const { code, message, data } = response.error;
1405+
pendingResponse.reject(new RequestError(code, message, data));
13801406
}
13811407
this.#pendingResponses.delete(response.id);
13821408
} else {
@@ -1385,6 +1411,7 @@ class Connection {
13851411
}
13861412

13871413
async sendRequest<Req, Resp>(method: string, params?: Req): Promise<Resp> {
1414+
this.#throwIfClosed();
13881415
const id = this.#nextRequestId++;
13891416
const responsePromise = new Promise((resolve, reject) => {
13901417
this.#pendingResponses.set(id, { resolve, reject });
@@ -1394,9 +1421,19 @@ class Connection {
13941421
}
13951422

13961423
async sendNotification<N>(method: string, params?: N): Promise<void> {
1424+
this.#throwIfClosed();
13971425
await this.#sendMessage({ jsonrpc: "2.0", method, params });
13981426
}
13991427

1428+
#throwIfClosed() {
1429+
if (this.#abortController.signal.aborted) {
1430+
throw (
1431+
this.#abortController.signal.reason ??
1432+
new Error("ACP connection closed")
1433+
);
1434+
}
1435+
}
1436+
14001437
async #sendMessage(message: AnyMessage) {
14011438
this.#writeQueue = this.#writeQueue
14021439
.then(async () => {
@@ -1408,8 +1445,7 @@ class Connection {
14081445
}
14091446
})
14101447
.catch((error) => {
1411-
// Continue processing writes on error
1412-
console.error("ACP write error:", error);
1448+
this.#close(error);
14131449
});
14141450
return this.#writeQueue;
14151451
}

src/jsonrpc.ts

Lines changed: 0 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -36,11 +36,6 @@ export type ErrorResponse = {
3636
data?: unknown;
3737
};
3838

39-
export type PendingResponse = {
40-
resolve: (response: unknown) => void;
41-
reject: (error: ErrorResponse) => void;
42-
};
43-
4439
export type RequestHandler = (
4540
method: string,
4641
params: unknown,

0 commit comments

Comments
 (0)