Unified · Split
| @@ -0,0 +1,5 @@ | |||
| 1 | --- | ||
| 2 | "executor": patch | ||
| 3 | --- | ||
| 4 | |||
| 5 | Windows installs now repair stale Executor service listeners and only report success after the background daemon publishes the sign-in manifest used by `executor web`. The desktop app also attaches to a reachable supervised daemon before trusting Windows PID probes, so it no longer starts a competing sidecar when the background service already owns the port. | ||
| @@ -124,7 +124,12 @@ import { · 123 unmodified lines | |||
| 124 | resolveExecutorDataDir, | 124 | resolveExecutorDataDir, |
| 125 | writeLocalServerManifest, | 125 | writeLocalServerManifest, |
| 126 | } from "./local-server-manifest"; | 126 | } from "./local-server-manifest"; |
| 127 | import { DEFAULT_SERVICE_PORT, getServiceBackend, SERVICE_LABEL } from "./service"; | 127 | import { |
| 128 | DEFAULT_SERVICE_PORT, | ||
| 129 | getServiceBackend, | ||
| 130 | SERVICE_LABEL, | ||
| 131 | stopWindowsExecutorListenersOnPort, | ||
| 132 | } from "./service"; | ||
| 128 | import { | 133 | import { |
| 129 | defaultCliServerConnectionProfile, | 134 | defaultCliServerConnectionProfile, |
| 130 | findCliServerConnectionProfile, | 135 | findCliServerConnectionProfile, |
| @@ -165,6 +170,7 @@ const DEFAULT_BASE_URL = `http://localhost:${DEFAULT_PORT}`; · 34 unmodified lines | |||
| 165 | const DAEMON_BOOT_TIMEOUT_MS = 15_000; | 170 | const DAEMON_BOOT_TIMEOUT_MS = 15_000; |
| 166 | const DAEMON_BOOT_POLL_MS = 150; | 171 | const DAEMON_BOOT_POLL_MS = 150; |
| 167 | const DAEMON_STOP_TIMEOUT_MS = 10_000; | 172 | const DAEMON_STOP_TIMEOUT_MS = 10_000; |
| 173 | const SERVICE_BOOT_TIMEOUT_MS = 45_000; | ||
| 168 | 174 | ||
| 169 | // --------------------------------------------------------------------------- | 175 | // --------------------------------------------------------------------------- |
| 170 | // Helpers | 176 | // Helpers |
| @@ -197,15 +203,15 @@ const readActiveLocalServerManifest = (): Effect.Effect< · 26 unmodified lines | |||
| 197 | const manifest = yield* readLocalServerManifest(); | 203 | const manifest = yield* readLocalServerManifest(); |
| 198 | if (!manifest) return null; | 204 | if (!manifest) return null; |
| 199 | 205 | ||
| 206 | if (yield* isServerReachable(manifest.connection.origin)) { | ||
| 207 | return manifest; | ||
| 208 | } | ||
| 209 | |||
| 200 | if (!isPidAlive(manifest.pid)) { | 210 | if (!isPidAlive(manifest.pid)) { |
| 201 | yield* removeLocalServerManifestIfOwnedBy({ pid: manifest.pid }).pipe(Effect.ignore); | 211 | yield* removeLocalServerManifestIfOwnedBy({ pid: manifest.pid }).pipe(Effect.ignore); |
| 202 | return null; | 212 | return null; |
| 203 | } | 213 | } |
| 204 | 214 | ||
| 205 | if (yield* isServerReachable(manifest.connection.origin)) { | ||
| 206 | return manifest; | ||
| 207 | } | ||
| 208 | |||
| 209 | return yield* Effect.fail( | 215 | return yield* Effect.fail( |
| 210 | new Error( | 216 | new Error( |
| 211 | [ | 217 | [ |
| @@ -2201,6 +2207,68 @@ const mcpCommand = Command.make( · 1989 unmodified lines | |||
| 2201 | 2207 | ||
| 2202 | const supervisedServiceOrigin = (port: number): string => `http://127.0.0.1:${port}`; | 2208 | const supervisedServiceOrigin = (port: number): string => `http://127.0.0.1:${port}`; |
| 2203 | 2209 | ||
| 2210 | const LOCAL_SERVICE_HOSTS = new Set(["localhost", "127.0.0.1", "::1", "[::1]"]); | ||
| 2211 | |||
| 2212 | const originPort = (url: URL): string => url.port || (url.protocol === "https:" ? "443" : "80"); | ||
| 2213 | |||
| 2214 | const sameServerOrigin = (left: string, right: string): boolean => { | ||
| 2215 | const leftUrl = new URL(normalizeExecutorServerConnection({ origin: left }).origin); | ||
| 2216 | const rightUrl = new URL(normalizeExecutorServerConnection({ origin: right }).origin); | ||
| 2217 | if (leftUrl.toString() === rightUrl.toString()) return true; | ||
| 2218 | return ( | ||
| 2219 | leftUrl.protocol === rightUrl.protocol && | ||
| 2220 | originPort(leftUrl) === originPort(rightUrl) && | ||
| 2221 | LOCAL_SERVICE_HOSTS.has(leftUrl.hostname.toLowerCase()) && | ||
| 2222 | LOCAL_SERVICE_HOSTS.has(rightUrl.hostname.toLowerCase()) | ||
| 2223 | ); | ||
| 2224 | }; | ||
| 2225 | |||
| 2226 | const hasReachableCliDaemonManifest = (input: { | ||
| 2227 | readonly origin: string; | ||
| 2228 | readonly version: string; | ||
| 2229 | }): Effect.Effect<boolean, never, FileSystem.FileSystem | PlatformPath.Path> => | ||
| 2230 | Effect.gen(function* () { | ||
| 2231 | const manifest = yield* readActiveLocalServerManifest().pipe( | ||
| 2232 | Effect.catchCause(() => Effect.succeed(null)), | ||
| 2233 | ); | ||
| 2234 | if (!manifest || manifest.kind !== "cli-daemon") return false; | ||
| 2235 | if (!sameServerOrigin(manifest.connection.origin, input.origin)) return false; | ||
| 2236 | if (manifest.owner.version !== input.version) return false; | ||
| 2237 | return yield* isServerReachable(manifest.connection.origin); | ||
| 2238 | }); | ||
| 2239 | |||
| 2240 | const clearUnmanifestedWindowsExecutorListener = (input: { | ||
| 2241 | readonly port: number; | ||
| 2242 | readonly origin: string; | ||
| 2243 | }): Effect.Effect<void, Error, FileSystem.FileSystem | PlatformPath.Path> => | ||
| 2244 | Effect.gen(function* () { | ||
| 2245 | const active = yield* readActiveLocalServerManifest().pipe( | ||
| 2246 | Effect.catchCause(() => Effect.succeed(null)), | ||
| 2247 | ); | ||
| 2248 | if (active && sameServerOrigin(active.connection.origin, input.origin)) return; | ||
| 2249 | if (!(yield* isServerReachable(input.origin))) return; | ||
| 2250 | |||
| 2251 | const stoppedPids = yield* stopWindowsExecutorListenersOnPort(input.port); | ||
| 2252 | const stopped = yield* waitForUnreachable({ | ||
| 2253 | check: isServerReachable(input.origin), | ||
| 2254 | timeoutMs: DAEMON_STOP_TIMEOUT_MS, | ||
| 2255 | intervalMs: DAEMON_BOOT_POLL_MS, | ||
| 2256 | }); | ||
| 2257 | if (stopped) return; | ||
| 2258 | |||
| 2259 | return yield* Effect.fail( | ||
| 2260 | new Error( | ||
| 2261 | [ | ||
| 2262 | `Executor is already reachable at ${input.origin}, but no live server manifest exists for it.`, | ||
| 2263 | stoppedPids.length > 0 | ||
| 2264 | ? `Tried to stop orphaned Executor pid(s): ${stoppedPids.join(", ")}.` | ||
| 2265 | : `No orphaned executor.exe listener could be stopped on port ${input.port}.`, | ||
| 2266 | "Stop the process using that port and re-run the install.", | ||
| 2267 | ].join("\n"), | ||
| 2268 | ), | ||
| 2269 | ); | ||
| 2270 | }); | ||
| 2271 | |||
| 2204 | const portFromOrigin = (origin: string): number | null => { | 2272 | const portFromOrigin = (origin: string): number | null => { |
| 2205 | try { | 2273 | try { |
| 2206 | const url = new URL(origin); | 2274 | const url = new URL(origin); |
| @@ -2274,7 +2342,10 @@ const installService = (port: number, commandName: string) => · 67 unmodified lines | |||
| 2274 | return; | 2342 | return; |
| 2275 | } | 2343 | } |
| 2276 | 2344 | ||
| 2277 | if (plan === "takeover-then-install") { | 2345 | if ( |
| 2346 | plan === "takeover-then-install" || | ||
| 2347 | (backend.platform === "win32" && plan === "reinstall") | ||
| 2348 | ) { | ||
| 2278 | const replaced = yield* takeOverActiveLocalServer(); | 2349 | const replaced = yield* takeOverActiveLocalServer(); |
| 2279 | if (replaced) { | 2350 | if (replaced) { |
| 2280 | console.log( | 2351 | console.log( |
| @@ -2294,22 +2365,29 @@ const installService = (port: number, commandName: string) => · 13 unmodified lines | |||
| 2294 | console.log(""); | 2365 | console.log(""); |
| 2295 | console.log("Writing the service definition and starting Executor..."); | 2366 | console.log("Writing the service definition and starting Executor..."); |
| 2296 | 2367 | ||
| 2368 | if (backend.platform === "win32") { | ||
| 2369 | yield* clearUnmanifestedWindowsExecutorListener({ port, origin }); | ||
| 2370 | } | ||
| 2371 | |||
| 2297 | // The unit carries no secret: the supervised daemon mints/loads its bearer | 2372 | // The unit carries no secret: the supervised daemon mints/loads its bearer |
| 2298 | // from auth.json (under EXECUTOR_DATA_DIR) on first boot, and clients read | 2373 | // from auth.json (under EXECUTOR_DATA_DIR) on first boot, and clients read |
| 2299 | // the same file — so reachability is the credential-free /api/health probe. | 2374 | // the same file — so reachability is the credential-free /api/health probe. |
| 2300 | yield* backend.install({ executablePath: process.execPath, port, version: CLI_VERSION }); | 2375 | yield* backend.install({ executablePath: process.execPath, port, version: CLI_VERSION }); |
| 2301 | 2376 | ||
| 2302 | console.log(`Waiting for Executor to become reachable at ${origin}...`); | 2377 | console.log(`Waiting for Executor to publish its service manifest at ${origin}...`); |
| 2303 | const reachable = yield* waitForReachable({ | 2378 | const reachable = yield* waitForReachable({ |
| 2304 | check: isServerReachable(origin), | 2379 | check: hasReachableCliDaemonManifest({ |
| 2305 | timeoutMs: DAEMON_BOOT_TIMEOUT_MS, | 2380 | origin, |
| 2381 | version: CLI_VERSION, | ||
| 2382 | }), | ||
| 2383 | timeoutMs: SERVICE_BOOT_TIMEOUT_MS, | ||
| 2306 | intervalMs: DAEMON_BOOT_POLL_MS, | 2384 | intervalMs: DAEMON_BOOT_POLL_MS, |
| 2307 | }); | 2385 | }); |
| 2308 | if (!reachable) { | 2386 | if (!reachable) { |
| 2309 | return yield* Effect.fail( | 2387 | return yield* Effect.fail( |
| 2310 | new Error( | 2388 | new Error( |
| 2311 | [ | 2389 | [ |
| 2312 | `Installed ${SERVICE_LABEL} but it did not become reachable at ${origin} within ${DAEMON_BOOT_TIMEOUT_MS / 1000}s.`, | 2390 | `Installed ${SERVICE_LABEL} but it did not publish a reachable server manifest for ${origin} within ${SERVICE_BOOT_TIMEOUT_MS / 1000}s.`, |
| 2313 | `Check ~/.executor/logs/daemon.error.log and \`${cliPrefix} service status\`.`, | 2391 | `Check ~/.executor/logs/daemon.error.log and \`${cliPrefix} service status\`.`, |
| 2314 | ].join("\n"), | 2392 | ].join("\n"), |
| 2315 | ), | 2393 | ), |
| @@ -2334,19 +2412,25 @@ const serviceInstallCommand = Command.make( · 18 unmodified lines | |||
| 2334 | const serviceUninstallCommand = Command.make("uninstall", {}, () => | 2412 | const serviceUninstallCommand = Command.make("uninstall", {}, () => |
| 2335 | Effect.gen(function* () { | 2413 | Effect.gen(function* () { |
| 2336 | const backend = getServiceBackend(); | 2414 | const backend = getServiceBackend(); |
| 2337 | const wasRunning = backend.automated | 2415 | const activeBefore = yield* readActiveLocalServerManifest().pipe( |
| 2338 | ? yield* backend.status().pipe( | 2416 | Effect.catchCause(() => Effect.succeed(null)), |
| 2339 | Effect.map((status) => status.running), | 2417 | ); |
| 2340 | Effect.catchCause(() => Effect.succeed(false)), | 2418 | const servicePort = activeBefore |
| 2341 | ) | 2419 | ? (portFromOrigin(activeBefore.connection.origin) ?? DEFAULT_SERVICE_PORT) |
| 2342 | : false; | 2420 | : DEFAULT_SERVICE_PORT; |
| 2421 | const status = backend.automated | ||
| 2422 | ? yield* backend.status().pipe(Effect.catchCause(() => Effect.succeed(null))) | ||
| 2423 | : null; | ||
| 2343 | yield* backend.uninstall(); | 2424 | yield* backend.uninstall(); |
| 2344 | if (wasRunning) { | 2425 | const stopped = yield* takeOverActiveLocalServer({ onlyKind: "cli-daemon" }); |
| 2345 | const stopped = yield* takeOverActiveLocalServer({ onlyKind: "cli-daemon" }); | 2426 | if (stopped) { |
| 2346 | if (stopped) { | 2427 | console.log( |
| 2347 | console.log( | 2428 | `Stopped running Executor daemon at ${stopped.connection.origin} (pid ${stopped.pid}).`, |
| 2348 | `Stopped running Executor daemon at ${stopped.connection.origin} (pid ${stopped.pid}).`, | 2429 | ); |
| 2349 | ); | 2430 | } else if (backend.platform === "win32" && status?.registered) { |
| 2431 | const stoppedPids = yield* stopWindowsExecutorListenersOnPort(servicePort); | ||
| 2432 | if (stoppedPids.length > 0) { | ||
| 2433 | console.log(`Stopped orphaned Executor daemon pid(s): ${stoppedPids.join(", ")}.`); | ||
| 2350 | } | 2434 | } |
| 2351 | } | 2435 | } |
| 2352 | console.log("Executor background service uninstalled."); | 2436 | console.log("Executor background service uninstalled."); |
| @@ -6,6 +6,7 @@ import { · 5 unmodified lines | |||
| 6 | generateSystemdUnit, | 6 | generateSystemdUnit, |
| 7 | generateWindowsDaemonWrapper, | 7 | generateWindowsDaemonWrapper, |
| 8 | generateWindowsRegisterScript, | 8 | generateWindowsRegisterScript, |
| 9 | generateWindowsStopExecutorListenersScript, | ||
| 9 | getServiceBackend, | 10 | getServiceBackend, |
| 10 | } from "./service"; | 11 | } from "./service"; |
| 11 | 12 | ||
| @@ -136,6 +137,16 @@ describe("service unit generation", () => { · 124 unmodified lines | |||
| 136 | expect(script).toContain("Register-ScheduledTask -TaskName 'ExecutorDaemon'"); | 137 | expect(script).toContain("Register-ScheduledTask -TaskName 'ExecutorDaemon'"); |
| 137 | expect(script).toContain("Start-ScheduledTask -TaskName 'ExecutorDaemon'"); | 138 | expect(script).toContain("Start-ScheduledTask -TaskName 'ExecutorDaemon'"); |
| 138 | }); | 139 | }); |
| 140 | |||
| 141 | it("stops orphaned executor.exe listeners without shadowing PowerShell's $PID", () => { | ||
| 142 | const script = generateWindowsStopExecutorListenersScript(4789); | ||
| 143 | |||
| 144 | expect(script).toContain("Get-NetTCPConnection -LocalPort 4789 -State Listen"); | ||
| 145 | expect(script).toContain("$listenerPid"); | ||
| 146 | expect(script).not.toContain("foreach ($pid"); | ||
| 147 | expect(script).toContain("[string]$process.Name -ieq 'executor.exe'"); | ||
| 148 | expect(script).toContain('Write-Output ("STOPPED=" + $listenerPid)'); | ||
| 149 | }); | ||
| 139 | }); | 150 | }); |
| 140 | 151 | ||
| 141 | describe("service backend dispatch", () => { | 152 | describe("service backend dispatch", () => { |
| @@ -579,6 +579,28 @@ export const generateWindowsRegisterScript = (options: { · 578 unmodified lines | |||
| 579 | `Start-ScheduledTask -TaskName ${psSingleQuote(options.taskName)}`, | 579 | `Start-ScheduledTask -TaskName ${psSingleQuote(options.taskName)}`, |
| 580 | ].join("\n"); | 580 | ].join("\n"); |
| 581 | 581 | ||
| 582 | /** | ||
| 583 | * Stop orphaned Windows `executor.exe` listeners on the service port. Task | ||
| 584 | * Scheduler can report the task as stopped after terminating the `.cmd` action | ||
| 585 | * while leaving the child `executor.exe` process bound to 4789. That process is | ||
| 586 | * healthy enough to satisfy `/api/health`, but `executor web` cannot discover it | ||
| 587 | * once its `server.json` is gone. | ||
| 588 | */ | ||
| 589 | export const generateWindowsStopExecutorListenersScript = (port: number): string => | ||
| 590 | [ | ||
| 591 | `$connections = @(Get-NetTCPConnection -LocalPort ${port} -State Listen -ErrorAction SilentlyContinue)`, | ||
| 592 | `$listenerPids = @($connections | Select-Object -ExpandProperty OwningProcess -Unique)`, | ||
| 593 | `foreach ($listenerPid in $listenerPids) {`, | ||
| 594 | ` $process = Get-CimInstance Win32_Process -Filter "ProcessId=$listenerPid" -ErrorAction SilentlyContinue`, | ||
| 595 | ` if ($null -eq $process) { continue }`, | ||
| 596 | ` $pathName = if ($process.ExecutablePath) { [System.IO.Path]::GetFileName([string]$process.ExecutablePath) } else { '' }`, | ||
| 597 | ` if ([string]$process.Name -ieq 'executor.exe' -or $pathName -ieq 'executor.exe') {`, | ||
| 598 | ` Stop-Process -Id $listenerPid -Force -ErrorAction SilentlyContinue`, | ||
| 599 | ` Write-Output ("STOPPED=" + $listenerPid)`, | ||
| 600 | ` }`, | ||
| 601 | `}`, | ||
| 602 | ].join("\n"); | ||
| 603 | |||
| 582 | /** Run a PowerShell script via -EncodedCommand (sidesteps all shell quoting). */ | 604 | /** Run a PowerShell script via -EncodedCommand (sidesteps all shell quoting). */ |
| 583 | const runPowerShell = (script: string): Effect.Effect<CommandResult, Error> => | 605 | const runPowerShell = (script: string): Effect.Effect<CommandResult, Error> => |
| 584 | runCommand("powershell.exe", [ | 606 | runCommand("powershell.exe", [ |
| @@ -590,6 +612,25 @@ const runPowerShell = (script: string): Effect.Effect<CommandResult, Error> => · 5 unmodified lines | |||
| 590 | Buffer.from(script, "utf16le").toString("base64"), | 612 | Buffer.from(script, "utf16le").toString("base64"), |
| 591 | ]); | 613 | ]); |
| 592 | 614 | ||
| 615 | export const stopWindowsExecutorListenersOnPort = ( | ||
| 616 | port: number, | ||
| 617 | ): Effect.Effect<ReadonlyArray<number>, Error> => | ||
| 618 | Effect.gen(function* () { | ||
| 619 | const result = yield* runPowerShell(generateWindowsStopExecutorListenersScript(port)); | ||
| 620 | if (result.code !== 0) { | ||
| 621 | const detail = result.stderr.trim() || result.stdout.trim(); | ||
| 622 | return yield* Effect.fail( | ||
| 623 | new Error(`Failed to inspect Executor listeners on port ${port}: ${detail}`), | ||
| 624 | ); | ||
| 625 | } | ||
| 626 | return result.stdout.split(/\r?\n/).flatMap((line) => { | ||
| 627 | const match = /^STOPPED=(\d+)\s*$/.exec(line.trim()); | ||
| 628 | if (!match) return []; | ||
| 629 | const pid = Number.parseInt(match[1], 10); | ||
| 630 | return Number.isInteger(pid) && pid > 0 ? [pid] : []; | ||
| 631 | }); | ||
| 632 | }); | ||
| 633 | |||
| 593 | const makeWindowsBackend = (): ServiceBackend => ({ | 634 | const makeWindowsBackend = (): ServiceBackend => ({ |
| 594 | platform: "win32", | 635 | platform: "win32", |
| 595 | automated: true, | 636 | automated: true, |
| @@ -16,6 +16,7 @@ · 15 unmodified lines | |||
| 16 | "package:mac": "electron-builder --mac --config electron-builder.config.ts", | 16 | "package:mac": "electron-builder --mac --config electron-builder.config.ts", |
| 17 | "package:win": "electron-builder --win --config electron-builder.config.ts", | 17 | "package:win": "electron-builder --win --config electron-builder.config.ts", |
| 18 | "package:linux": "electron-builder --linux --config electron-builder.config.ts", | 18 | "package:linux": "electron-builder --linux --config electron-builder.config.ts", |
| 19 | "test": "bun --bun vitest run", | ||
| 19 | "test:smoke": "bun ./scripts/smoke-sidecar.ts", | 20 | "test:smoke": "bun ./scripts/smoke-sidecar.ts", |
| 20 | "typecheck": "tsgo --noEmit", | 21 | "typecheck": "tsgo --noEmit", |
| 21 | "typecheck:slow": "tsc --noEmit" | 22 | "typecheck:slow": "tsc --noEmit" |
| @@ -29,6 +30,7 @@ · 7 unmodified lines | |||
| 29 | "electron-window-state": "^5.0.3" | 30 | "electron-window-state": "^5.0.3" |
| 30 | }, | 31 | }, |
| 31 | "devDependencies": { | 32 | "devDependencies": { |
| 33 | "@effect/vitest": "catalog:", | ||
| 32 | "@executor-js/app": "workspace:*", | 34 | "@executor-js/app": "workspace:*", |
| 33 | "@executor-js/local": "workspace:*", | 35 | "@executor-js/local": "workspace:*", |
| 34 | "@executor-js/plugin-desktop-settings": "workspace:*", | 36 | "@executor-js/plugin-desktop-settings": "workspace:*", |
| @@ -50,6 +52,7 @@ · 15 unmodified lines | |||
| 50 | "electron-vite": "^5", | 52 | "electron-vite": "^5", |
| 51 | "quickjs-emscripten": "catalog:", | 53 | "quickjs-emscripten": "catalog:", |
| 52 | "typescript": "catalog:", | 54 | "typescript": "catalog:", |
| 53 | "vite": "catalog:" | 55 | "vite": "catalog:", |
| 56 | "vitest": "catalog:" | ||
| 54 | } | 57 | } |
| 55 | } | 58 | } |
| @@ -24,6 +24,7 @@ import { · 23 unmodified lines | |||
| 24 | import { loadOrMintLocalAuthToken } from "./local-auth"; | 24 | import { loadOrMintLocalAuthToken } from "./local-auth"; |
| 25 | import { getServerSettings } from "./settings"; | 25 | import { getServerSettings } from "./settings"; |
| 26 | import { reportSidecarCrash, sidecarCrashReportingEnv } from "./diagnostics"; | 26 | import { reportSidecarCrash, sidecarCrashReportingEnv } from "./diagnostics"; |
| 27 | import { resolveSupervisedDaemonAttach } from "./supervised-daemon"; | ||
| 27 | import { SERVER_SETTINGS_USERNAME, type DesktopServerSettings } from "../shared/server-settings"; | 28 | import { SERVER_SETTINGS_USERNAME, type DesktopServerSettings } from "../shared/server-settings"; |
| 28 | 29 | ||
| 29 | // Sidecar output is echoed to the terminal (visible when Electron is run | 30 | // Sidecar output is echoed to the terminal (visible when Electron is run |
| @@ -465,10 +466,9 @@ const isDaemonReachable = async (origin: string): Promise<boolean> => { · 435 unmodified lines | |||
| 465 | 466 | ||
| 466 | /** | 467 | /** |
| 467 | * Attach to an already-running OS-supervised daemon instead of spawning our own | 468 | * Attach to an already-running OS-supervised daemon instead of spawning our own |
| 468 | * sidecar. Reads `server.json`, confirms the recorded process is alive and the | 469 | * sidecar. Reads `server.json`, confirms the endpoint answers, and returns a |
| 469 | * endpoint answers, and returns a child-less `SidecarConnection` flagged | 470 | * child-less `SidecarConnection` flagged `supervisedDaemon: true`. Returns null |
| 470 | * `supervisedDaemon: true`. Returns null when no usable supervised daemon is | 471 | * when no usable supervised daemon is present. |
| 471 | * present. | ||
| 472 | * | 472 | * |
| 473 | * Only a `cli-daemon` manifest is treated as supervised — a `desktop-sidecar` | 473 | * Only a `cli-daemon` manifest is treated as supervised — a `desktop-sidecar` |
| 474 | * manifest belongs to a managed sidecar (ours or another desktop instance) and | 474 | * manifest belongs to a managed sidecar (ours or another desktop instance) and |
| @@ -477,36 +477,40 @@ const isDaemonReachable = async (origin: string): Promise<boolean> => { · 2 unmodified lines | |||
| 477 | export async function attachToSupervisedDaemon(): Promise<SidecarConnection | null> { | 477 | export async function attachToSupervisedDaemon(): Promise<SidecarConnection | null> { |
| 478 | const dataDir = join(homedir(), ".executor"); | 478 | const dataDir = join(homedir(), ".executor"); |
| 479 | const manifest = readManifest(dataDir); | 479 | const manifest = readManifest(dataDir); |
| 480 | if (!manifest || manifest.kind !== "cli-daemon") return null; | 480 | const decision = await resolveSupervisedDaemonAttach(manifest, { |
| 481 | if (!isPidAlive(manifest.pid)) { | 481 | isReachable: isDaemonReachable, |
| 482 | removeManifestIfOwnedBy(dataDir, manifest.pid); | 482 | isPidAlive, |
| 483 | return null; | 483 | }); |
| 484 | if (decision._tag === "Attach") { | ||
| 485 | const { manifest, authToken } = decision; | ||
| 486 | const origin = manifest.connection.origin; | ||
| 487 | const url = new URL(origin); | ||
| 488 | sidecarLog.info(`attaching to supervised daemon at ${origin} (pid ${manifest.pid})`); | ||
| 489 | return { | ||
| 490 | baseUrl: origin, | ||
| 491 | hostname: url.hostname, | ||
| 492 | port: Number.parseInt(url.port, 10) || (url.protocol === "https:" ? 443 : 80), | ||
| 493 | username: SERVER_SETTINGS_USERNAME, | ||
| 494 | authToken, | ||
| 495 | child: null, | ||
| 496 | supervisedDaemon: true, | ||
| 497 | ownerVersion: manifest.owner.version, | ||
| 498 | ownerClient: manifest.owner.client, | ||
| 499 | ownerExecutablePath: manifest.owner.executablePath, | ||
| 500 | }; | ||
| 484 | } | 501 | } |
| 485 | 502 | ||
| 486 | const origin = manifest.connection.origin; | 503 | if (decision._tag === "RemoveStaleManifest") { |
| 487 | const auth = manifest.connection.auth; | 504 | removeManifestIfOwnedBy(dataDir, decision.pid); |
| 488 | const authToken = auth && auth.kind === "bearer" ? auth.token : ""; | ||
| 489 | if (!(await isDaemonReachable(origin))) { | ||
| 490 | sidecarLog.warn( | ||
| 491 | `supervised daemon at ${origin} (pid ${manifest.pid}) did not answer the health probe; keeping its manifest because the process is still alive`, | ||
| 492 | ); | ||
| 493 | return null; | 505 | return null; |
| 494 | } | 506 | } |
| 495 | 507 | ||
| 496 | const url = new URL(origin); | 508 | if (!manifest || manifest.kind !== "cli-daemon") return null; |
| 497 | sidecarLog.info(`attaching to supervised daemon at ${origin} (pid ${manifest.pid})`); | 509 | |
| 498 | return { | 510 | sidecarLog.warn( |
| 499 | baseUrl: origin, | 511 | `supervised daemon at ${manifest.connection.origin} (pid ${manifest.pid}) did not answer the health probe; keeping its manifest because the process is still alive`, |
| 500 | hostname: url.hostname, | 512 | ); |
| 501 | port: Number.parseInt(url.port, 10) || (url.protocol === "https:" ? 443 : 80), | 513 | return null; |
| 502 | username: SERVER_SETTINGS_USERNAME, | ||
| 503 | authToken, | ||
| 504 | child: null, | ||
| 505 | supervisedDaemon: true, | ||
| 506 | ownerVersion: manifest.owner.version, | ||
| 507 | ownerClient: manifest.owner.client, | ||
| 508 | ownerExecutablePath: manifest.owner.executablePath, | ||
| 509 | }; | ||
| 510 | } | 514 | } |
| 511 | 515 | ||
| 512 | export async function stopSidecar(child: ChildProcess): Promise<void> { | 516 | export async function stopSidecar(child: ChildProcess): Promise<void> { |
| @@ -0,0 +1,47 @@ | |||
| 1 | import { describe, expect, it } from "@effect/vitest"; | ||
| 2 | import { normalizeExecutorServerConnection } from "@executor-js/sdk/shared"; | ||
| 3 | import { resolveSupervisedDaemonAttach } from "./supervised-daemon"; | ||
| 4 | |||
| 5 | const manifest = { | ||
| 6 | version: 1 as const, | ||
| 7 | kind: "cli-daemon" as const, | ||
| 8 | pid: 1234, | ||
| 9 | startedAt: "2026-06-18T00:00:00.000Z", | ||
| 10 | dataDir: "C:\\Users\\rhys\\.executor", | ||
| 11 | scopeDir: "C:\\Users\\rhys\\.executor", | ||
| 12 | connection: normalizeExecutorServerConnection({ | ||
| 13 | origin: "http://localhost:4789", | ||
| 14 | auth: { kind: "bearer" as const, token: "secret" }, | ||
| 15 | }), | ||
| 16 | owner: { | ||
| 17 | client: "cli" as const, | ||
| 18 | version: "1.5.12", | ||
| 19 | executablePath: "C:\\Users\\rhys\\.bun\\bin\\executor.exe", | ||
| 20 | }, | ||
| 21 | }; | ||
| 22 | |||
| 23 | describe("resolveSupervisedDaemonAttach", () => { | ||
| 24 | it("attaches to a reachable daemon without probing pid liveness first", async () => { | ||
| 25 | let pidProbeCount = 0; | ||
| 26 | |||
| 27 | const decision = await resolveSupervisedDaemonAttach(manifest, { | ||
| 28 | isReachable: async () => true, | ||
| 29 | isPidAlive: () => { | ||
| 30 | pidProbeCount += 1; | ||
| 31 | return false; | ||
| 32 | }, | ||
| 33 | }); | ||
| 34 | |||
| 35 | expect(decision._tag).toBe("Attach"); | ||
| 36 | expect(pidProbeCount).toBe(0); | ||
| 37 | }); | ||
| 38 | |||
| 39 | it("removes the manifest only when the endpoint is unreachable and the pid is dead", async () => { | ||
| 40 | const decision = await resolveSupervisedDaemonAttach(manifest, { | ||
| 41 | isReachable: async () => false, | ||
| 42 | isPidAlive: () => false, | ||
| 43 | }); | ||
| 44 | |||
| 45 | expect(decision).toEqual({ _tag: "RemoveStaleManifest", pid: 1234 }); | ||
| 46 | }); | ||
| 47 | }); | ||
| @@ -0,0 +1,35 @@ | |||
| 1 | import type { ExecutorLocalServerManifest } from "@executor-js/sdk/shared"; | ||
| 2 | |||
| 3 | type CliDaemonManifest = ExecutorLocalServerManifest & { readonly kind: "cli-daemon" }; | ||
| 4 | |||
| 5 | type SupervisedDaemonAttachDecision = | ||
| 6 | | { | ||
| 7 | readonly _tag: "Attach"; | ||
| 8 | readonly manifest: CliDaemonManifest; | ||
| 9 | readonly authToken: string; | ||
| 10 | } | ||
| 11 | | { readonly _tag: "RemoveStaleManifest"; readonly pid: number } | ||
| 12 | | { readonly _tag: "Unavailable" }; | ||
| 13 | |||
| 14 | export const resolveSupervisedDaemonAttach = async ( | ||
| 15 | manifest: ExecutorLocalServerManifest | null, | ||
| 16 | input: { | ||
| 17 | readonly isReachable: (origin: string) => Promise<boolean>; | ||
| 18 | readonly isPidAlive: (pid: number) => boolean; | ||
| 19 | }, | ||
| 20 | ): Promise<SupervisedDaemonAttachDecision> => { | ||
| 21 | if (!manifest || manifest.kind !== "cli-daemon") return { _tag: "Unavailable" }; | ||
| 22 | const cliManifest = { ...manifest, kind: "cli-daemon" as const }; | ||
| 23 | |||
| 24 | if (await input.isReachable(cliManifest.connection.origin)) { | ||
| 25 | const auth = cliManifest.connection.auth; | ||
| 26 | const authToken = auth && auth.kind === "bearer" ? auth.token : ""; | ||
| 27 | return { _tag: "Attach", manifest: cliManifest, authToken }; | ||
| 28 | } | ||
| 29 | |||
| 30 | if (!input.isPidAlive(cliManifest.pid)) { | ||
| 31 | return { _tag: "RemoveStaleManifest", pid: cliManifest.pid }; | ||
| 32 | } | ||
| 33 | |||
| 34 | return { _tag: "Unavailable" }; | ||
| 35 | }; | ||
| @@ -3,6 +3,7 @@ · 2 unmodified lines | |||
| 3 | "target": "ESNext", | 3 | "target": "ESNext", |
| 4 | "module": "ESNext", | 4 | "module": "ESNext", |
| 5 | "moduleResolution": "bundler", | 5 | "moduleResolution": "bundler", |
| 6 | "allowImportingTsExtensions": true, | ||
| 6 | "strict": true, | 7 | "strict": true, |
| 7 | "esModuleInterop": true, | 8 | "esModuleInterop": true, |
| 8 | "skipLibCheck": true, | 9 | "skipLibCheck": true, |
| @@ -0,0 +1,7 @@ | |||
| 1 | import { defineConfig } from "vitest/config"; | ||
| 2 | |||
| 3 | export default defineConfig({ | ||
| 4 | test: { | ||
| 5 | include: ["src/**/*.test.ts"], | ||
| 6 | }, | ||
| 7 | }); | ||
| @@ -134,6 +134,7 @@ · 133 unmodified lines | |||
| 134 | "electron-window-state": "^5.0.3", | 134 | "electron-window-state": "^5.0.3", |
| 135 | }, | 135 | }, |
| 136 | "devDependencies": { | 136 | "devDependencies": { |
| 137 | "@effect/vitest": "catalog:", | ||
| 137 | "@executor-js/app": "workspace:*", | 138 | "@executor-js/app": "workspace:*", |
| 138 | "@executor-js/local": "workspace:*", | 139 | "@executor-js/local": "workspace:*", |
| 139 | "@executor-js/plugin-desktop-settings": "workspace:*", | 140 | "@executor-js/plugin-desktop-settings": "workspace:*", |
| @@ -156,6 +157,7 @@ · 16 unmodified lines | |||
| 156 | "quickjs-emscripten": "catalog:", | 157 | "quickjs-emscripten": "catalog:", |
| 157 | "typescript": "catalog:", | 158 | "typescript": "catalog:", |
| 158 | "vite": "catalog:", | 159 | "vite": "catalog:", |
| 160 | "vitest": "catalog:", | ||
| 159 | }, | 161 | }, |
| 160 | }, | 162 | }, |
| 161 | "apps/host-cloudflare": { | 163 | "apps/host-cloudflare": { |
| @@ -3,6 +3,7 @@ · 2 unmodified lines | |||
| 3 | "target": "ESNext", | 3 | "target": "ESNext", |
| 4 | "module": "ESNext", | 4 | "module": "ESNext", |
| 5 | "moduleResolution": "bundler", | 5 | "moduleResolution": "bundler", |
| 6 | "allowImportingTsExtensions": true, | ||
| 6 | "strict": true, | 7 | "strict": true, |
| 7 | "esModuleInterop": true, | 8 | "esModuleInterop": true, |
| 8 | "skipLibCheck": true, | 9 | "skipLibCheck": true, |
| @@ -5,7 +5,7 @@ import tailwindcss from "@tailwindcss/vite"; · 4 unmodified lines | |||
| 5 | import { tanstackRouter } from "@tanstack/router-plugin/vite"; | 5 | import { tanstackRouter } from "@tanstack/router-plugin/vite"; |
| 6 | import executorVitePlugin from "@executor-js/vite-plugin"; | 6 | import executorVitePlugin from "@executor-js/vite-plugin"; |
| 7 | 7 | ||
| 8 | import { routes } from "./tsr.routes"; | 8 | import { routes } from "./tsr.routes.ts"; |
| 9 | 9 | ||
| 10 | const APP_ROOT = fileURLToPath(new URL("./", import.meta.url)); | 10 | const APP_ROOT = fileURLToPath(new URL("./", import.meta.url)); |
| 11 | 11 | ||