History

Fix Windows service install discovery

Rhys Sullivan committed 4 months ago

Commit ae5d1011 parent 4091f8b13 files · +294 −53Browse files

Unified · Split

13 files
  1. .changeset/windows-service-manifest.md +5 −0
  2. apps/cli/src/main.ts +106 −22
  3. apps/cli/src/service.test.ts +11 −0
  4. apps/cli/src/service.ts +41 −0
  5. apps/desktop/package.json +4 −1
  6. apps/desktop/src/main/sidecar.ts +33 −29
  7. apps/desktop/src/main/supervised-daemon.test.ts +47 −0
  8. apps/desktop/src/main/supervised-daemon.ts +35 −0
  9. apps/desktop/tsconfig.json +1 −0
  10. apps/desktop/vitest.config.ts +7 −0
  11. bun.lock +2 −0
  12. packages/app/tsconfig.json +1 −0
  13. packages/app/vite.ts +1 −1
.changeset/windows-service-manifest.md+5−0
@@ -0,0 +1,5 @@
1---
2"executor": patch
3---
4
5Windows 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+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,
apps/desktop/package.json+4−1
@@ -16,6 +16,7 @@ · 15 unmodified lines
16 "package:mac": "electron-builder --mac --config electron-builder.config.ts",16 "package:mac": "electron-builder --mac --config electron-builder.config.ts",
17 "package:win": "electron-builder --win --config electron-builder.config.ts",17 "package:win": "electron-builder --win --config electron-builder.config.ts",
18 "package:linux": "electron-builder --linux --config electron-builder.config.ts",18 "package:linux": "electron-builder --linux --config electron-builder.config.ts",
19 "test": "bun --bun vitest run",
19 "test:smoke": "bun ./scripts/smoke-sidecar.ts",20 "test:smoke": "bun ./scripts/smoke-sidecar.ts",
20 "typecheck": "tsgo --noEmit",21 "typecheck": "tsgo --noEmit",
21 "typecheck:slow": "tsc --noEmit"22 "typecheck:slow": "tsc --noEmit"
@@ -29,6 +30,7 @@ · 7 unmodified lines
29 "electron-window-state": "^5.0.3"30 "electron-window-state": "^5.0.3"
30 },31 },
31 "devDependencies": {32 "devDependencies": {
33 "@effect/vitest": "catalog:",
32 "@executor-js/app": "workspace:*",34 "@executor-js/app": "workspace:*",
33 "@executor-js/local": "workspace:*",35 "@executor-js/local": "workspace:*",
34 "@executor-js/plugin-desktop-settings": "workspace:*",36 "@executor-js/plugin-desktop-settings": "workspace:*",
@@ -50,6 +52,7 @@ · 15 unmodified lines
50 "electron-vite": "^5",52 "electron-vite": "^5",
51 "quickjs-emscripten": "catalog:",53 "quickjs-emscripten": "catalog:",
52 "typescript": "catalog:",54 "typescript": "catalog:",
53 "vite": "catalog:"55 "vite": "catalog:",
56 "vitest": "catalog:"
54 }57 }
55}58}
apps/desktop/src/main/sidecar.ts+33−29
@@ -24,6 +24,7 @@ import { · 23 unmodified lines
24import { loadOrMintLocalAuthToken } from "./local-auth";24import { loadOrMintLocalAuthToken } from "./local-auth";
25import { getServerSettings } from "./settings";25import { getServerSettings } from "./settings";
26import { reportSidecarCrash, sidecarCrashReportingEnv } from "./diagnostics";26import { reportSidecarCrash, sidecarCrashReportingEnv } from "./diagnostics";
27import { resolveSupervisedDaemonAttach } from "./supervised-daemon";
27import { SERVER_SETTINGS_USERNAME, type DesktopServerSettings } from "../shared/server-settings";28import { SERVER_SETTINGS_USERNAME, type DesktopServerSettings } from "../shared/server-settings";
28 29
29// Sidecar output is echoed to the terminal (visible when Electron is run30// 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 * Attach to an already-running OS-supervised daemon instead of spawning our own468 * 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 the469 * sidecar. Reads `server.json`, confirms the endpoint answers, and returns a
469 * endpoint answers, and returns a child-less `SidecarConnection` flagged470 * child-less `SidecarConnection` flagged `supervisedDaemon: true`. Returns null
470 * `supervisedDaemon: true`. Returns null when no usable supervised daemon is471 * when no usable supervised daemon is present.
471 * present.
472 *472 *
473 * Only a `cli-daemon` manifest is treated as supervised — a `desktop-sidecar`473 * Only a `cli-daemon` manifest is treated as supervised — a `desktop-sidecar`
474 * manifest belongs to a managed sidecar (ours or another desktop instance) and474 * 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
477export async function attachToSupervisedDaemon(): Promise<SidecarConnection | null> {477export async function attachToSupervisedDaemon(): Promise<SidecarConnection | null> {
478 const dataDir = join(homedir(), ".executor");478 const dataDir = join(homedir(), ".executor");
479 const manifest = readManifest(dataDir);479 const manifest = readManifest(dataDir);
480 if (!manifest || manifest.kind !== "cli-daemon") return null;480 const decision = await resolveSupervisedDaemonAttach(manifest, {
481 if (!isPidAlive(manifest.pid)) {481 isReachable: isDaemonReachable,
482 removeManifestIfOwnedBy(dataDir, manifest.pid);482 isPidAlive,
483 return null;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;503 if (decision._tag === "RemoveStaleManifest") {
487 const auth = manifest.connection.auth;504 removeManifestIfOwnedBy(dataDir, decision.pid);
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 );
493 return null;505 return null;
494 }506 }
495 507
496 const url = new URL(origin);508 if (!manifest || manifest.kind !== "cli-daemon") return null;
497 sidecarLog.info(`attaching to supervised daemon at ${origin} (pid ${manifest.pid})`);509
498 return {510 sidecarLog.warn(
499 baseUrl: origin,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`,
500 hostname: url.hostname,512 );
501 port: Number.parseInt(url.port, 10) || (url.protocol === "https:" ? 443 : 80),513 return null;
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 };
510}514}
511 515
512export async function stopSidecar(child: ChildProcess): Promise<void> {516export async function stopSidecar(child: ChildProcess): Promise<void> {
apps/desktop/src/main/supervised-daemon.test.ts+47−0
@@ -0,0 +1,47 @@
1import { describe, expect, it } from "@effect/vitest";
2import { normalizeExecutorServerConnection } from "@executor-js/sdk/shared";
3import { resolveSupervisedDaemonAttach } from "./supervised-daemon";
4
5const 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
23describe("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});
apps/desktop/src/main/supervised-daemon.ts+35−0
@@ -0,0 +1,35 @@
1import type { ExecutorLocalServerManifest } from "@executor-js/sdk/shared";
2
3type CliDaemonManifest = ExecutorLocalServerManifest & { readonly kind: "cli-daemon" };
4
5type 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
14export 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};
apps/desktop/tsconfig.json+1−0
@@ -3,6 +3,7 @@ · 2 unmodified lines
3 "target": "ESNext",3 "target": "ESNext",
4 "module": "ESNext",4 "module": "ESNext",
5 "moduleResolution": "bundler",5 "moduleResolution": "bundler",
6 "allowImportingTsExtensions": true,
6 "strict": true,7 "strict": true,
7 "esModuleInterop": true,8 "esModuleInterop": true,
8 "skipLibCheck": true,9 "skipLibCheck": true,
apps/desktop/vitest.config.ts+7−0
@@ -0,0 +1,7 @@
1import { defineConfig } from "vitest/config";
2
3export default defineConfig({
4 test: {
5 include: ["src/**/*.test.ts"],
6 },
7});
bun.lock+2−0
@@ -134,6 +134,7 @@ · 133 unmodified lines
134 "electron-window-state": "^5.0.3",134 "electron-window-state": "^5.0.3",
135 },135 },
136 "devDependencies": {136 "devDependencies": {
137 "@effect/vitest": "catalog:",
137 "@executor-js/app": "workspace:*",138 "@executor-js/app": "workspace:*",
138 "@executor-js/local": "workspace:*",139 "@executor-js/local": "workspace:*",
139 "@executor-js/plugin-desktop-settings": "workspace:*",140 "@executor-js/plugin-desktop-settings": "workspace:*",
@@ -156,6 +157,7 @@ · 16 unmodified lines
156 "quickjs-emscripten": "catalog:",157 "quickjs-emscripten": "catalog:",
157 "typescript": "catalog:",158 "typescript": "catalog:",
158 "vite": "catalog:",159 "vite": "catalog:",
160 "vitest": "catalog:",
159 },161 },
160 },162 },
161 "apps/host-cloudflare": {163 "apps/host-cloudflare": {
packages/app/tsconfig.json+1−0
@@ -3,6 +3,7 @@ · 2 unmodified lines
3 "target": "ESNext",3 "target": "ESNext",
4 "module": "ESNext",4 "module": "ESNext",
5 "moduleResolution": "bundler",5 "moduleResolution": "bundler",
6 "allowImportingTsExtensions": true,
6 "strict": true,7 "strict": true,
7 "esModuleInterop": true,8 "esModuleInterop": true,
8 "skipLibCheck": true,9 "skipLibCheck": true,
packages/app/vite.ts+1−1
@@ -5,7 +5,7 @@ import tailwindcss from "@tailwindcss/vite"; · 4 unmodified lines
5import { tanstackRouter } from "@tanstack/router-plugin/vite";5import { tanstackRouter } from "@tanstack/router-plugin/vite";
6import executorVitePlugin from "@executor-js/vite-plugin";6import executorVitePlugin from "@executor-js/vite-plugin";
7 7
8import { routes } from "./tsr.routes";8import { routes } from "./tsr.routes.ts";
9 9
10const APP_ROOT = fileURLToPath(new URL("./", import.meta.url));10const APP_ROOT = fileURLToPath(new URL("./", import.meta.url));
11 11