mirror of
https://github.com/getpaseo/paseo.git
synced 2026-11-11 19:05:16 +00:00
Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e2c5642827 | ||
|
|
e7b31bb746 | ||
|
|
fb435d225f |
@@ -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();
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user