Daemon hardening epic

This commit is contained in:
Mohamed Boudra
2026-05-05 18:19:45 +07:00
parent 1571f002c7
commit 0cb9da17cd
27 changed files with 806 additions and 710 deletions

View File

@@ -0,0 +1,80 @@
import { EventEmitter } from "node:events";
import { beforeEach, describe, expect, test, vi } from "vitest";
const mocks = vi.hoisted(() => ({
spawnSync: vi.fn(),
spawnProcess: vi.fn(),
}));
vi.mock("node:child_process", async () => {
const actual = await vi.importActual<typeof import("node:child_process")>("node:child_process");
return {
...actual,
spawnSync: mocks.spawnSync,
};
});
vi.mock("@getpaseo/server", async () => {
const actual = await vi.importActual<typeof import("@getpaseo/server")>("@getpaseo/server");
return {
...actual,
loadConfig: () => ({ listen: "127.0.0.1:6767" }),
resolvePaseoHome: (env: NodeJS.ProcessEnv) => env.PASEO_HOME ?? "/tmp/paseo",
spawnProcess: mocks.spawnProcess,
};
});
class FakeChildProcess extends EventEmitter {
pid = 4242;
unref = vi.fn();
}
function expectSupervisorLaunch(argv: string[]): void {
const joined = argv.join(" ");
expect(joined).toContain("supervisor-entrypoint");
expect(joined).not.toContain("src/server/index.ts");
expect(joined).not.toContain("dist/server/server/index.js");
expect(joined).not.toContain("src/server/daemon-worker.ts");
expect(joined).not.toContain("dist/server/server/daemon-worker.js");
}
describe("local daemon launch supervision", () => {
beforeEach(() => {
vi.useRealTimers();
mocks.spawnSync.mockReset();
mocks.spawnProcess.mockReset();
});
test("foreground start spawns supervisor-entrypoint instead of server/index", async () => {
mocks.spawnSync.mockReturnValue({ status: 0, error: undefined });
const { startLocalDaemonForeground } = await import("./local-daemon.js");
const status = startLocalDaemonForeground({ home: "/tmp/paseo-test", relay: false });
expect(status).toBe(0);
expect(mocks.spawnSync).toHaveBeenCalledOnce();
const [command, argv] = mocks.spawnSync.mock.calls[0] as [string, string[]];
expect(command).toBe(process.execPath);
expectSupervisorLaunch(argv);
expect(argv).toContain("--no-relay");
});
test("detached start spawns supervisor-entrypoint instead of server/index", async () => {
vi.useFakeTimers();
const child = new FakeChildProcess();
mocks.spawnProcess.mockReturnValue(child);
const { startLocalDaemonDetached } = await import("./local-daemon.js");
const resultPromise = startLocalDaemonDetached({ home: "/tmp/paseo-test", mcp: false });
await vi.advanceTimersByTimeAsync(1200);
const result = await resultPromise;
expect(result).toEqual({ pid: 4242, logPath: "/tmp/paseo-test/daemon.log" });
expect(child.unref).toHaveBeenCalledOnce();
expect(mocks.spawnProcess).toHaveBeenCalledOnce();
const [command, argv] = mocks.spawnProcess.mock.calls[0] as [string, string[]];
expect(command).toBe(process.execPath);
expectSupervisorLaunch(argv);
expect(argv).toContain("--no-mcp");
});
});

View File

@@ -0,0 +1,105 @@
#!/usr/bin/env npx tsx
/**
* Regression: executable daemon launch commands must enter the supervisor.
* The worker entry remains an implementation detail of supervisor-entrypoint.
*/
import assert from "node:assert";
import { readFile } from "node:fs/promises";
import { join } from "node:path";
const repoRoot = join(import.meta.dirname, "../../..");
const serverPackagePath = join(repoRoot, "packages/server/package.json");
const appGlobalSetupPath = join(repoRoot, "packages/app/e2e/global-setup.ts");
const serverConnectionOfferE2ePath = join(
repoRoot,
"packages/server/src/server/daemon-e2e/connection-offer.e2e.test.ts",
);
const desktopRuntimePathsPath = join(repoRoot, "packages/desktop/src/daemon/runtime-paths.ts");
const nixPackagePath = join(repoRoot, "nix/package.nix");
function assertNoDirectWorkerLaunch(label: string, command: string): void {
assert(
!command.includes("src/server/index.ts"),
`${label} must not launch src/server/index.ts directly: ${command}`,
);
assert(
!command.includes("dist/server/server/index.js"),
`${label} must not launch dist/server/server/index.js directly: ${command}`,
);
assert(
!command.includes("src/server/daemon-worker.ts"),
`${label} must not launch src/server/daemon-worker.ts directly: ${command}`,
);
assert(
!command.includes("dist/server/server/daemon-worker.js"),
`${label} must not launch dist/server/server/daemon-worker.js directly: ${command}`,
);
}
function assertNoSpawnedWorkerEntrypoint(label: string, source: string): void {
assertNoDirectWorkerLaunch(label, source);
assert(
!/spawn\([^)]*["'`][^"'`]*\.\.\/index\.ts["'`]/s.test(source),
`${label} must not spawn ../index.ts directly`,
);
}
console.log("=== Daemon Launch Supervision Regression ===\n");
console.log("Test 1: server package scripts launch supervisor-entrypoint");
const serverPackage = JSON.parse(await readFile(serverPackagePath, "utf-8")) as {
scripts?: Record<string, string>;
};
const startScript = serverPackage.scripts?.start ?? "";
const devScript = serverPackage.scripts?.dev ?? "";
const devTsxScript = serverPackage.scripts?.["dev:tsx"] ?? "";
assert(startScript.includes("dist/scripts/supervisor-entrypoint.js"), startScript);
assertNoDirectWorkerLaunch("server start script", startScript);
assert(devScript.includes("scripts/dev-runner.ts"), devScript);
assertNoDirectWorkerLaunch("server dev script", devScript);
assert(devTsxScript.includes("scripts/dev-runner.ts"), devTsxScript);
assertNoDirectWorkerLaunch("server dev:tsx script", devTsxScript);
console.log("✓ server package scripts enter supervisor\n");
console.log("Test 2: app e2e global setup launches supervisor-entrypoint in dev mode");
const appGlobalSetup = await readFile(appGlobalSetupPath, "utf-8");
assert(
appGlobalSetup.includes('spawn(tsxBin, ["scripts/supervisor-entrypoint.ts", "--dev"]'),
"app e2e setup should spawn supervisor-entrypoint.ts with --dev",
);
assertNoSpawnedWorkerEntrypoint("app e2e global setup", appGlobalSetup);
console.log("✓ app e2e setup enters supervisor\n");
console.log("Test 3: server daemon e2e process launch enters supervisor");
const serverConnectionOfferE2e = await readFile(serverConnectionOfferE2ePath, "utf-8");
assert(
serverConnectionOfferE2e.includes("scripts/supervisor-entrypoint.ts"),
"server daemon e2e process launch should use supervisor-entrypoint.ts",
);
assertNoSpawnedWorkerEntrypoint("server daemon e2e process launch", serverConnectionOfferE2e);
console.log("✓ server daemon e2e process launch enters supervisor\n");
console.log("Test 4: desktop runtime and Nix wrapper point at supervisor-entrypoint");
const desktopRuntimePaths = await readFile(desktopRuntimePathsPath, "utf-8");
assert(
desktopRuntimePaths.includes('"dist", "scripts", "supervisor-entrypoint.js"'),
"desktop packaged daemon runner should resolve dist/scripts/supervisor-entrypoint.js",
);
assert(
desktopRuntimePaths.includes('"scripts", "supervisor-entrypoint.ts"'),
"desktop dev daemon runner should resolve scripts/supervisor-entrypoint.ts",
);
assertNoDirectWorkerLaunch("desktop runtime paths", desktopRuntimePaths);
const nixPackage = await readFile(nixPackagePath, "utf-8");
assert(
nixPackage.includes("dist/scripts/supervisor-entrypoint.js"),
"Nix paseo-server wrapper should use dist/scripts/supervisor-entrypoint.js",
);
assertNoDirectWorkerLaunch("Nix package wrapper", nixPackage);
console.log("✓ desktop runtime and Nix wrapper enter supervisor\n");
console.log("=== Daemon launch supervision regression test passed ===");

View File

@@ -1,198 +0,0 @@
#!/usr/bin/env npx tsx
/**
* Regression: unsupervised restart request should gracefully stop and exit 0,
* so an external owner can decide whether to respawn.
*/
import assert from "node:assert";
import { spawn, type ChildProcess } from "node:child_process";
import { mkdtemp, readFile, rm } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { tryConnectToDaemon } from "../src/utils/client.ts";
import { getAvailablePort } from "./helpers/network.ts";
const pollIntervalMs = 100;
const testEnv = {
PASEO_LOCAL_SPEECH_AUTO_DOWNLOAD: process.env.PASEO_LOCAL_SPEECH_AUTO_DOWNLOAD ?? "0",
PASEO_DICTATION_ENABLED: process.env.PASEO_DICTATION_ENABLED ?? "0",
PASEO_VOICE_MODE_ENABLED: process.env.PASEO_VOICE_MODE_ENABLED ?? "0",
};
function sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
function isProcessRunning(pid: number): boolean {
if (!Number.isInteger(pid) || pid <= 0) {
return false;
}
try {
process.kill(pid, 0);
return true;
} catch {
return false;
}
}
async function waitFor(
check: () => Promise<boolean> | boolean,
timeoutMs: number,
message: string,
): Promise<void> {
const deadline = Date.now() + timeoutMs;
async function poll(): Promise<void> {
if (await check()) return;
if (Date.now() >= deadline) throw new Error(message);
await sleep(pollIntervalMs);
return poll();
}
return poll();
}
interface ExitResult {
code: number | null;
signal: NodeJS.Signals | null;
}
function waitForProcessExit(processRef: ChildProcess, timeoutMs: number): Promise<ExitResult> {
return new Promise((resolve, reject) => {
const timeout = setTimeout(() => {
reject(new Error("timed out waiting for process exit"));
}, timeoutMs);
processRef.once("exit", (code, signal) => {
clearTimeout(timeout);
resolve({ code, signal });
});
});
}
async function canConnectToDaemon(host: string, timeoutMs: number): Promise<boolean> {
const deadline = Date.now() + timeoutMs;
async function poll(): Promise<boolean> {
const client = await tryConnectToDaemon({ host, timeout: 500 }).catch(() => null);
if (client) {
await client.close().catch(() => undefined);
return true;
}
if (Date.now() >= deadline) return false;
await sleep(pollIntervalMs);
return poll();
}
return poll();
}
async function readPidLockPid(paseoHome: string): Promise<number | null> {
const pidPath = join(paseoHome, "paseo.pid");
try {
const content = await readFile(pidPath, "utf-8");
const parsed = JSON.parse(content) as { pid?: unknown };
if (typeof parsed.pid !== "number" || !Number.isInteger(parsed.pid) || parsed.pid <= 0) {
return null;
}
return parsed.pid;
} catch {
return null;
}
}
console.log("=== Daemon Restart (unsupervised regression) ===\n");
const port = await getAvailablePort();
const paseoHome = await mkdtemp(join(tmpdir(), "paseo-restart-unsupervised-"));
const cliRoot = join(import.meta.dirname, "..");
const host = `127.0.0.1:${port}`;
let daemonProcess: ChildProcess | null = null;
let recentDaemonLogs = "";
try {
console.log("Test 1: start unsupervised daemon worker directly");
daemonProcess = spawn(process.execPath, ["--import", "tsx", "../server/src/server/index.ts"], {
cwd: cliRoot,
env: {
...process.env,
...testEnv,
PASEO_HOME: paseoHome,
PASEO_LISTEN: host,
PASEO_RELAY_ENABLED: "false",
CI: "true",
},
stdio: ["ignore", "pipe", "pipe"],
});
daemonProcess.stdout?.on("data", (chunk) => {
recentDaemonLogs = (recentDaemonLogs + chunk.toString()).slice(-8000);
});
daemonProcess.stderr?.on("data", (chunk) => {
recentDaemonLogs = (recentDaemonLogs + chunk.toString()).slice(-8000);
});
await waitFor(
async () =>
Boolean(daemonProcess?.pid && isProcessRunning(daemonProcess.pid)) &&
(await canConnectToDaemon(host, 1000)),
120000,
"daemon did not become running in time",
);
assert(daemonProcess.pid, "unsupervised daemon process pid should exist");
const lockPid = await readPidLockPid(paseoHome);
assert.strictEqual(lockPid, daemonProcess.pid, "unsupervised worker should own pid lock");
console.log(`✓ unsupervised daemon started with pid ${daemonProcess.pid}\n`);
console.log("Test 2: restart request should gracefully stop and exit code 0");
const client = await tryConnectToDaemon({ host, timeout: 5000 });
assert(client, "daemon client should connect");
const exitPromise = waitForProcessExit(daemonProcess, 30000);
try {
const restartAck = await client.restartServer("settings_update");
assert.strictEqual(
restartAck.status,
"restart_requested",
"restart request should be acknowledged",
);
} finally {
await client?.close().catch(() => undefined);
}
const exit = await exitPromise;
assert.strictEqual(exit.signal, null, `daemon should exit cleanly, got signal=${exit.signal}`);
assert.strictEqual(
exit.code,
0,
`daemon should exit with status 0, got code=${exit.code}\nRecent daemon logs:\n${recentDaemonLogs}`,
);
await waitFor(
async () => (await readPidLockPid(paseoHome)) === null,
15000,
"pid lock was not released after unsupervised restart request",
);
console.log("✓ unsupervised restart exited cleanly with code 0\n");
} finally {
if (daemonProcess?.pid && isProcessRunning(daemonProcess.pid)) {
daemonProcess.kill("SIGTERM");
await waitFor(
() => !isProcessRunning(daemonProcess!.pid ?? -1),
5000,
"daemon cleanup timed out",
).catch(() => {
daemonProcess?.kill("SIGKILL");
});
}
await rm(paseoHome, { recursive: true, force: true });
}
console.log("=== Unsupervised restart regression test passed ===");