| @@ -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."); |