History

[codex] Fix Windows service install discovery (#1046)

Rhys Sullivan committed 4 months ago

Commit 2de18041 parent 4091f8b13 files · +294 −53Browse files

Showing the changes to apps/cli/src (3 of 13 files). Show all

Unified · Split

3 files
  1. apps/cli/src/main.ts +106 −22
  2. apps/cli/src/service.test.ts +11 −0
  3. apps/cli/src/service.ts +41 −0
apps/cli/src/main.ts+106−22
@@ -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";
127import { DEFAULT_SERVICE_PORT, getServiceBackend, SERVICE_LABEL } from "./service";127import {
128 DEFAULT_SERVICE_PORT,
129 getServiceBackend,
130 SERVICE_LABEL,
131 stopWindowsExecutorListenersOnPort,
132} from "./service";
128import {133import {
129 defaultCliServerConnectionProfile,134 defaultCliServerConnectionProfile,
130 findCliServerConnectionProfile,135 findCliServerConnectionProfile,
@@ -165,6 +170,7 @@ const DEFAULT_BASE_URL = `http://localhost:${DEFAULT_PORT}`; · 34 unmodified lines
165const DAEMON_BOOT_TIMEOUT_MS = 15_000;170const DAEMON_BOOT_TIMEOUT_MS = 15_000;
166const DAEMON_BOOT_POLL_MS = 150;171const DAEMON_BOOT_POLL_MS = 150;
167const DAEMON_STOP_TIMEOUT_MS = 10_000;172const DAEMON_STOP_TIMEOUT_MS = 10_000;
173const SERVICE_BOOT_TIMEOUT_MS = 45_000;
168 174
169// ---------------------------------------------------------------------------175// ---------------------------------------------------------------------------
170// Helpers176// 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
2202const supervisedServiceOrigin = (port: number): string => `http://127.0.0.1:${port}`;2208const supervisedServiceOrigin = (port: number): string => `http://127.0.0.1:${port}`;
2203 2209
2210const LOCAL_SERVICE_HOSTS = new Set(["localhost", "127.0.0.1", "::1", "[::1]"]);
2211
2212const originPort = (url: URL): string => url.port || (url.protocol === "https:" ? "443" : "80");
2213
2214const 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
2226const 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
2240const 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
2204const portFromOrigin = (origin: string): number | null => {2272const 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 bearer2372 // 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 read2373 // 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
2334const serviceUninstallCommand = Command.make("uninstall", {}, () =>2412const serviceUninstallCommand = Command.make("uninstall", {}, () =>
2335 Effect.gen(function* () {2413 Effect.gen(function* () {
2336 const backend = getServiceBackend();2414 const backend = getServiceBackend();
2337 const wasRunning = backend.automated2415 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.");
apps/cli/src/service.test.ts+11−0
@@ -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
141describe("service backend dispatch", () => {152describe("service backend dispatch", () => {
apps/cli/src/service.ts+41−0
@@ -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 */
589export 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). */
583const runPowerShell = (script: string): Effect.Effect<CommandResult, Error> =>605const 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
615export 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
593const makeWindowsBackend = (): ServiceBackend => ({634const makeWindowsBackend = (): ServiceBackend => ({
594 platform: "win32",635 platform: "win32",
595 automated: true,636 automated: true,