From e7b31bb74694a81c8d0b14d9450d3ae6287adf68 Mon Sep 17 00:00:00 2001 From: Brandon Elliott <923787+Brandon168@users.noreply.github.com> Date: Mon, 9 Nov 2026 23:51:36 -0600 Subject: [PATCH] fix(server): exit a plugin process when its daemon's channel closes A plugin host only noticed that its daemon worker had died through the liveness ping: about 11 s later the ping failed and the process exited with code 1. With the pings off it would not notice at all and kept running as an orphan. The plugin process now exits as soon as the IPC channel closes without the plugin closing it itself. A normal shutdown still closes the channel from the plugin side and lets work in flight (a provider connection that resolves late) finish. Co-Authored-By: Claude Opus 5.5 --- .../src/server/plugins/plugin-process.ts | 9 +++++ .../src/server/plugins/runtime.posix.test.ts | 36 ++++++++++++++++++- 2 files changed, 44 insertions(+), 1 deletion(-) diff --git a/packages/server/src/server/plugins/plugin-process.ts b/packages/server/src/server/plugins/plugin-process.ts index 1ef843db83..7e37302663 100644 --- a/packages/server/src/server/plugins/plugin-process.ts +++ b/packages/server/src/server/plugins/plugin-process.ts @@ -646,6 +646,7 @@ export function createPluginWorker(options: { if (process.argv[1] === fileURLToPath(import.meta.url)) { let worker: ReturnType | null = null; + let closedByShutdown = false; const handlers = new Set<(message: PluginProcessRequest) => void>(); const channel: PluginWorkerChannel = { send(message, callback) { @@ -659,9 +660,17 @@ if (process.argv[1] === fileURLToPath(import.meta.url)) { }; }, disconnect() { + closedByShutdown = true; process.disconnect(); }, }; + // The daemon worker owns this process through the IPC channel. When the channel closes without + // this process closing it (the worker crashed or was killed), nothing can reach the plugin any + // more, so exit instead of running on as an orphan. A normal shutdown closes the channel itself + // and lets work still in flight finish. + process.once("disconnect", () => { + if (!closedByShutdown) process.exit(0); + }); process.on("message", (raw) => { const parsed = PluginProcessRequestSchema.safeParse(raw); if (!worker && parsed.success && parsed.data.type === "initialize") { diff --git a/packages/server/src/server/plugins/runtime.posix.test.ts b/packages/server/src/server/plugins/runtime.posix.test.ts index 1792a9d7ff..c06efd577b 100644 --- a/packages/server/src/server/plugins/runtime.posix.test.ts +++ b/packages/server/src/server/plugins/runtime.posix.test.ts @@ -7,7 +7,7 @@ import path from "node:path"; import { fileURLToPath } from "node:url"; import pino from "pino"; import type { ProviderEvent, ProviderRegistration } from "@getpaseo/plugin/server/provider"; -import { afterEach, describe, expect, it, vi } from "vitest"; +import { afterEach, describe, expect, it, onTestFinished, vi } from "vitest"; import type { AgentStreamEvent } from "../agent/agent-sdk-types.js"; import { PluginAgentClientRegistry } from "../agent/plugin-provider.js"; import { PluginRuntime } from "./runtime.js"; @@ -1936,6 +1936,40 @@ export default function contribute(plugin: any) { ); }); + it("exits a plugin process when its channel to the daemon closes without a shutdown", async () => { + const directory = await createPlugin( + "orphaned", + // A timer keeps the process alive the way a polling plugin does. + `export default function contribute(plugin: unknown) { + void plugin; + const timer = setInterval(() => undefined, 1_000); + return () => clearInterval(timer); +}`, + ); + const spawned: Array> = []; + const exits: Array> = []; + const runtime = createTestRuntime({ + spawnChild: () => { + const child = spawnObservedPluginProcess(() => undefined); + exits.push(new Promise((resolve) => child.once("exit", resolve))); + spawned.push(child); + return child; + }, + }); + // If the process never exits (the failure this test catches), do not leave it running. + onTestFinished(() => { + for (const child of spawned) if (child.exitCode === null) child.kill("SIGKILL"); + }); + await runtime.startPlugin("orphaned", directory); + expect(spawned).toHaveLength(1); + + // What the plugin sees when its daemon worker dies: the IPC channel closes, no shutdown request. + spawned[0]!.disconnect(); + + // In a real crash the daemon side is gone, so there is no runtime state left to stop here. + await expect(exits[0]).resolves.toBe(0); + }); + it("re-attaches a plugin session when the daemon closes the socket under a live child", async () => { const directory = await createPlugin( "reattaching",