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 <noreply@anthropic.com>
This commit is contained in:
Brandon Elliott
2026-11-10 00:23:29 -06:00
co-authored by Claude Opus 5.5
parent fb435d225f
commit e7b31bb746
2 changed files with 44 additions and 1 deletions
@@ -646,6 +646,7 @@ export function createPluginWorker(options: {
if (process.argv[1] === fileURLToPath(import.meta.url)) {
let worker: ReturnType<typeof createPluginWorker> | 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") {
@@ -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<ReturnType<typeof spawnObservedPluginProcess>> = [];
const exits: Array<Promise<number | null>> = [];
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",