Compare commits

...
Author SHA1 Message Date
paseo-bot[bot]andBrandon Elliott e2c5642827 fix(plugins): keep both process lifecycle regressions when updating main
Preserve the idle-ping fix and current main's invalid-message cleanup test;
both were inserted at the same location and cover distinct process exits.

Co-authored-by: Brandon Elliott <923787+Brandon168@users.noreply.github.com>
2026-11-11 05:58:47 +00:00
Brandon ElliottandClaude Opus 5.5 e7b31bb746 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>
2026-11-10 00:23:29 -06:00
Brandon ElliottandClaude Opus 5.5 fb435d225f fix(server): stop liveness pings on plugin sessions
Every DaemonClient pings its daemon every 10 s to notice a silently dead socket. A plugin's
session runs over its IPC channel (or an in-process queue for built-in plugins), whose loss is
reported directly, and the daemon already exempts plugin sockets from the application lease those
pings renew. The pings only woke both processes: about 2 wakeups a second in an idle daemon with
nine plugins and the built-ins.

DaemonClient gets a livenessHeartbeat option (default on); the plugin host turns it off.
Explicit verification (ensureConnected({ verify: true })) still pings.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-09 23:36:48 -06:00
4 changed files with 73 additions and 3 deletions
+14 -1
View File
@@ -753,13 +753,14 @@ class DaemonClientSession {
private readonly daemon = new FakeDaemon();
private readonly client: DaemonClient;
constructor() {
constructor(options: { livenessHeartbeat?: boolean } = {}) {
this.client = new DaemonClient({
url: "ws://test",
clientId: "clsk_heartbeat_test",
logger: noopLogger,
reconnect: { enabled: false },
transportFactory: () => this.daemon.transport,
...options,
});
clients.push(this.client);
}
@@ -3257,6 +3258,18 @@ test("starts pinging one interval after connecting and holds the cadence", async
expect(session.pingTimestamps()).toEqual(["10s", "20s", "30s"]);
});
test("a client with the liveness heartbeat off never pings on its own", async () => {
useHeartbeatClock();
const session = new DaemonClientSession({ livenessHeartbeat: false });
session.daemonAnswersPingsAfter("fast");
await session.connect();
await session.advance(60_000);
expect(session.state()).toEqual({ status: "connected" });
expect(session.pingTimestamps()).toEqual([]);
});
test("stops pinging once the connection is gone", async () => {
useHeartbeatClock();
const session = new DaemonClientSession();
+12 -1
View File
@@ -469,6 +469,13 @@ export interface DaemonClientConfig {
runtimeMetricsWindowMs?: number;
trace?: DaemonClientTrace;
capabilities?: Partial<Record<ClientCapability, unknown>>;
/**
* Ping the daemon every 10 s and reconnect after missed replies (default true). Turn it off
* only where the transport reports its own loss (a plugin's IPC channel, an in-process
* channel): there a ping cannot find a dead link the channel would not, and it costs both
* processes a wakeup per client every 10 s.
*/
livenessHeartbeat?: boolean;
}
export interface DaemonClientTrace {
@@ -2720,7 +2727,11 @@ export class DaemonClient {
}
private scheduleNextLivenessHeartbeat(): void {
if (this.connectionState.status !== "connected" || this.livenessHeartbeatTimer) {
if (
this.config.livenessHeartbeat === false ||
this.connectionState.status !== "connected" ||
this.livenessHeartbeatTimer
) {
return;
}
this.livenessHeartbeatTimer = setTimeout(() => {
@@ -313,6 +313,9 @@ export function createPluginWorker(options: {
// The runtime re-attaches a session when the daemon drops this socket.
reconnect: { enabled: true },
transportFactory,
// The channel is a pipe to the daemon (or an in-process queue for built-in plugins); its
// loss is reported directly, so a 10 s ping would only wake both sides.
livenessHeartbeat: false,
});
paseo = createPaseoApi(daemonClient);
await daemonClient.connect();
@@ -643,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) {
@@ -656,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("reports and removes a plugin it terminated for an invalid message", async () => {
const directory = await createPlugin(
"invalid-message",