Compare commits

..
Author SHA1 Message Date
paseo-bot[bot]andCong Xu a7947f1fac test(server): reproduce shared daemon lock loss across PID namespaces
Retain acquisition and stop regressions for #4307. These tests intentionally fail on main; they do not choose a namespace ownership or crash-recovery policy.

Co-authored-by: Cong Xu <3174585+xu-c@users.noreply.github.com>
2026-10-18 13:36:22 +02:00
7 changed files with 57 additions and 377 deletions
@@ -1,85 +0,0 @@
import React, { act } from "react";
import { createRoot, type Root } from "react-dom/client";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { getAllByRole } from "@testing-library/dom";
import { i18n as testI18n } from "@/i18n/i18next";
import { DEFAULT_SIDEBAR_WIDTH, MIN_SIDEBAR_WIDTH } from "@/stores/panel-store";
import { SidebarProjectEmptyState } from "./empty-states";
// App sources compile against the classic JSX runtime, which expects React on the global.
beforeEach(() => vi.stubGlobal("React", React));
interface Mounted {
root: Root;
container: HTMLDivElement;
}
const mounted: Mounted[] = [];
afterEach(async () => {
for (const entry of mounted.splice(0)) {
act(() => entry.root.unmount());
entry.container.remove();
}
await testI18n.changeLanguage("en");
});
async function mountEmptyState(language: string, width: number): Promise<HTMLElement> {
await testI18n.changeLanguage(language);
const container = document.createElement("div");
container.style.width = `${width}px`;
// Match the workspace list's horizontal inset around the card.
container.style.paddingInline = "6px";
container.style.boxSizing = "border-box";
document.body.appendChild(container);
const root = createRoot(container);
act(() => root.render(<SidebarProjectEmptyState />));
mounted.push({ root, container });
const card = container.querySelector('[data-testid="sidebar-project-empty-state"]');
if (!(card instanceof HTMLElement)) {
throw new Error("SidebarProjectEmptyState did not render its card");
}
return card;
}
function expectActionsInsideCard(card: HTMLElement) {
const cardRect = card.getBoundingClientRect();
const buttons = getAllByRole(card, "button");
expect(buttons).toHaveLength(2);
for (const button of buttons) {
const rect = button.getBoundingClientRect();
expect(rect.left).toBeGreaterThanOrEqual(cardRect.left);
expect(rect.right).toBeLessThanOrEqual(cardRect.right);
// Containing the button must also keep the whole label readable, without clipping.
const label = document.createRange();
label.selectNodeContents(button);
for (const fragment of Array.from(label.getClientRects())) {
expect(fragment.left).toBeGreaterThanOrEqual(rect.left);
expect(fragment.right).toBeLessThanOrEqual(rect.right);
expect(fragment.top).toBeGreaterThanOrEqual(rect.top);
expect(fragment.bottom).toBeLessThanOrEqual(rect.bottom);
}
}
}
describe("SidebarProjectEmptyState", () => {
const cases = ["en", "ru", "fr", "ja", "es", "pt-BR", "ar", "ko", "zh-CN"].flatMap((language) =>
[MIN_SIDEBAR_WIDTH, DEFAULT_SIDEBAR_WIDTH].map((width) => ({ language, width })),
);
it.each(cases)(
"keeps every action inside the card ($language, $width px sidebar)",
async ({ language, width }) => {
expectActionsInsideCard(await mountEmptyState(language, width));
},
);
it("keeps English actions on one row when they fit", async () => {
const card = await mountEmptyState("en", DEFAULT_SIDEBAR_WIDTH);
const [addProject, importSession] = getAllByRole(card, "button");
expect(addProject.getBoundingClientRect().top).toBe(importSession.getBoundingClientRect().top);
});
});
@@ -54,13 +54,7 @@ export function SidebarFilterEmptyState() {
title={t("sidebar.filterEmpty.title")}
description={t("sidebar.filterEmpty.description")}
>
<Button
variant="secondary"
size="xs"
style={styles.action}
textStyle={styles.actionText}
onPress={clearFilters}
>
<Button variant="secondary" size="xs" onPress={clearFilters}>
{t("sidebar.filterEmpty.clear")}
</Button>
</SidebarEmptyStateCard>
@@ -82,24 +76,10 @@ export function SidebarProjectEmptyState({
title={t("sidebar.project.empty.title")}
description={t("sidebar.project.empty.description")}
>
<Button
variant="secondary"
size="xs"
leftIcon={Plus}
style={styles.action}
textStyle={styles.actionText}
onPress={onAddProject}
>
<Button variant="secondary" size="xs" leftIcon={Plus} onPress={onAddProject}>
{t("sidebar.actions.addProject")}
</Button>
<Button
variant="outline"
size="xs"
leftIcon={Import}
style={styles.action}
textStyle={styles.actionText}
onPress={onImportSession}
>
<Button variant="outline" size="xs" leftIcon={Import} onPress={onImportSession}>
{t("importSession.title")}
</Button>
</SidebarEmptyStateCard>
@@ -119,16 +99,9 @@ const styles = StyleSheet.create((theme) => ({
},
actions: {
flexDirection: "row",
flexWrap: "wrap",
gap: theme.spacing[2],
marginTop: theme.spacing[4],
},
action: {
maxWidth: "100%",
},
actionText: {
flexShrink: 1,
},
title: {
color: theme.colors.foreground,
fontSize: theme.fontSize.sm,
@@ -285,15 +285,10 @@ describe("OMP MCP host tools", () => {
expect(omp.registeredHostTools().at(-1)?.[0]?.name).toMatch(/^mcp_local__echo_secret/);
omp.processExit("crashed");
await omp.startTurn("again");
// The relaunch spawns a new runtime and MCP child; on a loaded Windows
// runner that has taken longer than vi.waitFor's default one second.
await vi.waitFor(
() => {
expect(omp.runtimeSessions()).toHaveLength(2);
expect(omp.registeredHostTools().at(-1)?.[0]?.name).toMatch(/^mcp_local__echo_secret/);
},
{ timeout: 10_000 },
);
await vi.waitFor(() => {
expect(omp.runtimeSessions()).toHaveLength(2);
expect(omp.registeredHostTools().at(-1)?.[0]?.name).toMatch(/^mcp_local__echo_secret/);
});
} finally {
await omp.close();
}
@@ -1,57 +0,0 @@
import { expect, test } from "vitest";
import { createDaemonTestContext } from "../test-utils/index.js";
import { DaemonClient } from "../test-utils/daemon-client.js";
import type { SessionOutboundMessage } from "../messages.js";
test("deleting an agent removes it from every connected agent subscription", async () => {
const ctx = await createDaemonTestContext();
const observer = new DaemonClient({ url: `ws://127.0.0.1:${ctx.daemon.port}/ws` });
const updates: SessionOutboundMessage[] = [];
const requestorUpdates: SessionOutboundMessage[] = [];
try {
await observer.connect();
const agent = await ctx.client.createAgent({
provider: "codex",
cwd: ctx.daemon.staticDir,
title: "Cross-client deletion",
});
const snapshot = await observer.fetchAgents({ subscribe: {} });
expect(snapshot.entries.some((entry) => entry.agent.id === agent.id)).toBe(true);
observer.subscribeRawMessages((message) => updates.push(message));
ctx.client.subscribeRawMessages((message) => requestorUpdates.push(message));
await ctx.client.deleteAgent(agent.id);
// The record is gone, and a fresh query sees that. An existing subscriber
// must learn the same fact without reconnecting or fetching another list.
const afterDelete = await ctx.client.fetchAgents();
expect(afterDelete.entries.some((entry) => entry.agent.id === agent.id)).toBe(false);
expect(
requestorUpdates.some(
(message) =>
message.type === "agent_update" &&
message.payload.kind === "remove" &&
message.payload.agentId === agent.id,
),
).toBe(true);
await expect(observer.fetchAgentTimeline(agent.id, { direction: "tail" })).rejects.toThrow(
"Agent not found",
);
await expect
.poll(
() =>
updates.some(
(message) =>
message.type === "agent_update" &&
message.payload.kind === "remove" &&
message.payload.agentId === agent.id,
),
{ timeout: 2_000 },
)
.toBe(true);
} finally {
await observer.close();
await ctx.cleanup();
}
});
@@ -1,161 +0,0 @@
import { createServer as createHttpServer } from "node:http";
import { connect, createServer as createTcpServer, type AddressInfo, type Socket } from "node:net";
import { mkdtemp, rm } from "node:fs/promises";
import { tmpdir } from "node:os";
import path from "node:path";
import pino from "pino";
import { expect, test, vi } from "vitest";
import { WebSocket, WebSocketServer } from "ws";
import { HubRelationshipController } from "./relationship-controller.js";
import { DirectHubRelationshipRemote } from "./relationship-remote.js";
test("a silently lost Hub connection leaves connected state and reconnects when the path recovers", async () => {
const home = await mkdtemp(path.join(tmpdir(), "paseo-hub-liveness-"));
const hub = createHttpServer(async (request, response) => {
let body = "";
for await (const chunk of request) body += chunk;
const enrollment = JSON.parse(body) as { daemonId: string };
response.writeHead(200, { "content-type": "application/json" }).end(
JSON.stringify({
daemonId: enrollment.daemonId,
permissions: ["hub.execute"],
webSocketUrl: `ws://127.0.0.1:${proxyPort}/api/daemons/socket`,
}),
);
});
const webSockets = new WebSocketServer({ server: hub });
const peers = new Set<WebSocket>();
let connections = 0;
let pongs = 0;
webSockets.on("connection", (peer) => {
connections += 1;
peers.add(peer);
peer.on("close", () => peers.delete(peer));
});
await listen(hub);
const hubPort = (hub.address() as AddressInfo).port;
const streams = new Set<Socket>();
let dropped = false;
const proxy = createTcpServer((client) => {
const upstream = connect(hubPort, "127.0.0.1");
streams.add(client);
streams.add(upstream);
client.on("data", (data) => {
if (!dropped) upstream.write(data);
});
upstream.on("data", (data) => {
if (!dropped) client.write(data);
});
client.on("end", () => {
if (!dropped) upstream.end();
});
upstream.on("end", () => {
if (!dropped) client.end();
});
for (const socket of [client, upstream]) {
socket.on("error", () => socket.destroy());
socket.on("close", () => streams.delete(socket));
}
});
await listen(proxy);
const proxyPort = (proxy.address() as AddressInfo).port;
let retry: (() => void) | undefined;
const controller = new HubRelationshipController({
paseoHome: home,
hostname: "test-daemon",
serverId: "server-1",
daemonPublicKey: "public-key",
logger: pino({ level: "silent" }),
remote: new DirectHubRelationshipRemote(),
clock: {
now: () => new Date(),
schedule: (_delay, task) => {
retry = task;
return { cancel: () => (retry = undefined) };
},
},
attachSocket: async (socket) => {
if (!(socket instanceof WebSocket)) throw new Error("Expected a real WebSocket");
socket.on("pong", () => (pongs += 1));
},
updateAttachedPermissions: () => undefined,
createExecutionAgents: () => ({
create: async () => {
throw new Error("Unexpected agent creation");
},
subscribe: () => () => undefined,
invalidateAuthority: async () => undefined,
}),
});
try {
// Keep TCP and WebSocket I/O real; accelerate only the keepalive interval and its clock.
vi.useFakeTimers({ toFake: ["setInterval", "clearInterval", "Date"] });
await controller.connect({
hubUrl: `http://127.0.0.1:${proxyPort}`,
token: "test-token",
permissions: ["hub.execute"],
});
await expect.poll(() => controller.status().state).toBe("connected");
const connectedAt = controller.status().connectedAt;
expect(connections).toBe(1);
// Model the reported proxy request ending without a close reaching the daemon.
dropped = true;
for (const peer of peers) peer.terminate();
await expect.poll(() => peers.size).toBe(0);
await vi.advanceTimersByTimeAsync(60_000);
await expect.poll(() => controller.status().state).toBe("reconnecting");
expect(controller.status().lastError).toBe("Hub WebSocket heartbeat timed out");
expect(retry).toBeTypeOf("function");
dropped = false;
retry?.();
await expect.poll(() => controller.status().state).toBe("connected");
expect(connections).toBe(2);
expect(controller.status().connectedAt).not.toBe(connectedAt);
expect(controller.status().lastError).toBeNull();
// A healthy idle connection survives multiple liveness windows.
for (let tick = 0; tick < 12; tick += 1) {
const before = pongs;
await vi.advanceTimersByTimeAsync(10_000);
await expect.poll(() => pongs).toBeGreaterThan(before);
}
expect(controller.status().state).toBe("connected");
expect(connections).toBe(2);
await controller.stop();
await expect.poll(() => peers.size).toBe(0);
await expect.poll(() => vi.getTimerCount()).toBe(0);
} finally {
await controller.stop();
vi.useRealTimers();
for (const socket of streams) socket.destroy();
for (const peer of peers) peer.terminate();
webSockets.close();
await Promise.all([close(proxy), close(hub)]);
await rm(home, { recursive: true, force: true });
}
});
async function listen(
server: ReturnType<typeof createTcpServer> | ReturnType<typeof createHttpServer>,
) {
await new Promise<void>((resolve) => server.listen(0, "127.0.0.1", resolve));
}
async function close(
server: ReturnType<typeof createTcpServer> | ReturnType<typeof createHttpServer>,
) {
await new Promise<void>((resolve, reject) => {
server.close((error) => {
if (error) {
reject(error);
return;
}
resolve();
});
});
}
@@ -2,9 +2,6 @@ import { WebSocket } from "ws";
import { z } from "zod";
import type { WebSocketLike } from "../websocket-server.js";
const SOCKET_PING_INTERVAL_MS = 10_000;
const SOCKET_PONG_TIMEOUT_MS = 45_000;
export interface HubEnrollment {
daemonId: string;
idempotencyKey: string;
@@ -174,42 +171,13 @@ export class DirectHubRelationshipRemote implements HubRelationshipRemote {
},
});
let settled = false;
let heartbeat: ReturnType<typeof setInterval> | undefined;
const stopHeartbeat = () => {
clearInterval(heartbeat);
heartbeat = undefined;
};
const fail = (error: Error) => {
if (settled) return;
settled = true;
stopHeartbeat();
socket.terminate();
events.failed(error);
};
let lastPongAt = 0;
socket.on("pong", () => {
lastPongAt = Date.now();
});
socket.once("upgrade", (response) => {
if (response.headers["x-paseo-session-protocol"] === "1") {
sessionProtocol = "session-v1";
}
});
socket.once("open", () => {
if (settled) return;
lastPongAt = Date.now();
heartbeat = setInterval(() => {
if (socket.readyState !== WebSocket.OPEN) return;
if (Date.now() - lastPongAt >= SOCKET_PONG_TIMEOUT_MS) {
fail(new Error("Hub WebSocket heartbeat timed out"));
return;
}
socket.ping(undefined, undefined, (error) => {
if (error) fail(error);
});
}, SOCKET_PING_INTERVAL_MS);
heartbeat.unref?.();
events.connected(socket as WebSocketLike, sessionProtocol);
if (!settled) events.connected(socket as WebSocketLike, sessionProtocol);
});
socket.once("unexpected-response", (_request, response) => {
if (settled) {
@@ -226,12 +194,16 @@ export class DirectHubRelationshipRemote implements HubRelationshipRemote {
events.closed(1006);
});
socket.once("close", (code) => {
stopHeartbeat();
if (settled) return;
settled = true;
events.closed(code);
});
socket.once("error", fail);
socket.once("error", (error) => {
if (settled) return;
settled = true;
socket.terminate();
events.failed(error);
});
return socket;
}
@@ -0,0 +1,43 @@
import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { expect, test } from "vitest";
import { stopDaemonInstance } from "./daemon-instance.js";
import { acquirePidLock } from "./pid-lock.js";
// A shared home exposes a live supervisor's lock to a PID namespace in which its PID
// does not exist. The Docker reproduction in #4307 establishes that condition; these
// filesystem tests retain it without requiring a container runtime in the unit suite.
async function withUnseenOwner(run: (home: string, contents: string) => Promise<void>) {
const home = await mkdtemp(join(tmpdir(), "paseo-unseen-owner-"));
const contents = JSON.stringify({
pid: 2_147_483_647,
startedAt: new Date().toISOString(),
hostname: "shared-host",
uid: process.getuid?.() ?? 0,
listen: null,
heartbeat: true,
});
try {
await writeFile(join(home, "paseo.pid"), contents);
await run(home, contents);
} finally {
await rm(home, { recursive: true, force: true });
}
}
test("acquiring a lock preserves a fresh heartbeat from an unseen owner", async () => {
await withUnseenOwner(async (home, contents) => {
await expect(acquirePidLock(home, null)).rejects.toThrow();
expect(await readFile(join(home, "paseo.pid"), "utf8")).toBe(contents);
});
});
test("stopping an unseen owner preserves its fresh shared lock", async () => {
await withUnseenOwner(async (home, contents) => {
// Either refusing the operation or returning without modifying the foreign lock is safe.
// The failure being locked down is deleting the live supervisor's lock.
await stopDaemonInstance(home).catch(() => undefined);
expect(await readFile(join(home, "paseo.pid"), "utf8")).toBe(contents);
});
});