From ee6a2bba77dcb2cd11a52d135cbecfcfdc303941 Mon Sep 17 00:00:00 2001 From: Brandon Elliott Date: Tue, 10 Nov 2026 23:53:39 -0600 Subject: [PATCH] fix(server): report and remove a plugin the runtime terminated (#9285) When a plugin sent an invalid IPC message, the runtime terminated it by disconnecting the IPC channel and then killing the process. Node never emits "close" for a child whose channel the parent disconnected, and "close" is where the runtime handles an exited plugin, so the process died but the plugin stayed in the catalog, its session stayed open, nothing reported the exit, and calls to it failed with "does not contribute RPC" instead of "Plugin is not available". terminatePluginChild() now only kills the process; the channel closes with it, and "close" fires as for any other exit. Co-authored-by: Brandon Elliott <923787+Brandon168@users.noreply.github.com> Co-authored-by: Claude Opus 5.5 --- .../src/server/plugins/runtime.posix.test.ts | 32 +++++++++++++++++++ packages/server/src/server/plugins/runtime.ts | 3 +- 2 files changed, 34 insertions(+), 1 deletion(-) diff --git a/packages/server/src/server/plugins/runtime.posix.test.ts b/packages/server/src/server/plugins/runtime.posix.test.ts index 1792a9d7ff..ccaf00a332 100644 --- a/packages/server/src/server/plugins/runtime.posix.test.ts +++ b/packages/server/src/server/plugins/runtime.posix.test.ts @@ -1936,6 +1936,38 @@ export default function contribute(plugin: any) { ); }); + it("reports and removes a plugin it terminated for an invalid message", async () => { + const directory = await createPlugin( + "invalid-message", + `export default function contribute(plugin: unknown) { + void plugin; + setTimeout(() => process.send?.({ type: "not-a-plugin-message" }), 20); + return () => undefined; +}`, + ); + const sessions = createTrackedSessionHost(); + const runtime = createTestRuntime({ sessionHost: sessions.host }); + const exited = new Promise((resolve) => { + runtime.subscribe((pluginId, error) => { + if (pluginId === "invalid-message" && error) resolve(error); + }); + }); + await runtime.startPlugin("invalid-message", directory); + + // The runtime kills the process; it must then treat it like any other exited plugin. + await expect(exited).resolves.toBe("Plugin process exited: invalid-message"); + expect(sessions.active.size).toBe(0); + expect(runtime.catalog()).toEqual([]); + expect( + pluginMessages(runtime, "invalid-message").some((message) => + message.includes("sent an invalid message"), + ), + ).toBe(true); + await expect(runtime.invoke("invalid-message", "anything", {})).rejects.toThrow( + "Plugin is not available", + ); + }); + it("re-attaches a plugin session when the daemon closes the socket under a live child", async () => { const directory = await createPlugin( "reattaching", diff --git a/packages/server/src/server/plugins/runtime.ts b/packages/server/src/server/plugins/runtime.ts index 98cbf8fcf0..f8fe8a91b5 100644 --- a/packages/server/src/server/plugins/runtime.ts +++ b/packages/server/src/server/plugins/runtime.ts @@ -247,8 +247,9 @@ function spawnPluginChild(): PluginChild { }) as PluginChild; } +// Kill only. Node never emits "close" for a child whose IPC channel the parent disconnected, and +// "close" is how the runtime learns that a plugin exited; the channel closes with the process. function terminatePluginChild(child: PluginChild): void { - if (child.connected) child.disconnect(); if (!child.killed) child.kill(); }