diff --git a/nix/package.nix b/nix/package.nix index 6c26f2f2a..3ce7d4a09 100644 --- a/nix/package.nix +++ b/nix/package.nix @@ -114,7 +114,7 @@ buildNpmPackage rec { fi done - # Copy server scripts (daemon-runner, supervisor) needed by CLI + # Copy server scripts (including supervisor-entrypoint) needed by CLI if [ -d packages/server/dist/scripts ]; then mkdir -p $out/lib/paseo/packages/server/dist/scripts cp -a packages/server/dist/scripts/* $out/lib/paseo/packages/server/dist/scripts/ diff --git a/packages/app/src/desktop/daemon/desktop-daemon.ts b/packages/app/src/desktop/daemon/desktop-daemon.ts index a4186ac04..ec17b870a 100644 --- a/packages/app/src/desktop/daemon/desktop-daemon.ts +++ b/packages/app/src/desktop/daemon/desktop-daemon.ts @@ -6,7 +6,7 @@ export type DesktopDaemonState = "starting" | "running" | "stopped" | "errored"; export type DesktopDaemonStatus = { serverId: string; status: DesktopDaemonState; - listen: string; + listen: string | null; hostname: string | null; pid: number | null; home: string; @@ -82,7 +82,7 @@ function parseDesktopDaemonStatus(raw: unknown): DesktopDaemonStatus { return { serverId: toStringOrNull(raw.serverId) ?? "", status: parseDesktopDaemonState(raw.status), - listen: toStringOrNull(raw.listen) ?? "", + listen: toStringOrNull(raw.listen), hostname: toStringOrNull(raw.hostname), pid: toNumberOrNull(raw.pid), home: toStringOrNull(raw.home) ?? "", diff --git a/packages/app/src/runtime/host-runtime.ts b/packages/app/src/runtime/host-runtime.ts index 5c81b1c17..74c3eb08c 100644 --- a/packages/app/src/runtime/host-runtime.ts +++ b/packages/app/src/runtime/host-runtime.ts @@ -1172,7 +1172,7 @@ export class HostRuntimeStore { async bootstrapDesktop(): Promise { try { const daemon = await startDesktopDaemon(); - const listenAddress = daemon.listen.trim(); + const listenAddress = daemon.listen?.trim() ?? ""; const serverId = daemon.serverId.trim(); if (!listenAddress) { return { diff --git a/packages/cli/src/commands/daemon/local-daemon.ts b/packages/cli/src/commands/daemon/local-daemon.ts index f2c92ad1d..c2e39e513 100644 --- a/packages/cli/src/commands/daemon/local-daemon.ts +++ b/packages/cli/src/commands/daemon/local-daemon.ts @@ -124,11 +124,16 @@ function resolveDaemonRunnerEntry(): string { try { const packageJson = JSON.parse(readFileSync(packageJsonPath, "utf-8")) as { name?: string }; if (packageJson.name === "@getpaseo/server") { - const distRunner = path.join(currentDir, "dist", "scripts", "daemon-runner.js"); + const distRunner = path.join( + currentDir, + "dist", + "scripts", + "supervisor-entrypoint.js", + ); if (existsSync(distRunner)) { return distRunner; } - return path.join(currentDir, "scripts", "daemon-runner.ts"); + return path.join(currentDir, "scripts", "supervisor-entrypoint.ts"); } } catch { // Continue searching up if package.json exists but is invalid. diff --git a/packages/cli/tests/22-daemon-stop-supervisor.test.ts b/packages/cli/tests/22-daemon-stop-supervisor.test.ts index 2ef8a2d39..c706a8ea8 100644 --- a/packages/cli/tests/22-daemon-stop-supervisor.test.ts +++ b/packages/cli/tests/22-daemon-stop-supervisor.test.ts @@ -2,7 +2,7 @@ /** * Regression: `paseo daemon stop` must stop supervised dev daemons - * without allowing daemon-runner to respawn a new worker process. + * without allowing the supervisor entrypoint to respawn a new worker process. */ import assert from "node:assert"; @@ -120,20 +120,24 @@ let supervisorProcess: ChildProcess | null = null; let recentSupervisorLogs = ""; try { - console.log("Test 1: start daemon-runner in dev mode with isolated PASEO_HOME"); + console.log("Test 1: start supervisor-entrypoint in dev mode with isolated PASEO_HOME"); - supervisorProcess = spawn("npx", ["tsx", "../server/scripts/daemon-runner.ts", "--dev"], { - cwd: cliRoot, - env: { - ...process.env, - ...testEnv, - PASEO_HOME: paseoHome, - PASEO_LISTEN: `127.0.0.1:${port}`, - PASEO_RELAY_ENABLED: "false", - CI: "true", + supervisorProcess = spawn( + "npx", + ["tsx", "../server/scripts/supervisor-entrypoint.ts", "--dev"], + { + cwd: cliRoot, + env: { + ...process.env, + ...testEnv, + PASEO_HOME: paseoHome, + PASEO_LISTEN: `127.0.0.1:${port}`, + PASEO_RELAY_ENABLED: "false", + CI: "true", + }, + stdio: ["ignore", "pipe", "pipe"], }, - stdio: ["ignore", "pipe", "pipe"], - }); + ); supervisorProcess.stdout?.on("data", (chunk) => { recentSupervisorLogs = (recentSupervisorLogs + chunk.toString()).slice(-8000); @@ -161,8 +165,9 @@ try { const command = readProcessCommand(daemonPid); assert(command !== null, "pid lock pid should resolve to a running process command"); assert( - command.includes("daemon-runner.ts") || command.includes("daemon-runner.js"), - `pid lock pid should be daemon-runner process, got: ${command}`, + command.includes("supervisor-entrypoint.ts") || + command.includes("supervisor-entrypoint.js"), + `pid lock pid should be supervisor-entrypoint process, got: ${command}`, ); console.log(`✓ dev daemon started with daemon pid ${daemonPid}\n`); @@ -186,7 +191,7 @@ try { await waitFor( () => !isProcessRunning(supervisorProcess!.pid ?? -1), 15000, - "daemon-runner supervisor remained running after stop", + "supervisor-entrypoint process remained running after stop", ); } diff --git a/packages/cli/tests/23-daemon-sigint-supervisor.test.ts b/packages/cli/tests/23-daemon-sigint-supervisor.test.ts index eedd2154f..b7cbffb7c 100644 --- a/packages/cli/tests/23-daemon-sigint-supervisor.test.ts +++ b/packages/cli/tests/23-daemon-sigint-supervisor.test.ts @@ -1,7 +1,7 @@ #!/usr/bin/env npx tsx /** - * Regression: a single SIGINT sent to a supervised daemon-runner must allow + * Regression: a single SIGINT sent to the supervised supervisor entrypoint must allow * graceful daemon lifecycle shutdown to complete (no early forced exit path). */ @@ -131,21 +131,25 @@ let supervisorProcess: ChildProcess | null = null; let recentSupervisorLogs = ""; try { - console.log("Test 1: start daemon-runner in dev mode with isolated PASEO_HOME"); + console.log("Test 1: start supervisor-entrypoint in dev mode with isolated PASEO_HOME"); - supervisorProcess = spawn("npx", ["tsx", "../server/scripts/daemon-runner.ts", "--dev"], { - cwd: cliRoot, - env: { - ...process.env, - ...testEnv, - PASEO_HOME: paseoHome, - PASEO_LISTEN: `127.0.0.1:${port}`, - PASEO_RELAY_ENABLED: "false", - CI: "true", + supervisorProcess = spawn( + "npx", + ["tsx", "../server/scripts/supervisor-entrypoint.ts", "--dev"], + { + cwd: cliRoot, + env: { + ...process.env, + ...testEnv, + PASEO_HOME: paseoHome, + PASEO_LISTEN: `127.0.0.1:${port}`, + PASEO_RELAY_ENABLED: "false", + CI: "true", + }, + stdio: ["ignore", "pipe", "pipe"], + detached: process.platform !== "win32", }, - stdio: ["ignore", "pipe", "pipe"], - detached: process.platform !== "win32", - }); + ); supervisorProcess.stdout?.on("data", (chunk) => { recentSupervisorLogs = (recentSupervisorLogs + chunk.toString()).slice(-8000); diff --git a/packages/cli/tests/24-daemon-stop-ownership.test.ts b/packages/cli/tests/24-daemon-stop-ownership.test.ts index 53d783f51..966f804ff 100644 --- a/packages/cli/tests/24-daemon-stop-ownership.test.ts +++ b/packages/cli/tests/24-daemon-stop-ownership.test.ts @@ -62,7 +62,7 @@ try { "-e", // Keep the process alive long enough for stop command assertions. "setInterval(() => {}, 1000)", - "daemon-runner.ts", + "supervisor-entrypoint.ts", ], { env: { diff --git a/packages/cli/tests/25-daemon-restart-supervisor.test.ts b/packages/cli/tests/25-daemon-restart-supervisor.test.ts index 6187d71ae..c1361dca4 100644 --- a/packages/cli/tests/25-daemon-restart-supervisor.test.ts +++ b/packages/cli/tests/25-daemon-restart-supervisor.test.ts @@ -122,20 +122,24 @@ let supervisorProcess: ChildProcess | null = null; let recentSupervisorLogs = ""; try { - console.log("Test 1: start daemon-runner in dev mode with isolated PASEO_HOME"); + console.log("Test 1: start supervisor-entrypoint in dev mode with isolated PASEO_HOME"); - supervisorProcess = spawn("npx", ["tsx", "../server/scripts/daemon-runner.ts", "--dev"], { - cwd: cliRoot, - env: { - ...process.env, - ...testEnv, - PASEO_HOME: paseoHome, - PASEO_LISTEN: host, - PASEO_RELAY_ENABLED: "false", - CI: "true", + supervisorProcess = spawn( + "npx", + ["tsx", "../server/scripts/supervisor-entrypoint.ts", "--dev"], + { + cwd: cliRoot, + env: { + ...process.env, + ...testEnv, + PASEO_HOME: paseoHome, + PASEO_LISTEN: host, + PASEO_RELAY_ENABLED: "false", + CI: "true", + }, + stdio: ["ignore", "pipe", "pipe"], }, - stdio: ["ignore", "pipe", "pipe"], - }); + ); supervisorProcess.stdout?.on("data", (chunk) => { recentSupervisorLogs = (recentSupervisorLogs + chunk.toString()).slice(-8000); diff --git a/packages/cli/tests/26-daemon-restart-unsupervised.test.ts b/packages/cli/tests/26-daemon-restart-unsupervised.test.ts index e000f5265..172f1f752 100644 --- a/packages/cli/tests/26-daemon-restart-unsupervised.test.ts +++ b/packages/cli/tests/26-daemon-restart-unsupervised.test.ts @@ -134,11 +134,6 @@ try { env: { ...process.env, ...testEnv, - // This test validates direct unsupervised worker ownership semantics. - // Agent-orchestrated shells may export PASEO_PID_LOCK_MODE=external, - // which would delegate lock ownership away from this process and make - // daemon status checks fail to observe a running owner PID. - PASEO_PID_LOCK_MODE: "self", PASEO_HOME: paseoHome, PASEO_LISTEN: host, PASEO_RELAY_ENABLED: "false", diff --git a/packages/desktop/src/daemon/daemon-manager.ts b/packages/desktop/src/daemon/daemon-manager.ts index 4df565e19..b807c08e7 100644 --- a/packages/desktop/src/daemon/daemon-manager.ts +++ b/packages/desktop/src/daemon/daemon-manager.ts @@ -3,7 +3,7 @@ import { existsSync, readFileSync } from "node:fs"; import path from "node:path"; import { app, ipcMain } from "electron"; import log from "electron-log/main"; -import { loadConfig, resolvePaseoHome, getOrCreateServerId } from "@getpaseo/server"; +import { resolvePaseoHome, getOrCreateServerId } from "@getpaseo/server"; import { copyAttachmentFileToManagedStorage, deleteManagedAttachmentFile, @@ -27,14 +27,13 @@ const STARTUP_POLL_MAX_ATTEMPTS = 150; const STOP_TIMEOUT_MS = 15_000; const KILL_TIMEOUT_MS = 3_000; const DETACHED_STARTUP_GRACE_MS = 1200; -const DEFAULT_ELECTRON_DEV_SERVER_URL = "http://localhost:8081"; type DesktopDaemonState = "starting" | "running" | "stopped" | "errored"; type DesktopDaemonStatus = { serverId: string; status: DesktopDaemonState; - listen: string; + listen: string | null; hostname: string | null; pid: number | null; home: string; @@ -148,30 +147,6 @@ function logDesktopDaemonLifecycle(message: string, details?: Record value.trim()) - .filter((value) => value.length > 0), - ); - - const devServerUrl = process.env.EXPO_DEV_URL ?? DEFAULT_ELECTRON_DEV_SERVER_URL; - try { - const parsed = new URL(devServerUrl); - origins.add(parsed.origin); - - if (parsed.hostname === "localhost") { - origins.add(`${parsed.protocol}//127.0.0.1${parsed.port ? `:${parsed.port}` : ""}`); - } else if (parsed.hostname === "127.0.0.1") { - origins.add(`${parsed.protocol}//localhost${parsed.port ? `:${parsed.port}` : ""}`); - } - } catch { - // Ignore malformed dev server URLs and preserve any explicit env configuration. - } - - return origins.size > 0 ? Array.from(origins).join(",") : undefined; -} function toTrimmedString(value: unknown): string | null { if (typeof value !== "string") { @@ -246,12 +221,11 @@ function resolveDesktopAppVersion(): string { function resolveStatus(): DesktopDaemonStatus { const home = getPaseoHome(); - const config = loadConfig(home, { env: process.env }); const pidPath = pidFilePath(); let pid: number | null = null; let hostname: string | null = null; - let listen: string = config.listen; + let listen: string | null = null; try { if (existsSync(pidPath)) { @@ -266,7 +240,7 @@ function resolveStatus(): DesktopDaemonStatus { : typeof parsed.sockPath === "string" ? (parsed.sockPath as string) : null; - if (pidListen) listen = pidListen; + listen = pidListen; } } } catch { @@ -297,18 +271,12 @@ async function startDaemon(): Promise { const current = resolveStatus(); if (current.status === "running") return current; - const home = getPaseoHome(); const daemonRunner = resolveDaemonRunnerEntrypoint(); - const corsOrigins = buildDesktopDaemonCorsOriginsEnv(); const invocation = createNodeEntrypointInvocation({ entrypoint: daemonRunner, argvMode: "node-script", args: [], - baseEnv: { - ...process.env, - PASEO_HOME: home, - ...(corsOrigins ? { PASEO_CORS_ORIGINS: corsOrigins } : {}), - }, + baseEnv: process.env, }); logDesktopDaemonLifecycle("starting detached daemon", { @@ -317,8 +285,6 @@ async function startDaemon(): Promise { daemonRunnerExecArgv: daemonRunner.execArgv, command: invocation.command, args: invocation.args, - listen: process.env.PASEO_LISTEN ?? null, - corsOrigins: corsOrigins ?? null, }); const child: ChildProcess = spawn( @@ -388,7 +354,7 @@ async function startDaemon(): Promise { serverId: status.serverId || null, }); } - if (status.status === "running" && status.serverId) return status; + if (status.status === "running" && status.serverId && status.listen) return status; await sleep(STARTUP_POLL_INTERVAL_MS); } @@ -439,6 +405,9 @@ async function getDaemonPairing(): Promise { } try { + if (!status.listen) { + throw new Error("Daemon listen target is unavailable."); + } const baseUrl = buildDaemonHttpBaseUrl(status.listen); if (!baseUrl) { throw new Error(`Daemon listen target is not a TCP endpoint: ${status.listen}`); @@ -480,6 +449,10 @@ async function getLocalDaemonVersion(): Promise<{ }; } + if (!status.listen) { + return { version: null, error: "Daemon listen target is unavailable." }; + } + const baseUrl = buildDaemonHttpBaseUrl(status.listen); if (!baseUrl) { return { version: null, error: `Daemon listen target is not a TCP endpoint: ${status.listen}` }; diff --git a/packages/desktop/src/daemon/runtime-paths.ts b/packages/desktop/src/daemon/runtime-paths.ts index 18c09e9a6..d2b2ace8c 100644 --- a/packages/desktop/src/daemon/runtime-paths.ts +++ b/packages/desktop/src/daemon/runtime-paths.ts @@ -107,7 +107,7 @@ export function resolveDaemonRunnerEntrypoint(): NodeEntrypointSpec { "server", "dist", "scripts", - "daemon-runner.js", + "supervisor-entrypoint.js", ), }), execArgv: [], @@ -115,7 +115,12 @@ export function resolveDaemonRunnerEntrypoint(): NodeEntrypointSpec { } const serverPackage = resolveServerPackageInfo(); - const distRunner = path.join(serverPackage.root, "dist", "scripts", "daemon-runner.js"); + const distRunner = path.join( + serverPackage.root, + "dist", + "scripts", + "supervisor-entrypoint.js", + ); if (existsSync(distRunner)) { return { entryPath: distRunner, @@ -126,7 +131,7 @@ export function resolveDaemonRunnerEntrypoint(): NodeEntrypointSpec { return { entryPath: assertPathExists({ label: "Daemon runner source", - filePath: path.join(serverPackage.root, "scripts", "daemon-runner.ts"), + filePath: path.join(serverPackage.root, "scripts", "supervisor-entrypoint.ts"), }), execArgv: ["--import", "tsx"], }; diff --git a/packages/server/scripts/dev-runner.ts b/packages/server/scripts/dev-runner.ts index dea166038..581b04ebb 100644 --- a/packages/server/scripts/dev-runner.ts +++ b/packages/server/scripts/dev-runner.ts @@ -7,7 +7,7 @@ dotenv.config({ quiet: true, }); -const daemonRunnerEntry = fileURLToPath(new URL("./daemon-runner.ts", import.meta.url)); +const daemonRunnerEntry = fileURLToPath(new URL("./supervisor-entrypoint.ts", import.meta.url)); const result = spawnSync( process.execPath, ["--inspect", "--heapsnapshot-near-heap-limit=3", "--max-old-space-size=3072", "--report-on-fatalerror", "--report-directory=/tmp/paseo-reports", ...process.execArgv, daemonRunnerEntry, "--dev", ...process.argv.slice(2)], diff --git a/packages/server/scripts/supervision-parity.test.ts b/packages/server/scripts/supervision-parity.test.ts index 6aed9f1ff..b88dcb202 100644 --- a/packages/server/scripts/supervision-parity.test.ts +++ b/packages/server/scripts/supervision-parity.test.ts @@ -3,7 +3,10 @@ import { describe, expect, test } from "vitest"; describe("supervision parity", () => { test("has exactly one runtime callsite for runSupervisor", () => { - const daemonRunner = readFileSync(new URL("./daemon-runner.ts", import.meta.url), "utf8"); + const daemonRunner = readFileSync( + new URL("./supervisor-entrypoint.ts", import.meta.url), + "utf8", + ); const devRunner = readFileSync(new URL("./dev-runner.ts", import.meta.url), "utf8"); const daemonRunnerCalls = (daemonRunner.match(/\brunSupervisor\s*\(/g) ?? []).length; diff --git a/packages/server/scripts/daemon-runner.ts b/packages/server/scripts/supervisor-entrypoint.ts similarity index 91% rename from packages/server/scripts/daemon-runner.ts rename to packages/server/scripts/supervisor-entrypoint.ts index db6d660e2..519efba9d 100644 --- a/packages/server/scripts/daemon-runner.ts +++ b/packages/server/scripts/supervisor-entrypoint.ts @@ -1,8 +1,12 @@ import { fileURLToPath } from "url"; import { existsSync } from "node:fs"; import path from "node:path"; -import { loadConfig } from "../src/server/config.js"; -import { acquirePidLock, PidLockError, releasePidLock } from "../src/server/pid-lock.js"; +import { + acquirePidLock, + PidLockError, + releasePidLock, + updatePidLock, +} from "../src/server/pid-lock.js"; import { resolvePaseoHome } from "../src/server/paseo-home.js"; import { runSupervisor } from "./supervisor.js"; import { applySherpaLoaderEnv } from "../src/server/speech/providers/local/sherpa/sherpa-runtime-env.js"; @@ -72,10 +76,7 @@ async function main(): Promise { const config = parseConfig(process.argv.slice(2)); const workerEntry = config.devMode ? resolveDevWorkerEntry() : resolveWorkerEntry(); const workerExecArgv = resolveWorkerExecArgv(workerEntry); - const workerEnv: NodeJS.ProcessEnv = { - ...process.env, - PASEO_PID_LOCK_MODE: "external", - }; + const workerEnv: NodeJS.ProcessEnv = { ...process.env }; const packagedNodeEntrypointRunner = process.env.ELECTRON_RUN_AS_NODE === "1" ? resolvePackagedNodeEntrypointRunnerPath(fileURLToPath(import.meta.url)) @@ -84,10 +85,9 @@ async function main(): Promise { applySherpaLoaderEnv(workerEnv); const paseoHome = resolvePaseoHome(workerEnv); - const daemonConfig = loadConfig(paseoHome, { env: workerEnv }); try { - await acquirePidLock(paseoHome, daemonConfig.listen, { + await acquirePidLock(paseoHome, null, { ownerPid: process.pid, }); } catch (error) { @@ -135,6 +135,9 @@ async function main(): Promise { }) : undefined, restartOnCrash: config.devMode, + onWorkerReady: async ({ listen }) => { + await updatePidLock(paseoHome, { listen }, { ownerPid: process.pid }); + }, onSupervisorExit: releaseLock, }); } diff --git a/packages/server/scripts/supervisor.ts b/packages/server/scripts/supervisor.ts index b661371ab..5a99234e5 100644 --- a/packages/server/scripts/supervisor.ts +++ b/packages/server/scripts/supervisor.ts @@ -4,6 +4,10 @@ type WorkerLifecycleMessage = | { type: "paseo:shutdown"; } + | { + type: "paseo:ready"; + listen: string; + } | { type: "paseo:restart"; reason?: string; @@ -21,6 +25,7 @@ type SupervisorOptions = { args: string[]; env?: NodeJS.ProcessEnv; } | null; + onWorkerReady?: (message: { listen: string }) => Promise | void; restartOnCrash?: boolean; onSupervisorExit?: () => Promise | void; }; @@ -37,6 +42,13 @@ function parseLifecycleMessage(msg: unknown): WorkerLifecycleMessage | null { if (type === "paseo:shutdown") { return { type: "paseo:shutdown" }; } + if (type === "paseo:ready") { + const listen = (msg as { listen?: unknown }).listen; + if (typeof listen !== "string" || listen.trim().length === 0) { + return null; + } + return { type: "paseo:ready", listen }; + } if (type === "paseo:restart") { const reason = (msg as { reason?: unknown }).reason; return { @@ -110,6 +122,16 @@ export function runSupervisor(options: SupervisorOptions): void { return; } + if (lifecycleMessage.type === "paseo:ready") { + Promise.resolve(options.onWorkerReady?.({ listen: lifecycleMessage.listen })).catch( + (error) => { + const message = error instanceof Error ? error.message : String(error); + log(`Worker ready callback failed: ${message}`); + }, + ); + return; + } + if (lifecycleMessage.type === "paseo:shutdown") { requestShutdown("Shutdown requested by worker"); return; @@ -127,7 +149,11 @@ export function runSupervisor(options: SupervisorOptions): void { return; } - if (restarting || (restartOnCrash && code !== 0 && code !== null)) { + const crashed = + restartOnCrash && + ((code !== 0 && code !== null) || (signal !== null && signal === "SIGKILL")); + + if (restarting || crashed) { restarting = false; log(`Worker exited (${exitDescriptor}). Restarting worker...`); spawnWorker(); diff --git a/packages/server/src/server/bootstrap.ts b/packages/server/src/server/bootstrap.ts index 35fa32f4b..043af68a6 100644 --- a/packages/server/src/server/bootstrap.ts +++ b/packages/server/src/server/bootstrap.ts @@ -112,7 +112,6 @@ import { getOrCreateServerId } from "./server-id.js"; import { resolveDaemonVersion } from "./daemon-version.js"; import type { AgentClient, AgentProvider } from "./agent/agent-sdk-types.js"; import type { AgentProviderRuntimeSettingsMap } from "./agent/provider-launch-config.js"; -import { acquirePidLock, releasePidLock } from "./pid-lock.js"; import { isHostAllowed, type AllowedHostsConfig } from "./allowed-hosts.js"; import { createVoiceMcpSocketBridgeManager, @@ -183,10 +182,6 @@ export type PaseoDaemonConfig = { downloadTokenTtlMs?: number; agentProviderSettings?: AgentProviderRuntimeSettingsMap; onLifecycleIntent?: (intent: DaemonLifecycleIntent) => void; - pidLock?: { - mode?: "self" | "external"; - ownerPid?: number; - }; }; export interface PaseoDaemon { @@ -207,17 +202,6 @@ export async function createPaseoDaemon( const bootstrapStart = performance.now(); const elapsed = () => `${(performance.now() - bootstrapStart).toFixed(0)}ms`; const daemonVersion = resolveDaemonVersion(import.meta.url); - const pidLockMode = config.pidLock?.mode ?? "self"; - const pidLockOwnerPid = config.pidLock?.ownerPid; - const ownsPidLock = pidLockMode === "self"; - - // Acquire PID lock before expensive bootstrap work so duplicate starts fail immediately. - if (ownsPidLock) { - await acquirePidLock(config.paseoHome, config.listen, { - ownerPid: pidLockOwnerPid, - }); - logger.info({ elapsed: elapsed() }, "PID lock acquired"); - } try { const serverId = getOrCreateServerId(config.paseoHome, { logger }); @@ -707,6 +691,16 @@ export async function createPaseoDaemon( ); } + if (typeof process.send === "function") { + process.send({ + type: "paseo:ready", + listen: + boundListenTarget.type === "tcp" + ? `${boundListenTarget.host}:${boundListenTarget.port}` + : boundListenTarget.path, + }); + } + if (relayEnabled) { const offer = await createConnectionOfferV2({ serverId, @@ -776,12 +770,6 @@ export async function createPaseoDaemon( if (listenTarget.type === "socket" && existsSync(listenTarget.path)) { unlinkSync(listenTarget.path); } - // Release PID lock - if (ownsPidLock) { - await releasePidLock(config.paseoHome, { - ownerPid: pidLockOwnerPid, - }); - } }; return { @@ -794,11 +782,6 @@ export async function createPaseoDaemon( getListenTarget: () => boundListenTarget, }; } catch (err) { - if (ownsPidLock) { - await releasePidLock(config.paseoHome, { - ownerPid: pidLockOwnerPid, - }).catch(() => undefined); - } throw err; } } diff --git a/packages/server/src/server/index.ts b/packages/server/src/server/index.ts index 9d553b499..70b2401d9 100644 --- a/packages/server/src/server/index.ts +++ b/packages/server/src/server/index.ts @@ -131,14 +131,10 @@ async function main() { }; try { - const pidLockMode = process.env.PASEO_PID_LOCK_MODE === "external" ? "external" : "self"; daemon = await createPaseoDaemon( { ...config, onLifecycleIntent: handleLifecycleIntent, - pidLock: { - mode: pidLockMode, - }, }, logger, ); diff --git a/packages/server/src/server/pid-lock.test.ts b/packages/server/src/server/pid-lock.test.ts index befcfd1e4..527655e1b 100644 --- a/packages/server/src/server/pid-lock.test.ts +++ b/packages/server/src/server/pid-lock.test.ts @@ -3,7 +3,7 @@ import { tmpdir } from "node:os"; import { join } from "node:path"; import { describe, expect, test } from "vitest"; -import { acquirePidLock, getPidLockInfo, releasePidLock } from "./pid-lock.js"; +import { acquirePidLock, getPidLockInfo, releasePidLock, updatePidLock } from "./pid-lock.js"; describe("pid-lock ownership", () => { test("writes and releases lock for explicit owner pid", async () => { @@ -14,13 +14,25 @@ describe("pid-lock ownership", () => { await ( acquirePidLock as unknown as ( home: string, - sockPath: string, + sockPath: string | null, options: { ownerPid: number }, ) => Promise - )(paseoHome, "127.0.0.1:6767", { ownerPid }); + )(paseoHome, null, { ownerPid }); const lock = await getPidLockInfo(paseoHome); expect(lock?.pid).toBe(ownerPid); + expect(lock?.listen).toBeNull(); + + await ( + updatePidLock as unknown as ( + home: string, + patch: { listen: string }, + options: { ownerPid: number }, + ) => Promise + )(paseoHome, { listen: "127.0.0.1:6767" }, { ownerPid }); + + const updatedLock = await getPidLockInfo(paseoHome); + expect(updatedLock?.listen).toBe("127.0.0.1:6767"); await ( releasePidLock as unknown as (home: string, options: { ownerPid: number }) => Promise diff --git a/packages/server/src/server/pid-lock.ts b/packages/server/src/server/pid-lock.ts index 9fe584f77..43563a9da 100644 --- a/packages/server/src/server/pid-lock.ts +++ b/packages/server/src/server/pid-lock.ts @@ -8,7 +8,7 @@ export interface PidLockInfo { startedAt: string; hostname: string; uid: number; - listen: string; + listen: string | null; } export class PidLockError extends Error { @@ -43,7 +43,7 @@ function resolveOwnerPid(ownerPid?: number): number { export async function acquirePidLock( paseoHome: string, - listen: string, + listen: string | null, options?: { ownerPid?: number }, ): Promise { const pidPath = getPidFilePath(paseoHome); @@ -114,6 +114,37 @@ export async function acquirePidLock( } } +export async function updatePidLock( + paseoHome: string, + patch: { listen: string }, + options?: { ownerPid?: number }, +): Promise { + const pidPath = getPidFilePath(paseoHome); + const lockOwnerPid = resolveOwnerPid(options?.ownerPid); + const content = await readFile(pidPath, "utf-8"); + const existingLock = JSON.parse(content) as PidLockInfo; + + if (existingLock.pid !== lockOwnerPid) { + throw new PidLockError( + `Cannot update PID lock owned by PID ${existingLock.pid}`, + existingLock, + ); + } + + const updatedLock: PidLockInfo = { + ...existingLock, + ...patch, + }; + + const fd = await open(pidPath, "r+"); + try { + await fd.truncate(0); + await fd.writeFile(JSON.stringify(updatedLock)); + } finally { + await fd.close(); + } +} + export async function releasePidLock( paseoHome: string, options?: { ownerPid?: number }, diff --git a/packages/server/src/server/session.ts b/packages/server/src/server/session.ts index 2d1d32146..24ac670cc 100644 --- a/packages/server/src/server/session.ts +++ b/packages/server/src/server/session.ts @@ -1438,7 +1438,10 @@ export class Session { this.peakInflightRequests = this.inflightRequests; } try { - this.sessionLogger.trace({ inbound: msg }, "inbound message"); + this.sessionLogger.trace( + { messageType: msg.type, payloadBytes: JSON.stringify(msg).length }, + "inbound message", + ); try { switch (msg.type) { case "voice_audio_chunk": @@ -7119,7 +7122,10 @@ export class Session { * Emit a message to the client */ private emit(msg: SessionOutboundMessage): void { - this.sessionLogger.trace({ outbound: msg }, "outbound message"); + this.sessionLogger.trace( + { messageType: msg.type, payloadBytes: JSON.stringify(msg).length }, + "outbound message", + ); if ( msg.type === "audio_output" && (process.env.TTS_DEBUG_AUDIO_DIR || isPaseoDictationDebugEnabled()) && diff --git a/packages/server/tsconfig.scripts.json b/packages/server/tsconfig.scripts.json index 7e198651c..05a232edb 100644 --- a/packages/server/tsconfig.scripts.json +++ b/packages/server/tsconfig.scripts.json @@ -19,6 +19,6 @@ "declaration": false, "sourceMap": true }, - "include": ["scripts/daemon-runner.ts", "scripts/dev-runner.ts", "scripts/supervisor.ts"], + "include": ["scripts/supervisor-entrypoint.ts", "scripts/dev-runner.ts", "scripts/supervisor.ts"], "exclude": ["node_modules", "dist"] }