From 4ed944db2f271f9e7574fb843bf1ff114f8d96c8 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Sun, 19 Jul 2026 02:12:29 +0200 Subject: [PATCH] fix(migrate): validate live source state --- .../desktop/migrations/migration-sheet.tsx | 5 +++ packages/client/src/node.test.ts | 33 +++++++++++++++++-- packages/client/src/node.ts | 17 ++++++++++ .../sources/conductor/project-config.test.ts | 7 +++- .../src/sources/conductor/project-config.ts | 12 +++++++ 5 files changed, 70 insertions(+), 4 deletions(-) diff --git a/packages/app/src/desktop/migrations/migration-sheet.tsx b/packages/app/src/desktop/migrations/migration-sheet.tsx index 036e8765ea..de4017b8c5 100644 --- a/packages/app/src/desktop/migrations/migration-sheet.tsx +++ b/packages/app/src/desktop/migrations/migration-sheet.tsx @@ -34,6 +34,7 @@ export function useMigrationAvailability(source: string) { const [visible, setVisible] = useState(false); useEffect(() => { let active = true; + let retry: ReturnType | null = null; async function loadAvailability() { const next = await getDesktopHost()?.migrations?.getAvailability?.({ source }); if (active && next) { @@ -43,11 +44,15 @@ export function useMigrationAvailability(source: string) { ? t(`desktop.integrations.migration.availability.${next.reason}`) : null, }); + if (next.reason === "host-not-running") { + retry = setTimeout(() => void loadAvailability(), 2_000); + } } } void loadAvailability(); return () => { active = false; + if (retry) clearTimeout(retry); }; }, [source, t]); return { diff --git a/packages/client/src/node.test.ts b/packages/client/src/node.test.ts index a6a652e1f6..810f245277 100644 --- a/packages/client/src/node.test.ts +++ b/packages/client/src/node.test.ts @@ -19,7 +19,10 @@ test("discovers the configured local socket before TCP fallback", () => { const paseoHome = mkdtempSync(path.join(os.tmpdir(), "paseo-node-client-")); cleanup.push(paseoHome); mkdirSync(paseoHome, { recursive: true }); - writeFileSync(path.join(paseoHome, "paseo.pid"), JSON.stringify({ listen: "/tmp/paseo.sock" })); + writeFileSync( + path.join(paseoHome, "paseo.pid"), + JSON.stringify({ pid: process.pid, listen: "/tmp/paseo.sock" }), + ); writeFileSync( path.join(paseoHome, "config.json"), JSON.stringify({ daemon: { listen: "127.0.0.1:7777" } }), @@ -35,7 +38,10 @@ test("discovers the configured local socket before TCP fallback", () => { test("discovers the running daemon TCP address from its PID record", () => { const paseoHome = mkdtempSync(path.join(os.tmpdir(), "paseo-node-client-pid-tcp-")); cleanup.push(paseoHome); - writeFileSync(path.join(paseoHome, "paseo.pid"), JSON.stringify({ listen: "127.0.0.1:7789" })); + writeFileSync( + path.join(paseoHome, "paseo.pid"), + JSON.stringify({ pid: process.pid, listen: "127.0.0.1:7789" }), + ); expect(resolveDefaultDaemonHosts({ PASEO_HOME: paseoHome })).toEqual([ "127.0.0.1:7789", @@ -46,7 +52,10 @@ test("discovers the running daemon TCP address from its PID record", () => { test("normalizes the daemon's bare IPv6 loopback PID address", () => { const paseoHome = mkdtempSync(path.join(os.tmpdir(), "paseo-node-client-pid-ipv6-")); cleanup.push(paseoHome); - writeFileSync(path.join(paseoHome, "paseo.pid"), JSON.stringify({ listen: "::1:7789" })); + writeFileSync( + path.join(paseoHome, "paseo.pid"), + JSON.stringify({ pid: process.pid, listen: "::1:7789" }), + ); expect(resolveDefaultDaemonHosts({ PASEO_HOME: paseoHome })).toEqual([ "[::1]:7789", @@ -102,3 +111,21 @@ test("skips malformed discovered candidates and keeps the default fallback", () resolveDefaultDaemonHosts({ PASEO_HOME: paseoHome, PASEO_LISTEN: "tcp://missing-port" }), ).toEqual(["localhost:6767"]); }); + +test("ignores a stale PID listener before the configured daemon", () => { + const paseoHome = mkdtempSync(path.join(os.tmpdir(), "paseo-node-client-stale-pid-")); + cleanup.push(paseoHome); + writeFileSync( + path.join(paseoHome, "paseo.pid"), + JSON.stringify({ pid: 2_147_483_647, listen: "127.0.0.1:7789" }), + ); + writeFileSync( + path.join(paseoHome, "config.json"), + JSON.stringify({ daemon: { listen: "127.0.0.1:7777" } }), + ); + + expect(resolveDefaultDaemonHosts({ PASEO_HOME: paseoHome })).toEqual([ + "127.0.0.1:7777", + "localhost:6767", + ]); +}); diff --git a/packages/client/src/node.ts b/packages/client/src/node.ts index d27b8ca112..1b6ae28173 100644 --- a/packages/client/src/node.ts +++ b/packages/client/src/node.ts @@ -111,9 +111,17 @@ function readPidListen(paseoHome: string): string | null { if (!existsSync(pidPath)) return null; try { const parsed = JSON.parse(readFileSync(pidPath, "utf8")) as { + pid?: unknown; listen?: unknown; sockPath?: unknown; }; + if ( + typeof parsed.pid !== "number" || + !Number.isInteger(parsed.pid) || + !isProcessRunning(parsed.pid) + ) { + return null; + } if (typeof parsed.listen === "string") return parsed.listen; return typeof parsed.sockPath === "string" ? parsed.sockPath : null; } catch { @@ -121,6 +129,15 @@ function readPidListen(paseoHome: string): string | null { } } +function isProcessRunning(pid: number): boolean { + try { + process.kill(pid, 0); + return true; + } catch (error) { + return typeof error === "object" && error !== null && "code" in error && error.code === "EPERM"; + } +} + export function resolveDefaultDaemonHosts(env: NodeJS.ProcessEnv = process.env): string[] { const paseoHome = resolvePaseoHome(env); const direct = normalizeDaemonHost(env.PASEO_LISTEN ?? ""); diff --git a/packages/migrate/src/sources/conductor/project-config.test.ts b/packages/migrate/src/sources/conductor/project-config.test.ts index 2bfcd50b84..2f8dbe8fd4 100644 --- a/packages/migrate/src/sources/conductor/project-config.test.ts +++ b/packages/migrate/src/sources/conductor/project-config.test.ts @@ -156,6 +156,7 @@ test("preserves literal or escaped Conductor names and rewrites active variables escaped: { command: "printf \\$CONDUCTOR_PORT" }, escapedDouble: { command: 'printf "\\$CONDUCTOR_PORT"' }, bare: { command: "printf CONDUCTOR_PORT" }, + heredoc: { command: "cat <<'EOF'\n$CONDUCTOR_PORT\nEOF" }, active: { command: "printf \"'$CONDUCTOR_PORT'\"" }, }, }, @@ -170,7 +171,11 @@ test("preserves literal or escaped Conductor names and rewrites active variables escapedDouble: { command: 'printf "\\$CONDUCTOR_PORT"' }, }, }); - expect(inspected.notices).toEqual([]); + expect(inspected.notices).toContainEqual({ + code: "conductor-setting-unsupported", + level: "warning", + message: "scripts.heredoc: Here-document commands are not imported.", + }); }); test("skips cwd scripts on Windows instead of emitting POSIX shell syntax", () => { diff --git a/packages/migrate/src/sources/conductor/project-config.ts b/packages/migrate/src/sources/conductor/project-config.ts index 4ccd1401fe..3b8e63f335 100644 --- a/packages/migrate/src/sources/conductor/project-config.ts +++ b/packages/migrate/src/sources/conductor/project-config.ts @@ -369,6 +369,10 @@ function rewriteExactCommand( notices: MigrationNotice[], platform: NodeJS.Platform, ): string | null { + if (containsActiveHereDocument(command)) { + notices.push(unsupportedSetting(key, "Here-document commands are not imported.")); + return null; + } const sourceVariables = collectConductorVariables(command); if (platform === "win32" && sourceVariables.length > 0) { notices.push( @@ -714,6 +718,14 @@ function forEachActiveArithmetic(command: string, visit: (body: string) => void) } } +function containsActiveHereDocument(command: string): boolean { + const mask = activeShellMask(command); + for (const match of command.matchAll(/<<-?/g)) { + if (mask.slice(match.index, match.index + match[0].length) === match[0]) return true; + } + return false; +} + function appendArgs(command: string, args: string[]): string { return args.length === 0 ? command : `${command} ${args.map(shellQuoteArgument).join(" ")}`; }