mirror of
https://github.com/getpaseo/paseo.git
synced 2026-11-06 08:24:46 +00:00
fix(daemon): keep lifecycle operations on the selected instance
Restart reconstructed launch inputs and address fallback could redirect a home-selected shutdown to another daemon. Let supervisors publish bound endpoints, retain their worker restart contract, and share captured local lifecycle ownership between CLI and Desktop.
This commit is contained in:
@@ -140,7 +140,7 @@ try {
|
||||
const result = await daemonCommand(["status"]);
|
||||
assert.strictEqual(result.exitCode, 0, "status should succeed when daemon is stopped");
|
||||
const output = result.stdout.toLowerCase();
|
||||
assert(output.includes("local daemon"), "status table should include Local Daemon row");
|
||||
assert(output.includes("localdaemon"), "status table should include Local Daemon row");
|
||||
assert(output.includes("stopped"), "status should report stopped");
|
||||
console.log("✓ daemon status reports stopped when not running\n");
|
||||
}
|
||||
@@ -164,7 +164,7 @@ try {
|
||||
const result = await daemonCommand(["status", "--json"]);
|
||||
assert.strictEqual(result.exitCode, 0, "--json status should succeed");
|
||||
const status = JSON.parse(result.stdout);
|
||||
assert.strictEqual(typeof status.serverId, "string", "json status should include serverId");
|
||||
assert.strictEqual(status.serverId, undefined, "stopped observation must not invent identity");
|
||||
assert.strictEqual(status.localDaemon, "stopped", "json status should report stopped");
|
||||
assert.strictEqual(status.home, paseoHome, "json status should reflect the isolated home");
|
||||
assert.strictEqual(
|
||||
@@ -175,20 +175,12 @@ try {
|
||||
console.log("✓ daemon status --json outputs valid JSON\n");
|
||||
}
|
||||
|
||||
// Global hosts do not change local daemon lifecycle commands.
|
||||
{
|
||||
console.log("Test 5b: daemon status ignores a global --host");
|
||||
const result = await runLocalPaseo(["--host", "localhost:1", "daemon", "status", "--json"], {
|
||||
const result = await runLocalPaseo(["--host", "127.0.0.1:1", "daemon", "status", "--json"], {
|
||||
PASEO_HOME: paseoHome,
|
||||
});
|
||||
assert.strictEqual(result.exitCode, 0, `daemon status should stay local: ${result.stderr}`);
|
||||
const parsed = JSON.parse(result.stdout);
|
||||
assert.strictEqual(
|
||||
parsed.localDaemon,
|
||||
"stopped",
|
||||
"daemon should report the local stopped state",
|
||||
);
|
||||
console.log("✓ daemon status ignores a global --host\n");
|
||||
assert.strictEqual(result.exitCode, 1, "an explicit endpoint must not report local status");
|
||||
assert.match(result.stderr, /127.0.0.1:1/);
|
||||
}
|
||||
|
||||
// Test 6: daemon stop handles daemon not running gracefully
|
||||
@@ -205,16 +197,12 @@ try {
|
||||
console.log("✓ daemon stop succeeds gracefully when daemon not running\n");
|
||||
}
|
||||
|
||||
// Test 7: daemon restart starts daemon and can be stopped
|
||||
{
|
||||
console.log("Test 7: daemon restart starts daemon and can be stopped");
|
||||
const result = await daemonCommand(["restart", "--port", String(port)]);
|
||||
assert.strictEqual(result.exitCode, 0, "restart should succeed even when previously stopped");
|
||||
assert(result.stdout.toLowerCase().includes("restarted"), "output should report restart");
|
||||
|
||||
const cleanup = await daemonCommand(["stop", "--force"]);
|
||||
assert.strictEqual(cleanup.exitCode, 0, "cleanup stop should succeed after restart");
|
||||
console.log("✓ daemon restart starts and stop cleanup succeeds\n");
|
||||
const result = await daemonCommand(["restart", "--json"]);
|
||||
assert.strictEqual(result.exitCode, 1, "restart must not start a stopped daemon");
|
||||
assert.match(result.stderr, /DAEMON_NOT_RUNNING/);
|
||||
const configured = await daemonCommand(["config", "set", "daemon.listen", `127.0.0.1:${port}`]);
|
||||
assert.strictEqual(configured.exitCode, 0, configured.stderr);
|
||||
}
|
||||
|
||||
// Test 8: status uses the running daemon as relay authority over local IPC
|
||||
@@ -256,10 +244,10 @@ try {
|
||||
const relayProbe = await retryWhileWorkerRuns(
|
||||
worker,
|
||||
async () => {
|
||||
const statusResult = await daemonCommand(["status", "--json"]);
|
||||
const statusResult = await daemonCommand(["status", "--host", listen, "--json"]);
|
||||
const statusPayload =
|
||||
statusResult.exitCode === 0 ? JSON.parse(statusResult.stdout) : null;
|
||||
const pairingResult = await daemonCommand(["pair", "--json"]);
|
||||
const pairingResult = await daemonCommand(["pair", "--host", listen, "--json"]);
|
||||
return { statusResult, statusPayload, pairingResult };
|
||||
},
|
||||
(result) =>
|
||||
@@ -280,8 +268,8 @@ try {
|
||||
assert.strictEqual(payload.connectedDaemon, "reachable", "IPC daemon should be reachable");
|
||||
assert.strictEqual(
|
||||
payload.localDaemon,
|
||||
"stopped",
|
||||
"missing PID should report stopped locally",
|
||||
undefined,
|
||||
"endpoint status must not invent local PID state",
|
||||
);
|
||||
assert.notStrictEqual(payload.relay, "disabled", "status should use live relay state");
|
||||
assert.strictEqual(
|
||||
@@ -335,31 +323,16 @@ try {
|
||||
try {
|
||||
await writeFile(
|
||||
join(foreignHome, "config.json"),
|
||||
`${JSON.stringify({ daemon: { listen } }, null, 2)}\n`,
|
||||
`${JSON.stringify({ daemon: { listen, relay: { enabled: false } } }, null, 2)}\n`,
|
||||
"utf-8",
|
||||
);
|
||||
const foreignPairing = await retryWhileWorkerRuns(
|
||||
worker,
|
||||
() =>
|
||||
runLocalPaseo(["daemon", "pair", "--home", foreignHome, "--json"], {
|
||||
PASEO_HOME: foreignHome,
|
||||
}),
|
||||
(result) => result.stderr.includes("different Paseo home"),
|
||||
(result) => `Pairing did not report the daemon identity mismatch: ${result.stderr}`,
|
||||
);
|
||||
assert.notStrictEqual(
|
||||
foreignPairing.exitCode,
|
||||
0,
|
||||
"pairing should reject a daemon owned by another home",
|
||||
);
|
||||
assert(
|
||||
foreignPairing.stderr.includes("different Paseo home"),
|
||||
"pairing should explain the daemon identity mismatch",
|
||||
);
|
||||
assert(
|
||||
!foreignPairing.stdout.includes("#offer="),
|
||||
"pairing should not expose another offer",
|
||||
const foreignPairing = await runLocalPaseo(
|
||||
["daemon", "pair", "--home", foreignHome, "--json"],
|
||||
{ PASEO_HOME: foreignHome },
|
||||
);
|
||||
assert.strictEqual(foreignPairing.exitCode, 1);
|
||||
assert.match(foreignPairing.stderr, /RELAY_DISABLED/);
|
||||
assert(!foreignPairing.stdout.includes("#offer="));
|
||||
} finally {
|
||||
await rm(foreignHome, { recursive: true, force: true });
|
||||
}
|
||||
|
||||
@@ -1,53 +0,0 @@
|
||||
#!/usr/bin/env npx tsx
|
||||
|
||||
/**
|
||||
* Phase 18: Local daemon utility tests.
|
||||
*
|
||||
* Tests pure helpers that do not require a running daemon.
|
||||
*/
|
||||
|
||||
import assert from "node:assert";
|
||||
import { resolveTcpHostFromListen } from "../src/commands/daemon/local-daemon.js";
|
||||
|
||||
console.log("=== Local Daemon Utility Helpers ===\n");
|
||||
|
||||
{
|
||||
console.log("Test 1: resolves numeric listen values to localhost host:port");
|
||||
assert.strictEqual(resolveTcpHostFromListen("6767"), "127.0.0.1:6767");
|
||||
assert.strictEqual(resolveTcpHostFromListen(" 7777 "), "127.0.0.1:7777");
|
||||
console.log("✓ resolves numeric listen values\n");
|
||||
}
|
||||
|
||||
{
|
||||
console.log("Test 2: preserves explicit host:port listen values");
|
||||
assert.strictEqual(resolveTcpHostFromListen("localhost:6767"), "localhost:6767");
|
||||
assert.strictEqual(resolveTcpHostFromListen("0.0.0.0:8080"), "0.0.0.0:8080");
|
||||
console.log("✓ preserves explicit host:port values\n");
|
||||
}
|
||||
|
||||
{
|
||||
console.log("Test 3: rejects unix socket listen values");
|
||||
assert.strictEqual(resolveTcpHostFromListen("/tmp/paseo.sock"), null);
|
||||
assert.strictEqual(resolveTcpHostFromListen("unix:///tmp/paseo.sock"), null);
|
||||
assert.strictEqual(resolveTcpHostFromListen("pipe://\\\\.\\pipe\\paseo-managed-test"), null);
|
||||
assert.strictEqual(resolveTcpHostFromListen("\\\\.\\pipe\\paseo-managed-test"), null);
|
||||
console.log("✓ rejects unix socket listen values\n");
|
||||
}
|
||||
|
||||
{
|
||||
console.log("Test 4: rejects empty and non-host listen values");
|
||||
assert.strictEqual(resolveTcpHostFromListen(""), null);
|
||||
assert.strictEqual(resolveTcpHostFromListen(" "), null);
|
||||
assert.strictEqual(resolveTcpHostFromListen("localhost"), null);
|
||||
console.log("✓ rejects empty and non-host listen values\n");
|
||||
}
|
||||
|
||||
{
|
||||
console.log("Test 5: rejects Windows absolute paths (not TCP endpoints)");
|
||||
assert.strictEqual(resolveTcpHostFromListen("C:\\Users\\foo\\.paseo\\paseo.sock"), null);
|
||||
assert.strictEqual(resolveTcpHostFromListen("D:\\project\\socket"), null);
|
||||
assert.strictEqual(resolveTcpHostFromListen("C:\\paseo.sock"), null);
|
||||
console.log("✓ rejects Windows absolute paths\n");
|
||||
}
|
||||
|
||||
console.log("=== All local daemon utility tests passed ===");
|
||||
@@ -1,38 +0,0 @@
|
||||
#!/usr/bin/env npx tsx
|
||||
|
||||
import assert from "node:assert";
|
||||
import { homedir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { resolvePaseoHomePath, resolvePaseoWorktreesDir } from "../src/commands/worktree/ls.js";
|
||||
|
||||
console.log("=== Worktree LS Path Helper Tests ===\n");
|
||||
|
||||
const originalPaseoHome = process.env.PASEO_HOME;
|
||||
|
||||
try {
|
||||
{
|
||||
console.log("Test 1: resolves explicit PASEO_HOME when set");
|
||||
process.env.PASEO_HOME = "/tmp/paseo-explicit-home";
|
||||
|
||||
assert.strictEqual(resolvePaseoHomePath(), "/tmp/paseo-explicit-home");
|
||||
assert.strictEqual(resolvePaseoWorktreesDir(), "/tmp/paseo-explicit-home/worktrees");
|
||||
console.log("\u2713 explicit PASEO_HOME is respected\n");
|
||||
}
|
||||
|
||||
{
|
||||
console.log("Test 2: falls back to homedir/.paseo when PASEO_HOME is unset");
|
||||
delete process.env.PASEO_HOME;
|
||||
|
||||
assert.strictEqual(resolvePaseoHomePath(), join(homedir(), ".paseo"));
|
||||
assert.strictEqual(resolvePaseoWorktreesDir(), join(homedir(), ".paseo", "worktrees"));
|
||||
console.log("\u2713 fallback home path is derived from os.homedir()\n");
|
||||
}
|
||||
} finally {
|
||||
if (originalPaseoHome === undefined) {
|
||||
delete process.env.PASEO_HOME;
|
||||
} else {
|
||||
process.env.PASEO_HOME = originalPaseoHome;
|
||||
}
|
||||
}
|
||||
|
||||
console.log("=== All worktree ls path helper tests passed ===");
|
||||
@@ -213,8 +213,9 @@ try {
|
||||
);
|
||||
const capturedSupervisorLogs = await readCapturedSupervisorLogs(paseoHome, recentSupervisorLogs);
|
||||
assert(
|
||||
capturedSupervisorLogs.includes('"msg":"Worker requested shutdown"') &&
|
||||
capturedSupervisorLogs.includes('"reason":"client_shutdown_rpc"'),
|
||||
process.platform === "win32"
|
||||
? capturedSupervisorLogs.includes('"reason":"client_shutdown_rpc"')
|
||||
: capturedSupervisorLogs.includes("supervisor_received_SIGTERM"),
|
||||
`stop should log lifecycle shutdown reason from daemon worker, logs:\n${capturedSupervisorLogs}`,
|
||||
);
|
||||
assert(
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
*/
|
||||
|
||||
import assert from "node:assert";
|
||||
import { spawn, spawnSync, type ChildProcess } from "node:child_process";
|
||||
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";
|
||||
@@ -40,57 +40,33 @@ function isProcessRunning(pid: number): boolean {
|
||||
}
|
||||
}
|
||||
|
||||
function readWorkerPid(supervisorPid: number): number | null {
|
||||
if (!Number.isInteger(supervisorPid) || supervisorPid <= 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const result = spawnSync("ps", ["ax", "-o", "pid=,ppid="], { encoding: "utf8" });
|
||||
if (result.status !== 0 || result.error) {
|
||||
return null;
|
||||
}
|
||||
|
||||
for (const line of result.stdout.split("\n")) {
|
||||
const trimmed = line.trim();
|
||||
if (!trimmed) {
|
||||
continue;
|
||||
}
|
||||
const [pidToken, ppidToken] = trimmed.split(/\s+/);
|
||||
const pid = Number.parseInt(pidToken ?? "", 10);
|
||||
const ppid = Number.parseInt(ppidToken ?? "", 10);
|
||||
if (!Number.isInteger(pid) || !Number.isInteger(ppid)) {
|
||||
continue;
|
||||
}
|
||||
if (ppid === supervisorPid && pid > 0) {
|
||||
return pid;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
interface DaemonStatus {
|
||||
localDaemon: string | null;
|
||||
pid: number | null;
|
||||
workerPid: number | null;
|
||||
}
|
||||
|
||||
async function readDaemonStatus(paseoHome: string): Promise<DaemonStatus> {
|
||||
const result =
|
||||
await $`PASEO_HOME=${paseoHome} PASEO_LOCAL_SPEECH_AUTO_DOWNLOAD=${testEnv.PASEO_LOCAL_SPEECH_AUTO_DOWNLOAD} PASEO_DICTATION_ENABLED=${testEnv.PASEO_DICTATION_ENABLED} PASEO_VOICE_MODE_ENABLED=${testEnv.PASEO_VOICE_MODE_ENABLED} npx paseo daemon status --home ${paseoHome} --json`.nothrow();
|
||||
if (result.exitCode !== 0) {
|
||||
return { localDaemon: null, pid: null };
|
||||
return { localDaemon: null, pid: null, workerPid: null };
|
||||
}
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(result.stdout) as { localDaemon?: unknown; pid?: unknown };
|
||||
const parsed = JSON.parse(result.stdout) as {
|
||||
localDaemon?: unknown;
|
||||
pid?: unknown;
|
||||
workerPid?: number;
|
||||
};
|
||||
const localDaemon = typeof parsed.localDaemon === "string" ? parsed.localDaemon : null;
|
||||
const pid =
|
||||
typeof parsed.pid === "number" && Number.isInteger(parsed.pid) && parsed.pid > 0
|
||||
? parsed.pid
|
||||
: null;
|
||||
return { localDaemon, pid };
|
||||
return { localDaemon, pid, workerPid: parsed.workerPid ?? null };
|
||||
} catch {
|
||||
return { localDaemon: null, pid: null };
|
||||
return { localDaemon: null, pid: null, workerPid: null };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -173,7 +149,7 @@ try {
|
||||
);
|
||||
assert(supervisorPid !== null, "supervisor pid should exist once daemon starts");
|
||||
assert(isProcessRunning(supervisorPid), "supervisor process should be running");
|
||||
const workerPidBeforeRestart = readWorkerPid(supervisorPid);
|
||||
const workerPidBeforeRestart = statusBeforeRestart.workerPid;
|
||||
assert(workerPidBeforeRestart !== null, "supervisor should have a worker process before restart");
|
||||
assert(
|
||||
isProcessRunning(workerPidBeforeRestart),
|
||||
@@ -184,7 +160,7 @@ try {
|
||||
);
|
||||
|
||||
console.log("Test 2: app-style restart request should restart worker and keep daemon healthy");
|
||||
const client = await tryConnectToDaemon({ host, timeout: 5000 });
|
||||
const client = await tryConnectToDaemon({ target: { kind: "endpoint", host }, timeout: 5000 });
|
||||
assert(client, "daemon client should connect");
|
||||
try {
|
||||
const restartAck = await client.restartServer("settings_update");
|
||||
@@ -198,8 +174,8 @@ try {
|
||||
}
|
||||
|
||||
await waitFor(
|
||||
() => {
|
||||
const workerPid = readWorkerPid(supervisorPid);
|
||||
async () => {
|
||||
const workerPid = (await readDaemonStatus(paseoHome)).workerPid;
|
||||
return (
|
||||
workerPid !== null && workerPid !== workerPidBeforeRestart && isProcessRunning(workerPid)
|
||||
);
|
||||
@@ -208,7 +184,7 @@ try {
|
||||
"worker pid did not change after restart request",
|
||||
);
|
||||
|
||||
const workerPidAfterRestart = readWorkerPid(supervisorPid);
|
||||
const workerPidAfterRestart = (await readDaemonStatus(paseoHome)).workerPid;
|
||||
assert(workerPidAfterRestart !== null, "worker process should exist after restart");
|
||||
assert.notStrictEqual(
|
||||
workerPidAfterRestart,
|
||||
|
||||
@@ -1,16 +1,13 @@
|
||||
#!/usr/bin/env npx tsx
|
||||
|
||||
import assert from "node:assert";
|
||||
import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import {
|
||||
getDaemonHost,
|
||||
normalizeDaemonHost,
|
||||
resolveDaemonPassword,
|
||||
resolveDaemonTarget,
|
||||
resolveDefaultDaemonHosts,
|
||||
} from "../src/utils/client.js";
|
||||
import { selectDaemonTarget } from "../src/utils/daemon-target.js";
|
||||
import { resolveCliVersion } from "../src/version.js";
|
||||
|
||||
console.log("=== CLI IPC Target Helpers ===\n");
|
||||
@@ -81,48 +78,13 @@ console.log("=== CLI IPC Target Helpers ===\n");
|
||||
}
|
||||
|
||||
{
|
||||
console.log("Test 6: default host resolution tries local IPC first, then localhost fallback");
|
||||
const paseoHome = mkdtempSync(path.join(os.tmpdir(), "paseo-client-targets-"));
|
||||
try {
|
||||
mkdirSync(paseoHome, { recursive: true });
|
||||
writeFileSync(
|
||||
path.join(paseoHome, "paseo.pid"),
|
||||
JSON.stringify({ pid: process.pid, listen: "/tmp/paseo-from-pid.sock" }),
|
||||
);
|
||||
assert.deepStrictEqual(resolveDefaultDaemonHosts({ PASEO_HOME: paseoHome }), [
|
||||
"unix:///tmp/paseo-from-pid.sock",
|
||||
"localhost:6767",
|
||||
]);
|
||||
const previousHome = process.env.PASEO_HOME;
|
||||
const previousHost = process.env.PASEO_HOST;
|
||||
process.env.PASEO_HOME = paseoHome;
|
||||
delete process.env.PASEO_HOST;
|
||||
assert.strictEqual(getDaemonHost(), "unix:///tmp/paseo-from-pid.sock");
|
||||
if (previousHome === undefined) delete process.env.PASEO_HOME;
|
||||
else process.env.PASEO_HOME = previousHome;
|
||||
if (previousHost === undefined) delete process.env.PASEO_HOST;
|
||||
else process.env.PASEO_HOST = previousHost;
|
||||
} finally {
|
||||
rmSync(paseoHome, { recursive: true, force: true });
|
||||
}
|
||||
console.log("✓ default host resolution tries local IPC first, then localhost fallback\n");
|
||||
}
|
||||
|
||||
{
|
||||
console.log("Test 7: configured TCP host is preserved before the localhost fallback");
|
||||
const paseoHome = mkdtempSync(path.join(os.tmpdir(), "paseo-client-targets-tcp-"));
|
||||
try {
|
||||
assert.deepStrictEqual(
|
||||
resolveDefaultDaemonHosts({
|
||||
PASEO_HOME: paseoHome,
|
||||
PASEO_LISTEN: "127.0.0.1:7777",
|
||||
}),
|
||||
["127.0.0.1:7777", "localhost:6767"],
|
||||
);
|
||||
} finally {
|
||||
rmSync(paseoHome, { recursive: true, force: true });
|
||||
}
|
||||
console.log("✓ configured TCP host is preserved before the localhost fallback\n");
|
||||
const target = selectDaemonTarget(
|
||||
{ home: "/tmp/selected-home" },
|
||||
{ PASEO_HOST: "ignored:12345", PASEO_LISTEN: "ignored:23456" },
|
||||
);
|
||||
assert.deepStrictEqual(target, { kind: "instance", home: "/tmp/selected-home" });
|
||||
assert.strictEqual(getDaemonHost({ target }), "home /tmp/selected-home");
|
||||
assert.throws(() => selectDaemonTarget({}, { PASEO_HOME: "/tmp/a", PASEO_HOST: "unused:12345" }));
|
||||
}
|
||||
|
||||
{
|
||||
@@ -131,28 +93,6 @@ console.log("=== CLI IPC Target Helpers ===\n");
|
||||
console.log("✓ CLI app version resolves for daemon hello compatibility\n");
|
||||
}
|
||||
|
||||
{
|
||||
console.log("Test 9: local IPC still takes priority over configured TCP hosts");
|
||||
const paseoHome = mkdtempSync(path.join(os.tmpdir(), "paseo-client-targets-order-"));
|
||||
try {
|
||||
mkdirSync(paseoHome, { recursive: true });
|
||||
writeFileSync(
|
||||
path.join(paseoHome, "paseo.pid"),
|
||||
JSON.stringify({ pid: process.pid, listen: "/tmp/paseo-priority.sock" }),
|
||||
);
|
||||
assert.deepStrictEqual(
|
||||
resolveDefaultDaemonHosts({
|
||||
PASEO_HOME: paseoHome,
|
||||
PASEO_LISTEN: "127.0.0.1:7777",
|
||||
}),
|
||||
["unix:///tmp/paseo-priority.sock", "127.0.0.1:7777", "localhost:6767"],
|
||||
);
|
||||
} finally {
|
||||
rmSync(paseoHome, { recursive: true, force: true });
|
||||
}
|
||||
console.log("✓ local IPC still takes priority over configured TCP hosts\n");
|
||||
}
|
||||
|
||||
{
|
||||
console.log("Test 10: daemon password resolution prefers TCP URI query, falls back to env");
|
||||
const previousEnv = process.env.PASEO_PASSWORD;
|
||||
|
||||
@@ -51,7 +51,10 @@ try {
|
||||
const config = JSON.parse(await readFile(join(paseoHome, "config.json"), "utf-8"));
|
||||
|
||||
assert.strictEqual(result.configPath, join(paseoHome, "config.json"));
|
||||
assert.strictEqual(result.restartCommand, "paseo daemon restart");
|
||||
assert.strictEqual(
|
||||
result.restartCommand,
|
||||
`paseo daemon restart --home ${JSON.stringify(paseoHome)}`,
|
||||
);
|
||||
assert.strictEqual(config.daemon.listen, "127.0.0.1:9999");
|
||||
assert.strictEqual(config.daemon.relay.enabled, false);
|
||||
assert.notStrictEqual(config.daemon.auth.password, "shared-secret");
|
||||
@@ -68,6 +71,7 @@ try {
|
||||
const result = await runSetPasswordCommand(
|
||||
{
|
||||
home: paseoHome,
|
||||
daemonTarget: { kind: "instance", home: paseoHome },
|
||||
promptPassword: promptSequence(["new-secret", "new-secret"]),
|
||||
},
|
||||
{} as Command,
|
||||
@@ -88,6 +92,7 @@ try {
|
||||
runSetPasswordCommand(
|
||||
{
|
||||
home: paseoHome,
|
||||
daemonTarget: { kind: "instance", home: paseoHome },
|
||||
promptPassword: promptSequence(["first-secret", "second-secret"]),
|
||||
},
|
||||
{} as Command,
|
||||
|
||||
@@ -118,6 +118,8 @@ try {
|
||||
join(paseoHome, "paseo.pid"),
|
||||
JSON.stringify({
|
||||
pid: ownerProcess.pid,
|
||||
hostname: "test",
|
||||
uid: process.getuid?.() ?? -1,
|
||||
listen: "127.0.0.1:1",
|
||||
startedAt: new Date().toISOString(),
|
||||
}),
|
||||
@@ -153,15 +155,11 @@ try {
|
||||
action: parsed.action,
|
||||
forced: parsed.forced,
|
||||
usedLifecycleRpc: parsed.usedLifecycleRpc,
|
||||
reason: parsed.reason,
|
||||
message: parsed.message,
|
||||
},
|
||||
{
|
||||
action: "stopped",
|
||||
forced: true,
|
||||
usedLifecycleRpc: false,
|
||||
reason: "owner_pid_sigkill",
|
||||
message: "Daemon owner process was force-stopped",
|
||||
},
|
||||
`stop should report forced tree cleanup: ${stopResult.stdout}`,
|
||||
);
|
||||
|
||||
@@ -26,7 +26,7 @@ try {
|
||||
assert.strictEqual(status.connectedDaemon, "auth_required");
|
||||
assert(!("runningAgents" in status), "status should not fetch agent counts");
|
||||
assert(!("idleAgents" in status), "status should not fetch agent counts");
|
||||
assert.match(status.note, /requires a password/i);
|
||||
assert.match(status.note, /Password required/i);
|
||||
assert.doesNotMatch(status.note, /not reachable/i);
|
||||
console.log("✓ missing password reports auth_required\n");
|
||||
}
|
||||
@@ -44,7 +44,7 @@ try {
|
||||
|
||||
assert.strictEqual(status.localDaemon, "running");
|
||||
assert.strictEqual(status.connectedDaemon, "auth_failed");
|
||||
assert.match(status.note, /password was rejected/i);
|
||||
assert.match(status.note, /Incorrect password/i);
|
||||
assert.doesNotMatch(status.note, /not reachable/i);
|
||||
console.log("✓ wrong password reports auth_failed\n");
|
||||
}
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
#!/usr/bin/env npx tsx
|
||||
|
||||
/**
|
||||
* Regression: `paseo daemon stop` must stop a reachable daemon even when the
|
||||
* local pid file points at a dead supervisor owner.
|
||||
* Regression: `paseo daemon stop` must leave a reachable daemon alone when the
|
||||
* selected home points at a dead supervisor owner.
|
||||
*/
|
||||
|
||||
import assert from "node:assert";
|
||||
@@ -12,6 +12,7 @@ import { mkdtemp, rm, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { $ } from "zx";
|
||||
import { connectToDaemon } from "../src/utils/client.js";
|
||||
import { getAvailablePort } from "./helpers/network.ts";
|
||||
|
||||
$.verbose = false;
|
||||
@@ -146,21 +147,24 @@ try {
|
||||
|
||||
await waitFor(
|
||||
async () => {
|
||||
const status = await readDaemonStatus(paseoHome);
|
||||
return status.localDaemon === "stale_pid" && status.connectedDaemon === "reachable";
|
||||
try {
|
||||
const client = await connectToDaemon({ target: { kind: "endpoint", host }, timeout: 500 });
|
||||
await client.close();
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
},
|
||||
120000,
|
||||
"daemon did not enter stale_pid + reachable state in time",
|
||||
"unowned worker did not become reachable in time",
|
||||
);
|
||||
|
||||
const statusBeforeStop = await readDaemonStatus(paseoHome);
|
||||
assert.strictEqual(statusBeforeStop.pid, stalePid, "status should report the stale owner pid");
|
||||
assert.strictEqual(statusBeforeStop.pid, null, "status should not claim a dead owner is running");
|
||||
assert(workerProcess.pid && isProcessRunning(workerProcess.pid), "worker should be running");
|
||||
console.log(`✓ fixture has stale pid ${stalePid} and live worker ${workerProcess.pid}\n`);
|
||||
|
||||
console.log(
|
||||
"Test 2: `paseo daemon stop` should stop reachable worker instead of saying not_running",
|
||||
);
|
||||
console.log("Test 2: home-selected stop leaves the unowned reachable worker running");
|
||||
const stopResult =
|
||||
await $`PASEO_HOME=${paseoHome} PASEO_LOCAL_SPEECH_AUTO_DOWNLOAD=${testEnv.PASEO_LOCAL_SPEECH_AUTO_DOWNLOAD} PASEO_DICTATION_ENABLED=${testEnv.PASEO_DICTATION_ENABLED} PASEO_VOICE_MODE_ENABLED=${testEnv.PASEO_VOICE_MODE_ENABLED} npx paseo daemon stop --home ${paseoHome} --json`.nothrow();
|
||||
assert.strictEqual(stopResult.exitCode, 0, `stop should succeed: ${stopResult.stderr}`);
|
||||
@@ -169,22 +173,11 @@ try {
|
||||
pid?: unknown;
|
||||
message?: unknown;
|
||||
};
|
||||
assert.strictEqual(stopJson.action, "stopped", "stop should report stopped action");
|
||||
assert.strictEqual(
|
||||
stopJson.pid,
|
||||
String(stalePid),
|
||||
"stop should report the stale pid it recovered from",
|
||||
);
|
||||
assert.strictEqual(
|
||||
stopJson.message,
|
||||
"Daemon stopped gracefully",
|
||||
"stop should route through lifecycle shutdown",
|
||||
);
|
||||
|
||||
await waitFor(
|
||||
() => !isProcessRunning(workerProcess?.pid ?? -1),
|
||||
15000,
|
||||
"worker remained running after stop",
|
||||
assert.strictEqual(stopJson.action, "not_running");
|
||||
assert.strictEqual(stopJson.pid, stalePid);
|
||||
assert(
|
||||
workerProcess.pid && isProcessRunning(workerProcess.pid),
|
||||
"stop must not contact the stale endpoint",
|
||||
);
|
||||
assert.strictEqual(existsSync(pidPath), false, "stale pid file should be removed after stop");
|
||||
console.log("✓ stop recovered stale supervisor pid state\n");
|
||||
|
||||
@@ -55,7 +55,16 @@ const env = {
|
||||
};
|
||||
|
||||
try {
|
||||
const start = await runLocalPaseo(["daemon", "restart", "--port", String(port)], env);
|
||||
for (const [field, value] of [
|
||||
["daemon.listen", `127.0.0.1:${port}`],
|
||||
["daemon.relay.enabled", "false"],
|
||||
["features.dictation.enabled", "false"],
|
||||
["features.voiceMode.enabled", "false"],
|
||||
]) {
|
||||
const saved = await runLocalPaseo(["daemon", "config", "set", field!, value!], env);
|
||||
assert.equal(saved.exitCode, 0, saved.stderr);
|
||||
}
|
||||
const start = await runLocalPaseo(["daemon", "start"], env);
|
||||
assert.strictEqual(
|
||||
start.exitCode,
|
||||
0,
|
||||
|
||||
@@ -44,7 +44,9 @@ async function main(): Promise<void> {
|
||||
assert.equal(install.exitCode, 0, install.stderr);
|
||||
assert.equal(JSON.parse(install.stdout).id, "cli-e2e");
|
||||
|
||||
const client = await connectToDaemon({ host: `127.0.0.1:${context.port}` });
|
||||
const client = await connectToDaemon({
|
||||
target: { kind: "endpoint", host: `127.0.0.1:${context.port}` },
|
||||
});
|
||||
await client.patchDaemonConfig({ pluginsEnabled: true });
|
||||
await client.close();
|
||||
|
||||
|
||||
@@ -1,5 +1,10 @@
|
||||
import { $, ProcessPromise } from "zx";
|
||||
import { join } from "node:path";
|
||||
import { mkdtempSync, rmSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
|
||||
const cliHome = mkdtempSync(join(tmpdir(), "paseo-test-cli-os-home-"));
|
||||
process.once("exit", () => rmSync(cliHome, { recursive: true, force: true }));
|
||||
|
||||
const CLI_ENTRY = join(import.meta.dirname, "..", "..", "dist", "index.js");
|
||||
|
||||
@@ -11,6 +16,13 @@ export function runLocalPaseo(
|
||||
$.verbose = false;
|
||||
return $({
|
||||
cwd,
|
||||
env: { ...process.env, ...env },
|
||||
env: {
|
||||
...Object.fromEntries(
|
||||
Object.entries(process.env).filter(([key]) => !key.startsWith("PASEO_")),
|
||||
),
|
||||
...env,
|
||||
HOME: cliHome,
|
||||
USERPROFILE: cliHome,
|
||||
},
|
||||
})`${process.execPath} ${CLI_ENTRY} ${args}`.nothrow();
|
||||
}
|
||||
|
||||
@@ -169,19 +169,23 @@ export async function createTempDirs(): Promise<{ paseoHome: string; workDir: st
|
||||
* Wait for daemon to be ready by running `paseo agent ls`
|
||||
* This connects via WebSocket and ensures the daemon is responsive
|
||||
*/
|
||||
async function probeDaemonReady(port: number, env?: NodeJS.ProcessEnv): Promise<boolean> {
|
||||
async function probeDaemonReady(
|
||||
port: number,
|
||||
paseoHome: string,
|
||||
env?: NodeJS.ProcessEnv,
|
||||
): Promise<boolean> {
|
||||
try {
|
||||
const { exitCode } = await runPaseoCli(
|
||||
{
|
||||
port,
|
||||
wsUrl: `ws://${TEST_DAEMON_HOST}:${port}`,
|
||||
paseoHome: "",
|
||||
paseoHome,
|
||||
workDir: "",
|
||||
process: null,
|
||||
isReady: false,
|
||||
stop: async () => {},
|
||||
},
|
||||
["agent", "ls"],
|
||||
["agent", "ls", "--host", `${TEST_DAEMON_HOST}:${port}`],
|
||||
{ env },
|
||||
);
|
||||
return exitCode === 0;
|
||||
@@ -192,13 +196,14 @@ async function probeDaemonReady(port: number, env?: NodeJS.ProcessEnv): Promise<
|
||||
|
||||
async function waitForDaemonReady(
|
||||
port: number,
|
||||
paseoHome: string,
|
||||
timeout = 30000,
|
||||
env?: NodeJS.ProcessEnv,
|
||||
): Promise<void> {
|
||||
const deadline = Date.now() + timeout;
|
||||
|
||||
async function poll(): Promise<void> {
|
||||
if (await probeDaemonReady(port, env)) return;
|
||||
if (await probeDaemonReady(port, paseoHome, env)) return;
|
||||
if (Date.now() >= deadline) {
|
||||
throw new Error(`Daemon failed to become ready on port ${port} within ${timeout}ms`);
|
||||
}
|
||||
@@ -240,23 +245,23 @@ export async function startTestDaemon(options?: {
|
||||
const cliSrcPath = join(cliDir, "src", "index.ts");
|
||||
|
||||
// Start daemon process using tsx to run TypeScript directly
|
||||
const daemonProcess = spawn(
|
||||
process.execPath,
|
||||
[TSX_ENTRY, cliSrcPath, "daemon", "start", "--foreground"],
|
||||
{
|
||||
env: {
|
||||
...process.env,
|
||||
...TEST_DAEMON_ENV_DEFAULTS,
|
||||
PASEO_HOME: paseoHome,
|
||||
PASEO_LISTEN: `${TEST_DAEMON_HOST}:${port}`,
|
||||
// Force no TTY to prevent QR code output
|
||||
CI: "true",
|
||||
...options?.env,
|
||||
},
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
detached: process.platform !== "win32",
|
||||
const daemonProcess = spawn(process.execPath, [TSX_ENTRY, cliSrcPath, "daemon", "run"], {
|
||||
env: {
|
||||
...Object.fromEntries(
|
||||
Object.entries(process.env).filter(([key]) => !key.startsWith("PASEO_")),
|
||||
),
|
||||
...TEST_DAEMON_ENV_DEFAULTS,
|
||||
PASEO_HOME: paseoHome,
|
||||
PASEO_LISTEN: `${TEST_DAEMON_HOST}:${port}`,
|
||||
// Force no TTY to prevent QR code output
|
||||
CI: "true",
|
||||
...options?.env,
|
||||
HOME: paseoHome,
|
||||
USERPROFILE: paseoHome,
|
||||
},
|
||||
);
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
detached: process.platform !== "win32",
|
||||
});
|
||||
|
||||
const stdout = createOutputCapture();
|
||||
const stderr = createOutputCapture();
|
||||
@@ -319,7 +324,7 @@ export async function startTestDaemon(options?: {
|
||||
|
||||
// Wait for daemon to be ready
|
||||
try {
|
||||
await waitForDaemonReady(port, timeout, options?.env);
|
||||
await waitForDaemonReady(port, paseoHome, timeout, options?.env);
|
||||
ctx.isReady = true;
|
||||
} catch (err) {
|
||||
// Daemon failed to start - clean up and rethrow
|
||||
@@ -358,11 +363,14 @@ export async function runPaseoCli(
|
||||
return new Promise((resolve, reject) => {
|
||||
const proc = spawn(process.execPath, [TSX_ENTRY, cliSrcPath, ...args], {
|
||||
env: {
|
||||
...process.env,
|
||||
...Object.fromEntries(
|
||||
Object.entries(process.env).filter(([key]) => !key.startsWith("PASEO_")),
|
||||
),
|
||||
...TEST_DAEMON_ENV_DEFAULTS,
|
||||
PASEO_HOST: `${TEST_DAEMON_HOST}:${ctx.port}`,
|
||||
PASEO_HOME: ctx.paseoHome,
|
||||
...options?.env,
|
||||
HOME: ctx.paseoHome,
|
||||
USERPROFILE: ctx.paseoHome,
|
||||
},
|
||||
cwd,
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
|
||||
@@ -196,12 +196,17 @@ async function runSingleTest(testFile: string): Promise<TestOutcome> {
|
||||
const testName = testFile.replace(/\.test\.ts$/, "");
|
||||
const startedAt = Date.now();
|
||||
const npmCache = await mkdtemp(join(tmpdir(), "paseo-cli-test-npm-cache-"));
|
||||
const osHome = await mkdtemp(join(tmpdir(), "paseo-cli-test-os-home-"));
|
||||
|
||||
try {
|
||||
return await new Promise<TestOutcome>((resolve) => {
|
||||
const proc = spawn("npx", ["tsx", testPath], {
|
||||
env: {
|
||||
...process.env,
|
||||
...Object.fromEntries(
|
||||
Object.entries(process.env).filter(([key]) => !key.startsWith("PASEO_")),
|
||||
),
|
||||
HOME: osHome,
|
||||
USERPROFILE: osHome,
|
||||
PATH: [rootNodeModulesBin, process.env.PATH].filter(Boolean).join(delimiter),
|
||||
npm_config_cache: npmCache,
|
||||
PASEO_LOCAL_SPEECH_AUTO_DOWNLOAD: testEnvDefaults.PASEO_LOCAL_SPEECH_AUTO_DOWNLOAD,
|
||||
@@ -249,6 +254,7 @@ async function runSingleTest(testFile: string): Promise<TestOutcome> {
|
||||
});
|
||||
} finally {
|
||||
await rm(npmCache, { recursive: true, force: true });
|
||||
await rm(osHome, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -20,6 +20,16 @@ const TEST_ENV_DEFAULTS = {
|
||||
PASEO_VOICE_MODE_ENABLED: process.env.PASEO_VOICE_MODE_ENABLED ?? "0",
|
||||
};
|
||||
|
||||
function testEnvironment(paseoHome: string): NodeJS.ProcessEnv {
|
||||
return {
|
||||
...Object.fromEntries(Object.entries(process.env).filter(([key]) => !key.startsWith("PASEO_"))),
|
||||
...TEST_ENV_DEFAULTS,
|
||||
PASEO_HOME: paseoHome,
|
||||
HOME: paseoHome,
|
||||
USERPROFILE: paseoHome,
|
||||
};
|
||||
}
|
||||
|
||||
function killPidTree(pid: number, signal: NodeJS.Signals): void {
|
||||
if (!Number.isInteger(pid) || pid <= 0) {
|
||||
return;
|
||||
@@ -83,19 +93,25 @@ export async function createTempDirs(): Promise<{ paseoHome: string; workDir: st
|
||||
* Wait for daemon to be ready by testing WebSocket connection
|
||||
* Uses `paseo agent ls` which connects via WebSocket
|
||||
*/
|
||||
async function probeDaemon(port: number): Promise<boolean> {
|
||||
async function probeDaemon(port: number, paseoHome: string): Promise<boolean> {
|
||||
try {
|
||||
const result = await $`PASEO_HOST=localhost:${port} paseo agent ls`.nothrow();
|
||||
const result = await $({
|
||||
env: testEnvironment(paseoHome),
|
||||
})`paseo agent ls --host localhost:${port}`.nothrow();
|
||||
return result.exitCode === 0;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export async function waitForDaemon(port: number, timeout = 30000): Promise<void> {
|
||||
export async function waitForDaemon(
|
||||
port: number,
|
||||
paseoHome: string,
|
||||
timeout = 30000,
|
||||
): Promise<void> {
|
||||
const deadline = Date.now() + timeout;
|
||||
async function poll(): Promise<void> {
|
||||
if (await probeDaemon(port)) return;
|
||||
if (await probeDaemon(port, paseoHome)) return;
|
||||
if (Date.now() >= deadline) {
|
||||
throw new Error(`Daemon failed to start on port ${port} within ${timeout}ms`);
|
||||
}
|
||||
@@ -110,8 +126,14 @@ export async function waitForDaemon(port: number, timeout = 30000): Promise<void
|
||||
*/
|
||||
export async function startDaemon(port: number, paseoHome: string): Promise<ProcessPromise> {
|
||||
$.verbose = false;
|
||||
const daemon =
|
||||
$`PASEO_HOME=${paseoHome} PASEO_LISTEN=127.0.0.1:${port} PASEO_RELAY_ENABLED=false PASEO_LOCAL_SPEECH_AUTO_DOWNLOAD=${TEST_ENV_DEFAULTS.PASEO_LOCAL_SPEECH_AUTO_DOWNLOAD} PASEO_DICTATION_ENABLED=${TEST_ENV_DEFAULTS.PASEO_DICTATION_ENABLED} PASEO_VOICE_MODE_ENABLED=${TEST_ENV_DEFAULTS.PASEO_VOICE_MODE_ENABLED} CI=true paseo daemon start --foreground`.nothrow();
|
||||
const daemon = $({
|
||||
env: {
|
||||
...testEnvironment(paseoHome),
|
||||
PASEO_LISTEN: `127.0.0.1:${port}`,
|
||||
PASEO_RELAY_ENABLED: "false",
|
||||
CI: "true",
|
||||
},
|
||||
})`paseo daemon run`.nothrow();
|
||||
return daemon;
|
||||
}
|
||||
|
||||
@@ -125,7 +147,7 @@ export async function createTestContext(): Promise<TestContext> {
|
||||
// Helper to run CLI commands against test daemon
|
||||
const paseo = (args: string[]): ProcessPromise => {
|
||||
$.verbose = false;
|
||||
return $`PASEO_HOST=localhost:${port} PASEO_LOCAL_SPEECH_AUTO_DOWNLOAD=${TEST_ENV_DEFAULTS.PASEO_LOCAL_SPEECH_AUTO_DOWNLOAD} PASEO_DICTATION_ENABLED=${TEST_ENV_DEFAULTS.PASEO_DICTATION_ENABLED} PASEO_VOICE_MODE_ENABLED=${TEST_ENV_DEFAULTS.PASEO_VOICE_MODE_ENABLED} paseo ${args}`.nothrow();
|
||||
return $({ env: testEnvironment(paseoHome) })`paseo --home ${paseoHome} ${args}`.nothrow();
|
||||
};
|
||||
|
||||
// Cleanup function
|
||||
@@ -162,7 +184,7 @@ export async function createTestContext(): Promise<TestContext> {
|
||||
export async function createTestContextWithDaemon(): Promise<TestContext> {
|
||||
const ctx = await createTestContext();
|
||||
ctx.daemon = await startDaemon(ctx.port, ctx.paseoHome);
|
||||
await waitForDaemon(ctx.port);
|
||||
await waitForDaemon(ctx.port, ctx.paseoHome);
|
||||
return ctx;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user