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 | 124 | resolveExecutorDataDir, |
| 125 | 125 | writeLocalServerManifest, |
| 126 | 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 | 133 | import { |
| 129 | 134 | defaultCliServerConnectionProfile, |
| 130 | 135 | findCliServerConnectionProfile, |
| @@ -165,6 +170,7 @@ const DEFAULT_BASE_URL = `http://localhost:${DEFAULT_PORT}`; · 34 unmodified lines | ||
| 165 | 170 | const DAEMON_BOOT_TIMEOUT_MS = 15_000; |
| 166 | 171 | const DAEMON_BOOT_POLL_MS = 150; |
| 167 | 172 | const DAEMON_STOP_TIMEOUT_MS = 10_000; |
| 173 | +const SERVICE_BOOT_TIMEOUT_MS = 45_000; | |
| 168 | 174 | |
| 169 | 175 | // --------------------------------------------------------------------------- |
| 170 | 176 | // Helpers |
| @@ -197,15 +203,15 @@ const readActiveLocalServerManifest = (): Effect.Effect< · 26 unmodified lines | ||
| 197 | 203 | const manifest = yield* readLocalServerManifest(); |
| 198 | 204 | if (!manifest) return null; |
| 199 | 205 | |
| 206 | + if (yield* isServerReachable(manifest.connection.origin)) { | |
| 207 | + return manifest; | |
| 208 | + } | |
| 209 | + | |
| 200 | 210 | if (!isPidAlive(manifest.pid)) { |
| 201 | 211 | yield* removeLocalServerManifestIfOwnedBy({ pid: manifest.pid }).pipe(Effect.ignore); |
| 202 | 212 | return null; |
| 203 | 213 | } |
| 204 | 214 | |
| 205 | − if (yield* isServerReachable(manifest.connection.origin)) { | |
| 206 | − return manifest; | |
| 207 | − } | |
| 208 | − | |
| 209 | 215 | return yield* Effect.fail( |
| 210 | 216 | new Error( |
| 211 | 217 | [ |
| @@ -2201,6 +2207,68 @@ const mcpCommand = Command.make( · 1989 unmodified lines | ||
| 2201 | 2207 | |
| 2202 | 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 | 2272 | const portFromOrigin = (origin: string): number | null => { |
| 2205 | 2273 | try { |
| 2206 | 2274 | const url = new URL(origin); |
| @@ -2274,7 +2342,10 @@ const installService = (port: number, commandName: string) => · 67 unmodified lines | ||
| 2274 | 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 | 2349 | const replaced = yield* takeOverActiveLocalServer(); |
| 2279 | 2350 | if (replaced) { |
| 2280 | 2351 | console.log( |
| @@ -2294,22 +2365,29 @@ const installService = (port: number, commandName: string) => · 13 unmodified lines | ||
| 2294 | 2365 | console.log(""); |
| 2295 | 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 | 2372 | // The unit carries no secret: the supervised daemon mints/loads its bearer |
| 2298 | 2373 | // from auth.json (under EXECUTOR_DATA_DIR) on first boot, and clients read |
| 2299 | 2374 | // the same file — so reachability is the credential-free /api/health probe. |
| 2300 | 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 | 2378 | const reachable = yield* waitForReachable({ |
| 2304 | − check: isServerReachable(origin), | |
| 2305 | − timeoutMs: DAEMON_BOOT_TIMEOUT_MS, | |
| 2379 | + check: hasReachableCliDaemonManifest({ | |
| 2380 | + origin, | |
| 2381 | + version: CLI_VERSION, | |
| 2382 | + }), | |
| 2383 | + timeoutMs: SERVICE_BOOT_TIMEOUT_MS, | |
| 2306 | 2384 | intervalMs: DAEMON_BOOT_POLL_MS, |
| 2307 | 2385 | }); |
| 2308 | 2386 | if (!reachable) { |
| 2309 | 2387 | return yield* Effect.fail( |
| 2310 | 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 | 2391 | `Check ~/.executor/logs/daemon.error.log and \`${cliPrefix} service status\`.`, |
| 2314 | 2392 | ].join("\n"), |
| 2315 | 2393 | ), |
| @@ -2334,19 +2412,25 @@ const serviceInstallCommand = Command.make( · 18 unmodified lines | ||
| 2334 | 2412 | const serviceUninstallCommand = Command.make("uninstall", {}, () => |
| 2335 | 2413 | Effect.gen(function* () { |
| 2336 | 2414 | const backend = getServiceBackend(); |
| 2337 | − const wasRunning = backend.automated | |
| 2338 | − ? yield* backend.status().pipe( | |
| 2339 | − Effect.map((status) => status.running), | |
| 2340 | − Effect.catchCause(() => Effect.succeed(false)), | |
| 2341 | − ) | |
| 2342 | − : false; | |
| 2415 | + const activeBefore = yield* readActiveLocalServerManifest().pipe( | |
| 2416 | + Effect.catchCause(() => Effect.succeed(null)), | |
| 2417 | + ); | |
| 2418 | + const servicePort = activeBefore | |
| 2419 | + ? (portFromOrigin(activeBefore.connection.origin) ?? DEFAULT_SERVICE_PORT) | |
| 2420 | + : DEFAULT_SERVICE_PORT; | |
| 2421 | + const status = backend.automated | |
| 2422 | + ? yield* backend.status().pipe(Effect.catchCause(() => Effect.succeed(null))) | |
| 2423 | + : null; | |
| 2343 | 2424 | yield* backend.uninstall(); |
| 2344 | − if (wasRunning) { | |
| 2345 | − const stopped = yield* takeOverActiveLocalServer({ onlyKind: "cli-daemon" }); | |
| 2346 | − if (stopped) { | |
| 2347 | − console.log( | |
| 2348 | − `Stopped running Executor daemon at ${stopped.connection.origin} (pid ${stopped.pid}).`, | |
| 2349 | − ); | |
| 2425 | + const stopped = yield* takeOverActiveLocalServer({ onlyKind: "cli-daemon" }); | |
| 2426 | + if (stopped) { | |
| 2427 | + console.log( | |
| 2428 | + `Stopped running Executor daemon at ${stopped.connection.origin} (pid ${stopped.pid}).`, | |
| 2429 | + ); | |
| 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 | 2436 | console.log("Executor background service uninstalled."); |
| @@ -6,6 +6,7 @@ import { · 5 unmodified lines | ||
| 6 | 6 | generateSystemdUnit, |
| 7 | 7 | generateWindowsDaemonWrapper, |
| 8 | 8 | generateWindowsRegisterScript, |
| 9 | + generateWindowsStopExecutorListenersScript, | |
| 9 | 10 | getServiceBackend, |
| 10 | 11 | } from "./service"; |
| 11 | 12 | |
| @@ -136,6 +137,16 @@ describe("service unit generation", () => { · 124 unmodified lines | ||
| 136 | 137 | expect(script).toContain("Register-ScheduledTask -TaskName 'ExecutorDaemon'"); |
| 137 | 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 | 152 | describe("service backend dispatch", () => { |
| @@ -579,6 +579,28 @@ export const generateWindowsRegisterScript = (options: { · 578 unmodified lines | ||
| 579 | 579 | `Start-ScheduledTask -TaskName ${psSingleQuote(options.taskName)}`, |
| 580 | 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 | 604 | /** Run a PowerShell script via -EncodedCommand (sidesteps all shell quoting). */ |
| 583 | 605 | const runPowerShell = (script: string): Effect.Effect<CommandResult, Error> => |
| 584 | 606 | runCommand("powershell.exe", [ |
| @@ -590,6 +612,25 @@ const runPowerShell = (script: string): Effect.Effect<CommandResult, Error> => · 5 unmodified lines | ||
| 590 | 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 | 634 | const makeWindowsBackend = (): ServiceBackend => ({ |
| 594 | 635 | platform: "win32", |
| 595 | 636 | automated: true, |
| @@ -16,6 +16,7 @@ · 15 unmodified lines | ||
| 16 | 16 | "package:mac": "electron-builder --mac --config electron-builder.config.ts", |
| 17 | 17 | "package:win": "electron-builder --win --config electron-builder.config.ts", |
| 18 | 18 | "package:linux": "electron-builder --linux --config electron-builder.config.ts", |
| 19 | + "test": "bun --bun vitest run", | |
| 19 | 20 | "test:smoke": "bun ./scripts/smoke-sidecar.ts", |
| 20 | 21 | "typecheck": "tsgo --noEmit", |
| 21 | 22 | "typecheck:slow": "tsc --noEmit" |
| @@ -29,6 +30,7 @@ · 7 unmodified lines | ||
| 29 | 30 | "electron-window-state": "^5.0.3" |
| 30 | 31 | }, |
| 31 | 32 | "devDependencies": { |
| 33 | + "@effect/vitest": "catalog:", | |
| 32 | 34 | "@executor-js/app": "workspace:*", |
| 33 | 35 | "@executor-js/local": "workspace:*", |
| 34 | 36 | "@executor-js/plugin-desktop-settings": "workspace:*", |
| @@ -50,6 +52,7 @@ · 15 unmodified lines | ||
| 50 | 52 | "electron-vite": "^5", |
| 51 | 53 | "quickjs-emscripten": "catalog:", |
| 52 | 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 | 24 | import { loadOrMintLocalAuthToken } from "./local-auth"; |
| 25 | 25 | import { getServerSettings } from "./settings"; |
| 26 | 26 | import { reportSidecarCrash, sidecarCrashReportingEnv } from "./diagnostics"; |
| 27 | +import { resolveSupervisedDaemonAttach } from "./supervised-daemon"; | |
| 27 | 28 | import { SERVER_SETTINGS_USERNAME, type DesktopServerSettings } from "../shared/server-settings"; |
| 28 | 29 | |
| 29 | 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 | 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 | − * endpoint answers, and returns a child-less `SidecarConnection` flagged | |
| 470 | − * `supervisedDaemon: true`. Returns null when no usable supervised daemon is | |
| 471 | − * present. | |
| 469 | + * sidecar. Reads `server.json`, confirms the endpoint answers, and returns a | |
| 470 | + * child-less `SidecarConnection` flagged `supervisedDaemon: true`. Returns null | |
| 471 | + * when no usable supervised daemon is present. | |
| 472 | 472 | * |
| 473 | 473 | * Only a `cli-daemon` manifest is treated as supervised — a `desktop-sidecar` |
| 474 | 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 | 477 | export async function attachToSupervisedDaemon(): Promise<SidecarConnection | null> { |
| 478 | 478 | const dataDir = join(homedir(), ".executor"); |
| 479 | 479 | const manifest = readManifest(dataDir); |
| 480 | − if (!manifest || manifest.kind !== "cli-daemon") return null; | |
| 481 | − if (!isPidAlive(manifest.pid)) { | |
| 482 | − removeManifestIfOwnedBy(dataDir, manifest.pid); | |
| 483 | − return null; | |
| 480 | + const decision = await resolveSupervisedDaemonAttach(manifest, { | |
| 481 | + isReachable: isDaemonReachable, | |
| 482 | + isPidAlive, | |
| 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; | |
| 487 | − const auth = manifest.connection.auth; | |
| 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 | − ); | |
| 503 | + if (decision._tag === "RemoveStaleManifest") { | |
| 504 | + removeManifestIfOwnedBy(dataDir, decision.pid); | |
| 493 | 505 | return null; |
| 494 | 506 | } |
| 495 | 507 | |
| 496 | − const url = new URL(origin); | |
| 497 | − sidecarLog.info(`attaching to supervised daemon at ${origin} (pid ${manifest.pid})`); | |
| 498 | − return { | |
| 499 | − baseUrl: origin, | |
| 500 | − hostname: url.hostname, | |
| 501 | − port: Number.parseInt(url.port, 10) || (url.protocol === "https:" ? 443 : 80), | |
| 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 | − }; | |
| 508 | + if (!manifest || manifest.kind !== "cli-daemon") return null; | |
| 509 | + | |
| 510 | + sidecarLog.warn( | |
| 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`, | |
| 512 | + ); | |
| 513 | + return null; | |
| 510 | 514 | } |
| 511 | 515 | |
| 512 | 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 | 3 | "target": "ESNext", |
| 4 | 4 | "module": "ESNext", |
| 5 | 5 | "moduleResolution": "bundler", |
| 6 | + "allowImportingTsExtensions": true, | |
| 6 | 7 | "strict": true, |
| 7 | 8 | "esModuleInterop": true, |
| 8 | 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 | 134 | "electron-window-state": "^5.0.3", |
| 135 | 135 | }, |
| 136 | 136 | "devDependencies": { |
| 137 | + "@effect/vitest": "catalog:", | |
| 137 | 138 | "@executor-js/app": "workspace:*", |
| 138 | 139 | "@executor-js/local": "workspace:*", |
| 139 | 140 | "@executor-js/plugin-desktop-settings": "workspace:*", |
| @@ -156,6 +157,7 @@ · 16 unmodified lines | ||
| 156 | 157 | "quickjs-emscripten": "catalog:", |
| 157 | 158 | "typescript": "catalog:", |
| 158 | 159 | "vite": "catalog:", |
| 160 | + "vitest": "catalog:", | |
| 159 | 161 | }, |
| 160 | 162 | }, |
| 161 | 163 | "apps/host-cloudflare": { |
| @@ -3,6 +3,7 @@ · 2 unmodified lines | ||
| 3 | 3 | "target": "ESNext", |
| 4 | 4 | "module": "ESNext", |
| 5 | 5 | "moduleResolution": "bundler", |
| 6 | + "allowImportingTsExtensions": true, | |
| 6 | 7 | "strict": true, |
| 7 | 8 | "esModuleInterop": true, |
| 8 | 9 | "skipLibCheck": true, |
| @@ -5,7 +5,7 @@ import tailwindcss from "@tailwindcss/vite"; · 4 unmodified lines | ||
| 5 | 5 | import { tanstackRouter } from "@tanstack/router-plugin/vite"; |
| 6 | 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 | 10 | const APP_ROOT = fileURLToPath(new URL("./", import.meta.url)); |
| 11 | 11 | |