Skip to content

Commit 2de1804

Browse files
authored
[codex] Fix Windows service install discovery (#1046)
1 parent 4091f8b commit 2de1804

13 files changed

Lines changed: 294 additions & 53 deletions

File tree

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -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.

apps/cli/src/main.ts

Lines changed: 106 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -124,7 +124,12 @@ import {
124124
resolveExecutorDataDir,
125125
writeLocalServerManifest,
126126
} 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";
128133
import {
129134
defaultCliServerConnectionProfile,
130135
findCliServerConnectionProfile,
@@ -165,6 +170,7 @@ const DEFAULT_BASE_URL = `http://localhost:${DEFAULT_PORT}`;
165170
const DAEMON_BOOT_TIMEOUT_MS = 15_000;
166171
const DAEMON_BOOT_POLL_MS = 150;
167172
const DAEMON_STOP_TIMEOUT_MS = 10_000;
173+
const SERVICE_BOOT_TIMEOUT_MS = 45_000;
168174

169175
// ---------------------------------------------------------------------------
170176
// Helpers
@@ -197,15 +203,15 @@ const readActiveLocalServerManifest = (): Effect.Effect<
197203
const manifest = yield* readLocalServerManifest();
198204
if (!manifest) return null;
199205

206+
if (yield* isServerReachable(manifest.connection.origin)) {
207+
return manifest;
208+
}
209+
200210
if (!isPidAlive(manifest.pid)) {
201211
yield* removeLocalServerManifestIfOwnedBy({ pid: manifest.pid }).pipe(Effect.ignore);
202212
return null;
203213
}
204214

205-
if (yield* isServerReachable(manifest.connection.origin)) {
206-
return manifest;
207-
}
208-
209215
return yield* Effect.fail(
210216
new Error(
211217
[
@@ -2201,6 +2207,68 @@ const mcpCommand = Command.make(
22012207

22022208
const supervisedServiceOrigin = (port: number): string => `http://127.0.0.1:${port}`;
22032209

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+
22042272
const portFromOrigin = (origin: string): number | null => {
22052273
try {
22062274
const url = new URL(origin);
@@ -2274,7 +2342,10 @@ const installService = (port: number, commandName: string) =>
22742342
return;
22752343
}
22762344

2277-
if (plan === "takeover-then-install") {
2345+
if (
2346+
plan === "takeover-then-install" ||
2347+
(backend.platform === "win32" && plan === "reinstall")
2348+
) {
22782349
const replaced = yield* takeOverActiveLocalServer();
22792350
if (replaced) {
22802351
console.log(
@@ -2294,22 +2365,29 @@ const installService = (port: number, commandName: string) =>
22942365
console.log("");
22952366
console.log("Writing the service definition and starting Executor...");
22962367

2368+
if (backend.platform === "win32") {
2369+
yield* clearUnmanifestedWindowsExecutorListener({ port, origin });
2370+
}
2371+
22972372
// The unit carries no secret: the supervised daemon mints/loads its bearer
22982373
// from auth.json (under EXECUTOR_DATA_DIR) on first boot, and clients read
22992374
// the same file — so reachability is the credential-free /api/health probe.
23002375
yield* backend.install({ executablePath: process.execPath, port, version: CLI_VERSION });
23012376

2302-
console.log(`Waiting for Executor to become reachable at ${origin}...`);
2377+
console.log(`Waiting for Executor to publish its service manifest at ${origin}...`);
23032378
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,
23062384
intervalMs: DAEMON_BOOT_POLL_MS,
23072385
});
23082386
if (!reachable) {
23092387
return yield* Effect.fail(
23102388
new Error(
23112389
[
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.`,
23132391
`Check ~/.executor/logs/daemon.error.log and \`${cliPrefix} service status\`.`,
23142392
].join("\n"),
23152393
),
@@ -2334,19 +2412,25 @@ const serviceInstallCommand = Command.make(
23342412
const serviceUninstallCommand = Command.make("uninstall", {}, () =>
23352413
Effect.gen(function* () {
23362414
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;
23432424
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(", ")}.`);
23502434
}
23512435
}
23522436
console.log("Executor background service uninstalled.");

apps/cli/src/service.test.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import {
66
generateSystemdUnit,
77
generateWindowsDaemonWrapper,
88
generateWindowsRegisterScript,
9+
generateWindowsStopExecutorListenersScript,
910
getServiceBackend,
1011
} from "./service";
1112

@@ -136,6 +137,16 @@ describe("service unit generation", () => {
136137
expect(script).toContain("Register-ScheduledTask -TaskName 'ExecutorDaemon'");
137138
expect(script).toContain("Start-ScheduledTask -TaskName 'ExecutorDaemon'");
138139
});
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+
});
139150
});
140151

141152
describe("service backend dispatch", () => {

apps/cli/src/service.ts

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -579,6 +579,28 @@ export const generateWindowsRegisterScript = (options: {
579579
`Start-ScheduledTask -TaskName ${psSingleQuote(options.taskName)}`,
580580
].join("\n");
581581

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+
582604
/** Run a PowerShell script via -EncodedCommand (sidesteps all shell quoting). */
583605
const runPowerShell = (script: string): Effect.Effect<CommandResult, Error> =>
584606
runCommand("powershell.exe", [
@@ -590,6 +612,25 @@ const runPowerShell = (script: string): Effect.Effect<CommandResult, Error> =>
590612
Buffer.from(script, "utf16le").toString("base64"),
591613
]);
592614

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+
593634
const makeWindowsBackend = (): ServiceBackend => ({
594635
platform: "win32",
595636
automated: true,

apps/desktop/package.json

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616
"package:mac": "electron-builder --mac --config electron-builder.config.ts",
1717
"package:win": "electron-builder --win --config electron-builder.config.ts",
1818
"package:linux": "electron-builder --linux --config electron-builder.config.ts",
19+
"test": "bun --bun vitest run",
1920
"test:smoke": "bun ./scripts/smoke-sidecar.ts",
2021
"typecheck": "tsgo --noEmit",
2122
"typecheck:slow": "tsc --noEmit"
@@ -29,6 +30,7 @@
2930
"electron-window-state": "^5.0.3"
3031
},
3132
"devDependencies": {
33+
"@effect/vitest": "catalog:",
3234
"@executor-js/app": "workspace:*",
3335
"@executor-js/local": "workspace:*",
3436
"@executor-js/plugin-desktop-settings": "workspace:*",
@@ -50,6 +52,7 @@
5052
"electron-vite": "^5",
5153
"quickjs-emscripten": "catalog:",
5254
"typescript": "catalog:",
53-
"vite": "catalog:"
55+
"vite": "catalog:",
56+
"vitest": "catalog:"
5457
}
5558
}

apps/desktop/src/main/sidecar.ts

Lines changed: 33 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ import {
2424
import { loadOrMintLocalAuthToken } from "./local-auth";
2525
import { getServerSettings } from "./settings";
2626
import { reportSidecarCrash, sidecarCrashReportingEnv } from "./diagnostics";
27+
import { resolveSupervisedDaemonAttach } from "./supervised-daemon";
2728
import { SERVER_SETTINGS_USERNAME, type DesktopServerSettings } from "../shared/server-settings";
2829

2930
// Sidecar output is echoed to the terminal (visible when Electron is run
@@ -465,10 +466,9 @@ const isDaemonReachable = async (origin: string): Promise<boolean> => {
465466

466467
/**
467468
* 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.
472472
*
473473
* Only a `cli-daemon` manifest is treated as supervised — a `desktop-sidecar`
474474
* manifest belongs to a managed sidecar (ours or another desktop instance) and
@@ -477,36 +477,40 @@ const isDaemonReachable = async (origin: string): Promise<boolean> => {
477477
export async function attachToSupervisedDaemon(): Promise<SidecarConnection | null> {
478478
const dataDir = join(homedir(), ".executor");
479479
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+
};
484501
}
485502

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);
493505
return null;
494506
}
495507

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;
510514
}
511515

512516
export async function stopSidecar(child: ChildProcess): Promise<void> {

0 commit comments

Comments
 (0)