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",