From 675dd337267dca21ad19531a30cb88547db579e7 Mon Sep 17 00:00:00 2001 From: Fernando Celmer Date: Sat, 15 Aug 2026 00:40:19 -0300 Subject: [PATCH 1/4] =?UTF-8?q?=F0=9F=AA=B2=20BUG-#1:=20Reject=20pending?= =?UTF-8?q?=20RPC=20requests=20when=20the=20process=20exits=20or=20fails?= =?UTF-8?q?=20to=20spawn?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/core-client/core-client.ts | 21 ++++++++++++++++----- 1 file changed, 16 insertions(+), 5 deletions(-) diff --git a/src/core-client/core-client.ts b/src/core-client/core-client.ts index f7c1e23..5240f32 100644 --- a/src/core-client/core-client.ts +++ b/src/core-client/core-client.ts @@ -20,10 +20,21 @@ export class RpcClient extends EventEmitter { } }); this.process.on("stderr", (text: string) => this.emit("stderr", text)); - this.process.on("exit", (info: { code: number | null; signal: string | null }) => - this.emit("exit", info) - ); - this.process.on("spawnError", (error: NodeJS.ErrnoException) => this.emit("spawnError", error)); + this.process.on("exit", (info: { code: number | null; signal: string | null }) => { + this.rejectPending("pycodeloop serve exited"); + this.emit("exit", info); + }); + this.process.on("spawnError", (error: NodeJS.ErrnoException) => { + this.rejectPending(error.message); + this.emit("spawnError", error); + }); + } + + private rejectPending(message: string): void { + for (const [id, resolve] of this.pending) { + resolve({ jsonrpc: "2.0", id, error: { code: -32000, message } }); + } + this.pending.clear(); } private dispatch(message: RpcMessage): void { @@ -53,7 +64,7 @@ export class RpcClient extends EventEmitter { } dispose(): void { - this.pending.clear(); + this.rejectPending("Disposed"); this.process.kill(); } } From d2b9fbdff14265ad4b379cc671c2d62a17b468e4 Mon Sep 17 00:00:00 2001 From: Fernando Celmer Date: Sat, 15 Aug 2026 00:40:19 -0300 Subject: [PATCH 2/4] =?UTF-8?q?=E2=9D=A4=EF=B8=8F=20TEST-#1:=20Add=20cover?= =?UTF-8?q?age=20for=20pending-request=20rejection=20on=20exit/spawnError/?= =?UTF-8?q?dispose?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- test/core-client.test.ts | 29 +++++++++++++++++++++++++++++ 1 file changed, 29 insertions(+) create mode 100644 test/core-client.test.ts diff --git a/test/core-client.test.ts b/test/core-client.test.ts new file mode 100644 index 0000000..874b954 --- /dev/null +++ b/test/core-client.test.ts @@ -0,0 +1,29 @@ +import assert from "node:assert/strict"; +import { test } from "node:test"; +import { RpcClient } from "../src/core-client/core-client"; + +test("pending requests reject instead of hanging forever when the process exits", async () => { + const client = new RpcClient(process.execPath, ["-e", "process.exit(1)"], process.cwd()); + + const response = await client.request("chat/send", { prompt: "hi" }); + + assert.equal(response.error?.code, -32000); +}); + +test("pending requests reject when the process fails to spawn", async () => { + const client = new RpcClient("this-binary-does-not-exist", [], process.cwd()); + + const response = await client.request("chat/send", { prompt: "hi" }); + + assert.equal(response.error?.code, -32000); +}); + +test("dispose rejects any still-pending request instead of leaving it hanging", async () => { + const client = new RpcClient(process.execPath, ["-e", "setTimeout(() => {}, 60000)"], process.cwd()); + + const pending = client.request("chat/send", { prompt: "hi" }); + client.dispose(); + + const response = await pending; + assert.equal(response.error?.code, -32000); +}); From 4e644e2fbc4fc91a7abc7d1119a9a3b469eceac2 Mon Sep 17 00:00:00 2001 From: Fernando Celmer Date: Sat, 15 Aug 2026 00:47:55 -0300 Subject: [PATCH 3/4] =?UTF-8?q?=F0=9F=AA=B2=20BUG-#1:=20Inject=20a=20fake?= =?UTF-8?q?=20ProcessHandle=20in=20tests=20instead=20of=20spawning=20a=20r?= =?UTF-8?q?eal=20process?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/core-client/core-client.ts | 20 ++++++++++++--- test/core-client.test.ts | 47 ++++++++++++++++++++++++++++------ 2 files changed, 56 insertions(+), 11 deletions(-) diff --git a/src/core-client/core-client.ts b/src/core-client/core-client.ts index 5240f32..51b3dab 100644 --- a/src/core-client/core-client.ts +++ b/src/core-client/core-client.ts @@ -4,14 +4,28 @@ import { RpcMessage, decodeRpcMessage, encodeRpcMessage } from "./protocol"; export { RpcMessage } from "./protocol"; +/** What RpcClient needs from a child process — satisfied by CoreProcess, + * and by a lightweight fake in tests so they don't have to spawn a real + * process (which keeps the test runner alive until it fully exits). */ +export interface ProcessHandle extends EventEmitter { + write(data: string): void; + kill(): void; +} + export class RpcClient extends EventEmitter { - private process: CoreProcess; + private process: ProcessHandle; private nextId = 1; private pending = new Map void>(); - constructor(command: string, args: string[], cwd: string, env?: Record) { + constructor( + command: string, + args: string[], + cwd: string, + env?: Record, + process: ProcessHandle = new CoreProcess(command, args, cwd, env) + ) { super(); - this.process = new CoreProcess(command, args, cwd, env); + this.process = process; this.process.on("line", (line: string) => { const message = decodeRpcMessage(line); diff --git a/test/core-client.test.ts b/test/core-client.test.ts index 874b954..602c972 100644 --- a/test/core-client.test.ts +++ b/test/core-client.test.ts @@ -1,29 +1,60 @@ import assert from "node:assert/strict"; +import { EventEmitter } from "node:events"; import { test } from "node:test"; -import { RpcClient } from "../src/core-client/core-client"; +import { ProcessHandle, RpcClient } from "../src/core-client/core-client"; -test("pending requests reject instead of hanging forever when the process exits", async () => { - const client = new RpcClient(process.execPath, ["-e", "process.exit(1)"], process.cwd()); +class FakeProcess extends EventEmitter implements ProcessHandle { + killed = false; - const response = await client.request("chat/send", { prompt: "hi" }); + write(_data: string): void {} + kill(): void { + this.killed = true; + } +} + +test("pending requests reject when the process exits mid-flight", async () => { + const fake = new FakeProcess(); + const client = new RpcClient("fake", [], process.cwd(), undefined, fake); + + const pending = client.request("chat/send", { prompt: "hi" }); + fake.emit("exit", { code: 1, signal: null }); + + const response = await pending; assert.equal(response.error?.code, -32000); }); test("pending requests reject when the process fails to spawn", async () => { - const client = new RpcClient("this-binary-does-not-exist", [], process.cwd()); + const fake = new FakeProcess(); + const client = new RpcClient("fake", [], process.cwd(), undefined, fake); - const response = await client.request("chat/send", { prompt: "hi" }); + const pending = client.request("chat/send", { prompt: "hi" }); + fake.emit("spawnError", new Error("ENOENT")); + const response = await pending; assert.equal(response.error?.code, -32000); + assert.equal(response.error?.message, "ENOENT"); }); -test("dispose rejects any still-pending request instead of leaving it hanging", async () => { - const client = new RpcClient(process.execPath, ["-e", "setTimeout(() => {}, 60000)"], process.cwd()); +test("dispose rejects any still-pending request and kills the process", async () => { + const fake = new FakeProcess(); + const client = new RpcClient("fake", [], process.cwd(), undefined, fake); const pending = client.request("chat/send", { prompt: "hi" }); client.dispose(); const response = await pending; assert.equal(response.error?.code, -32000); + assert.equal(fake.killed, true); +}); + +test("a real RPC response still resolves normally, not through rejectPending", async () => { + const fake = new FakeProcess(); + const client = new RpcClient("fake", [], process.cwd(), undefined, fake); + + const pending = client.request("chat/send", { prompt: "hi" }); + fake.emit("line", JSON.stringify({ jsonrpc: "2.0", id: "1", result: { text: "ok" } })); + + const response = await pending; + assert.deepEqual(response.result, { text: "ok" }); }); From fc1a85df3555d82206f1c52eca67fbe0d818973c Mon Sep 17 00:00:00 2001 From: Fernando Celmer Date: Sat, 15 Aug 2026 01:12:37 -0300 Subject: [PATCH 4/4] =?UTF-8?q?=F0=9F=AA=B2=20BUG-#1:=20Clear=20pending=20?= =?UTF-8?q?before=20notifying=20resolvers;=20guard=20new=20requests=20afte?= =?UTF-8?q?r=20the=20process=20dies?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/core-client/core-client.ts | 14 ++++++++++++-- test/core-client.test.ts | 26 ++++++++++++++++++++++++++ 2 files changed, 38 insertions(+), 2 deletions(-) diff --git a/src/core-client/core-client.ts b/src/core-client/core-client.ts index 51b3dab..d630aa1 100644 --- a/src/core-client/core-client.ts +++ b/src/core-client/core-client.ts @@ -16,6 +16,7 @@ export class RpcClient extends EventEmitter { private process: ProcessHandle; private nextId = 1; private pending = new Map void>(); + private dead = false; constructor( command: string, @@ -45,10 +46,12 @@ export class RpcClient extends EventEmitter { } private rejectPending(message: string): void { - for (const [id, resolve] of this.pending) { + this.dead = true; + const snapshot = new Map(this.pending); + this.pending.clear(); + for (const [id, resolve] of snapshot) { resolve({ jsonrpc: "2.0", id, error: { code: -32000, message } }); } - this.pending.clear(); } private dispatch(message: RpcMessage): void { @@ -71,6 +74,13 @@ export class RpcClient extends EventEmitter { request(method: string, params: Record = {}): Promise { const id = String(this.nextId++); + if (this.dead) { + return Promise.resolve({ + jsonrpc: "2.0", + id, + error: { code: -32000, message: "RpcClient is disposed" }, + }); + } return new Promise((resolve) => { this.pending.set(id, resolve); this.process.write(encodeRpcMessage({ jsonrpc: "2.0", id, method, params })); diff --git a/test/core-client.test.ts b/test/core-client.test.ts index 602c972..ad40997 100644 --- a/test/core-client.test.ts +++ b/test/core-client.test.ts @@ -58,3 +58,29 @@ test("a real RPC response still resolves normally, not through rejectPending", a const response = await pending; assert.deepEqual(response.result, { text: "ok" }); }); + +test("a re-entrant request made from inside a rejected .then() is not silently dropped", async () => { + const fake = new FakeProcess(); + const client = new RpcClient("fake", [], process.cwd(), undefined, fake); + + const retryResponse = client.request("chat/send", { prompt: "first" }).then((first) => { + assert.equal(first.error?.code, -32000); + return client.request("chat/send", { prompt: "retry" }); + }); + fake.emit("exit", { code: 1, signal: null }); + + const response = await retryResponse; + assert.equal(response.error?.code, -32000); + assert.equal(response.error?.message, "RpcClient is disposed"); +}); + +test("requests made after the process has already exited reject immediately instead of hanging", async () => { + const fake = new FakeProcess(); + const client = new RpcClient("fake", [], process.cwd(), undefined, fake); + + fake.emit("exit", { code: 1, signal: null }); + const response = await client.request("diagnostics/status"); + + assert.equal(response.error?.code, -32000); + assert.equal(response.error?.message, "RpcClient is disposed"); +});