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