From 4f9643bc8a643d6ef866f440ddb774fbef2a3290 Mon Sep 17 00:00:00 2001 From: Kiet Ho Date: Tue, 19 May 2026 16:27:47 -0700 Subject: [PATCH 1/2] refactor(desktop): couple host-service to Electron, drop adoption MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Host-service no longer survives Electron — children spawn attached (detached: false) and get SIGTERMed via stopAll() on before-quit. A parent-pid watchdog inside the child handles crash/force-kill paths where SIGTERM never arrives, so we don't leak orphans. PTY survival is now owned entirely by pty-daemon (which host-service supervises with its own detached lifecycle), so coupling host-service itself loses no user-facing property and removes a large class of "wedged adopted service" bugs. Removes tryAdopt/discoverAll/teardownKnownManifests/releaseAll/ adopted-liveness from the coordinator. Manifest writing stays — the CLI in packages/cli still reads it to find a live host-service. Adopts a 3s drain window on SIGTERM so in-flight HTTP/SSE/WS finish or get torn down cleanly. --- apps/desktop/docs/HOST_SERVICE_LIFECYCLE.md | 77 ++--- .../lib/electron-app/factories/app/setup.ts | 4 +- apps/desktop/src/main/host-service/index.ts | 49 +++- apps/desktop/src/main/index.ts | 26 +- .../main/lib/host-service-coordinator.test.ts | 272 +++--------------- .../src/main/lib/host-service-coordinator.ts | 259 ++--------------- 6 files changed, 150 insertions(+), 537 deletions(-) diff --git a/apps/desktop/docs/HOST_SERVICE_LIFECYCLE.md b/apps/desktop/docs/HOST_SERVICE_LIFECYCLE.md index 3daf0d05dc4..143578310a5 100644 --- a/apps/desktop/docs/HOST_SERVICE_LIFECYCLE.md +++ b/apps/desktop/docs/HOST_SERVICE_LIFECYCLE.md @@ -2,22 +2,21 @@ ## Architecture -Electron main owns app lifecycle, tray, and host-service management. Host-services run as child processes that can outlive the app via manifest-based adoption. +Electron main owns app lifecycle, tray, and host-service management. Host-service runs as a child process **coupled to Electron** — it starts and stops with the app. Terminal sessions (PTYs) survive Electron restarts via a separate `pty-daemon` that host-service supervises on its own detached lifecycle. ``` ┌─────────────────────────────────────────────────────┐ │ Electron Main Process │ │ │ │ ┌──────────┐ ┌──────────────────────┐ ┌───────┐ │ -│ │ Tray │ │ HostServiceManager │ │Windows│ │ +│ │ Tray │ │ HostServiceCoordinator│ │Windows│ │ │ │ (macOS) │ │ │ │ │ │ -│ │ │◄─┤ status events │ │ hide/ │ │ -│ │ restart │ │ start/stop/adopt │ │ show │ │ -│ │ stop │ │ per org │ │ │ │ -│ │ quit ────┼──┼──► requestQuit(mode) │ │ │ │ +│ │ restart │◄─┤ status events │ │ hide/ │ │ +│ │ stop │ │ start/stop per org │ │ show │ │ +│ │ quit ────┼──┼──► app.quit() │ │ │ │ │ └──────────┘ └──────┬───────────────┘ └───────┘ │ └───────────────────────┼─────────────────────────────┘ - │ IPC + stdio + │ spawn (attached, detached:false) ┌─────────────┼─────────────┐ │ │ │ ▼ ▼ ▼ @@ -26,50 +25,52 @@ Electron main owns app lifecycle, tray, and host-service management. Host-servic │ (org A) │ │ (org B) │ │ (org C) │ │ │ │ │ │ │ │ HTTP/tRPC │ │ HTTP/tRPC │ │ HTTP/tRPC │ - │ port:rand │ │ port:rand │ │ port:rand │ │ │ │ │ │ │ - │ writes │ │ writes │ │ writes │ - │ manifest │ │ manifest │ │ manifest │ + │ supervises │ │ supervises │ │ supervises │ + │ pty-daemon │ │ pty-daemon │ │ pty-daemon │ + └─────┬──────┘ └─────┬──────┘ └─────┬──────┘ + │ │ │ + ▼ ▼ ▼ + ┌────────────┐ ┌────────────┐ ┌────────────┐ + │ pty-daemon │ │ pty-daemon │ │ pty-daemon │ + │ (detached) │ │ (detached) │ │ (detached) │ + │ → PTYs │ │ → PTYs │ │ → PTYs │ └────────────┘ └────────────┘ └────────────┘ - │ │ │ - ▼ ▼ ▼ - ~/.superset/host/{orgId}/manifest.json ``` -### Quit modes +### Quit behavior -All quit paths use a single `QuitMode` (`"release" | "stop"`): +Electron `before-quit` always SIGTERMs every host-service via `coordinator.stopAll()`. There is no "release" mode — host-services no longer outlive the app. -- **release** — detach from services, they keep running for re-adoption on next launch -- **stop** — SIGTERM all services, then exit -- **implicit** (Cmd+Q with active services on macOS) — hide windows to tray +What survives a quit: +- **pty-daemon + open PTYs** — pty-daemon is spawned by host-service with `detached: true`. On the next launch, host-service adopts the existing pty-daemon via its socket/manifest. See `packages/host-service/src/daemon/DaemonSupervisor.ts`. -### Manifest adoption +What does **not** survive: +- In-flight chat completions, file watchers, durable-session reads. These are bound to host-service's process and tear down with it. The renderer handles reconnect on next launch. -Each host-service child writes `~/.superset/host/{orgId}/manifest.json` on startup (pid, endpoint, authToken, version). It's a pidfile extended with connection info. +### How host-service is reaped -- **Release quit** — children keep running, manifests stay on disk -- **Next launch** — `discoverAndAdoptAll()` scans manifests, health-checks each pid/endpoint, reconnects if healthy, removes and respawns if not -- **Stop quit** — SIGTERM children, they remove their own manifests on shutdown +| Quit path | Mechanism | +|---|---| +| Clean `before-quit` (Cmd+Q, tray quit, auto-update install) | `coordinator.stopAll()` SIGTERMs each child; child closes its HTTP server and exits within `SHUTDOWN_GRACE_MS` (3s) | +| Electron force-killed / crash | Parent-pid watchdog inside host-service (`apps/desktop/src/main/host-service/index.ts`) polls `process.ppid`. When Electron's pid is gone, the child shuts down voluntarily | +| Dev `bun dev` SIGTERM/SIGINT | Coordinator's `stopAll()` runs in the signal handler before `app.exit()` | -``` -App Launch App Quit (release) Next Launch -───────── ────────────────── ─────────── -spawn child ──► child writes parent detaches scan manifests - manifest.json manifests stay on disk health-check pid/endpoint - {pid, endpoint, child keeps running ├─ healthy → reconnect - authToken, ...} └─ dead/bad → remove, respawn -``` +The watchdog only runs when `HOST_PARENT_PID` is set in the child env — CLI-spawned host-services (`packages/cli`) explicitly skip coupling and use `detached: true` for their own deployment model. + +### Manifest -### v1 vs v2 terminal paths +Each host-service still writes `~/.superset/host/{orgId}/manifest.json` (pid, endpoint, authToken, app version). Electron's coordinator no longer reads it for adoption; the manifest is now consumed by: -v1 terminals run on a separate **terminal-host daemon** (`src/main/terminal-host/`) — a persistent background process that owns PTYs over a Unix domain socket. It has its own survival and reconnection model independent of host-service. +- **CLI** (`packages/cli`) — finds and talks to a running host-service for `status`/`stop`/`start` commands. +- **`coordinator.reset()`** — SIGKILLs whatever pid the manifest names as a recovery escape hatch when a wedged host-service has been left behind (superset-sh/superset#4299). -v2 terminals run through **host-service** child processes. The quit/adopt/tray lifecycle described here only applies to host-service instances. +Host-service writes the manifest on boot but does not remove it on exit; coordinator removes it on `stop()` and when the child exits. ### Design decisions -- **No supervisor process.** Electron main owns everything. Simpler while v1 and v2 coexist. -- **No tray on Windows/Linux.** Services still survive quit and are re-adopted, but there's no persistent UI to manage them. -- **Tray calls `requestQuit(mode)`.** One function, one codepath — no setter chains or flag mutation. -- **Manifest handling is single-sourced.** Both parent and child use `host-service-manifest.ts`. Files are written with 0o600 permissions. +- **Coupled to Electron.** PTY survival is owned by pty-daemon, not host-service. No reason for host-service itself to outlive the app — coupling deletes the adoption codepath and removes a class of "wedged adopted service" bugs. +- **CLI keeps its own spawn.** Standalone host-service deployments (CLI-driven) still use detached lifetime via `packages/cli/src/lib/host/spawn.ts`. The coordinator's coupling only applies to Electron-spawned children. +- **No supervisor process.** Electron main owns everything. +- **No tray on Windows/Linux.** Services stop with the app. +- **Manifest handling stays single-sourced.** Both desktop and CLI use the same `host-service-manifest.ts` API. Files are written with 0o600 permissions. diff --git a/apps/desktop/src/lib/electron-app/factories/app/setup.ts b/apps/desktop/src/lib/electron-app/factories/app/setup.ts index 222e8c4de37..e8cc89a83da 100644 --- a/apps/desktop/src/lib/electron-app/factories/app/setup.ts +++ b/apps/desktop/src/lib/electron-app/factories/app/setup.ts @@ -53,8 +53,8 @@ export async function makeAppSetup( }); // macOS: keep the app alive (standard behavior) — tray/dock provide re-entry. - // Windows/Linux: quit the app UI. Host-services survive via releaseAll() - // and will be re-adopted on next launch. + // Windows/Linux: quit the app UI. Host-services are coupled to the app and + // stop with it; v1 pty-daemon survives separately. app.on("window-all-closed", () => !PLATFORM.IS_MAC && app.quit()); return window; diff --git a/apps/desktop/src/main/host-service/index.ts b/apps/desktop/src/main/host-service/index.ts index 9923b30717f..d7125e8ac76 100644 --- a/apps/desktop/src/main/host-service/index.ts +++ b/apps/desktop/src/main/host-service/index.ts @@ -96,14 +96,53 @@ async function main(): Promise { ); injectWebSocket(server); - // Manifest lifecycle belongs to the coordinator, not the child. - const shutdown = () => { + let shuttingDown = false; + const shutdown = (reason: string) => { + if (shuttingDown) return; + shuttingDown = true; + console.log(`[host-service] shutdown (${reason}), draining connections`); server.close(); - process.exit(0); + // SSE/WS streams (chat, watchers) ignore server.close() — give in-flight + // HTTP a brief window, then forcibly tear sockets down. + const forceExit = setTimeout(() => { + const httpServer = server as unknown as { + closeAllConnections?: () => void; + }; + httpServer.closeAllConnections?.(); + process.exit(0); + }, SHUTDOWN_GRACE_MS); + forceExit.unref(); }; - process.on("SIGTERM", shutdown); - process.on("SIGINT", shutdown); + process.on("SIGTERM", () => shutdown("SIGTERM")); + process.on("SIGINT", () => shutdown("SIGINT")); + + // Self-exit if our Electron parent dies without sending SIGTERM + // (orphan reparenting to init/launchd). CLI-spawned host-services + // don't set HOST_PARENT_PID and skip this. + const parentPid = Number(process.env.HOST_PARENT_PID); + if (Number.isInteger(parentPid) && parentPid > 1) { + const interval = setInterval(() => { + const stillParented = isParentAlive(parentPid); + if (!stillParented) { + clearInterval(interval); + shutdown("parent-exit"); + } + }, WATCHDOG_INTERVAL_MS); + interval.unref(); + } +} + +const SHUTDOWN_GRACE_MS = 3_000; +const WATCHDOG_INTERVAL_MS = 2_000; + +function isParentAlive(parentPid: number): boolean { + try { + process.kill(parentPid, 0); + return process.ppid === parentPid; + } catch { + return false; + } } void main().catch((error) => { diff --git a/apps/desktop/src/main/index.ts b/apps/desktop/src/main/index.ts index 181fde3e22e..157548c5b7b 100644 --- a/apps/desktop/src/main/index.ts +++ b/apps/desktop/src/main/index.ts @@ -176,14 +176,14 @@ export function quitApp(): void { app.quit(); } -/** Nuclear quit: also kills host-service(s) and pty-daemon/terminal-host. */ +/** Quit + also tear down the v1 terminal-host client. Tray "Quit Completely". */ export function quitAppCompletely(): void { forceFullCleanup = true; setSkipQuitConfirmation(); app.quit(); } -/** Bypasses before-quit — services are left running for re-adoption on next launch. */ +/** Bypasses before-quit. Host-service children self-exit via the parent watchdog. */ export function exitImmediately(): void { app.exit(0); } @@ -224,11 +224,9 @@ app.on("before-quit", async (event) => { isQuitting = true; try { + getHostServiceCoordinator().stopAll(); if (isDev || forceFullCleanup || isUpdateReadyToInstall()) { - await runFullQuitCleanup(); - } else { - // Prod: leave services running so the next launch re-adopts via manifest. - getHostServiceCoordinator().releaseAll(); + await teardownTerminalHost(); } shutdownTanstackDbPersistence(); disposeTray(); @@ -241,13 +239,10 @@ app.on("before-quit", async (event) => { }); /** - * Full cleanup — kill host-service + terminal-host children. Used in dev, on - * update installs, and on the tray's "Quit Superset Completely" path in prod. + * Tear down the v1 terminal-host client. Skipped on regular quit so v1 + * PTY sessions reattach via socket on next launch. */ -async function runFullQuitCleanup(): Promise { - const coordinator = getHostServiceCoordinator(); - await coordinator.teardownKnownManifests(); - coordinator.stopAll(); +async function teardownTerminalHost(): Promise { try { await getTerminalHostClient().shutdownIfRunning({ killSessions: true }); } catch (err) { @@ -273,8 +268,9 @@ if (process.env.NODE_ENV === "development") { if (signalHandled) return; signalHandled = true; console.log(`[main] Received ${signal}, quitting...`); + getHostServiceCoordinator().stopAll(); void Promise.allSettled([ - runFullQuitCleanup(), + teardownTerminalHost(), stopNetworkLogger(), ]).finally(() => app.exit(0)); }; @@ -418,10 +414,6 @@ if (!gotTheLock) { console.error("[main] Failed to install bundled CLI shim:", error); } - // Discover and adopt host-services that survived a previous quit - // before the tray initializes, so it shows accurate status immediately. - await getHostServiceCoordinator().discoverAll(); - if (IS_DEV) { getHostServiceCoordinator().enableDevReload(async () => { const { token } = await loadToken(); diff --git a/apps/desktop/src/main/lib/host-service-coordinator.test.ts b/apps/desktop/src/main/lib/host-service-coordinator.test.ts index 24d2e9bf8c0..7d5f20c41ef 100644 --- a/apps/desktop/src/main/lib/host-service-coordinator.test.ts +++ b/apps/desktop/src/main/lib/host-service-coordinator.test.ts @@ -26,9 +26,6 @@ const manifestStore: { } | null; } = { current: null }; -// Per-test temp dir backing the mocked `manifestDir`. A real path (not a -// fixed string) so tests stay isolated; assigned in beforeEach, removed in -// afterEach. let testManifestRoot = ""; const readManifestMock = mock(() => manifestStore.current); @@ -36,9 +33,6 @@ const removeManifestMock = mock(() => { manifestStore.current = null; }); const isProcessAliveMock = mock(() => true); -const listManifestsMock = mock( - () => [] as NonNullable[], -); const killProcessMock = mock((pid: number, signal: NodeJS.Signals | number) => { if (killProcessError) { const error = killProcessError; @@ -55,7 +49,6 @@ mock.module("./host-service-manifest", () => ({ removeManifest: removeManifestMock, isProcessAlive: isProcessAliveMock, killProcess: killProcessMock, - listManifests: listManifestsMock, manifestDir: (orgId: string) => path.join(testManifestRoot, orgId), })); @@ -117,187 +110,43 @@ interface HostServiceCoordinatorInternals { rememberPort(organizationId: string, port: number): void; } -describe("HostServiceCoordinator.tryAdopt — adoption health check", () => { - let coordinator: InstanceType; - let spawnMock: ReturnType; - - beforeEach(() => { - manifestStore.current = null; - readManifestMock.mockClear(); - removeManifestMock.mockClear(); - isProcessAliveMock.mockClear(); - listManifestsMock.mockClear(); - killProcessMock.mockClear(); - pollHealthCheckMock.mockClear(); - - testManifestRoot = fs.mkdtempSync(path.join(os.tmpdir(), "hsc-test-")); - - killedPids = []; - killProcessError = null; - - coordinator = new HostServiceCoordinator(); - // Replace spawn so a failed adoption doesn't actually launch electron. - spawnMock = mock(async () => ({ - port: 60000, - secret: "fresh-secret", - machineId: "host-1", - })); - (coordinator as unknown as { spawn: typeof spawnMock }).spawn = spawnMock; - }); - - afterEach(() => { - coordinator.releaseAll(); - if (testManifestRoot) { - fs.rmSync(testManifestRoot, { recursive: true, force: true }); - testManifestRoot = ""; - } - }); - - test("adopts when manifest is healthy", async () => { - manifestStore.current = baseManifest(1234); - pollHealthCheckMock.mockImplementationOnce(() => Promise.resolve(true)); - - const conn = await coordinator.start("org-1", spawnConfig); - - expect(conn.port).toBe(55555); - expect(conn.secret).toBe("manifest-secret"); - expect(pollHealthCheckMock).toHaveBeenCalledTimes(1); - expect(spawnMock).not.toHaveBeenCalled(); - expect(removeManifestMock).not.toHaveBeenCalled(); - expect(coordinator.getProcessStatus("org-1")).toBe("running"); - }); - - test("kills the adopted pid with SIGKILL and falls through to spawn when health check fails", async () => { - manifestStore.current = baseManifest(4321); - pollHealthCheckMock.mockImplementationOnce(() => Promise.resolve(false)); - - const conn = await coordinator.start("org-1", spawnConfig); - - expect(pollHealthCheckMock).toHaveBeenCalledTimes(1); - expect(killedPids).toContainEqual({ pid: 4321, signal: "SIGKILL" }); - expect(removeManifestMock).toHaveBeenCalledTimes(1); - expect(spawnMock).toHaveBeenCalledTimes(1); - expect(conn.port).toBe(60000); - expect(conn.secret).toBe("fresh-secret"); - }); - - test("swallows SIGKILL ESRCH (pid already gone) and still respawns", async () => { - manifestStore.current = baseManifest(7777); - pollHealthCheckMock.mockImplementationOnce(() => Promise.resolve(false)); - const err: NodeJS.ErrnoException = new Error("kill ESRCH"); - err.code = "ESRCH"; - killProcessError = err; - - const conn = await coordinator.start("org-1", spawnConfig); - - expect(killProcessMock).toHaveBeenCalledWith(7777, "SIGKILL"); - expect(removeManifestMock).toHaveBeenCalledTimes(1); - expect(spawnMock).toHaveBeenCalledTimes(1); - expect(conn.port).toBe(60000); - }); - - test("kills and respawns when app-version changed even if the service is healthy", async () => { - manifestStore.current = { - ...baseManifest(5555), - spawnedByAppVersion: "0.9.0", - }; - pollHealthCheckMock.mockImplementationOnce(() => Promise.resolve(true)); - - const conn = await coordinator.start("org-1", spawnConfig); - - expect(pollHealthCheckMock).toHaveBeenCalledTimes(1); - expect(killedPids).toContainEqual({ pid: 5555, signal: "SIGKILL" }); - expect(removeManifestMock).toHaveBeenCalledTimes(1); - expect(spawnMock).toHaveBeenCalledTimes(1); - expect(conn.port).toBe(60000); - expect(conn.secret).toBe("fresh-secret"); - }); - - test("removes an unhealthy app-version mismatch without killing when health does not verify", async () => { - manifestStore.current = { - ...baseManifest(5556), - spawnedByAppVersion: "0.9.0", - }; - pollHealthCheckMock.mockImplementationOnce(() => Promise.resolve(false)); - - const conn = await coordinator.start("org-1", spawnConfig); - - expect(pollHealthCheckMock).toHaveBeenCalledTimes(1); - expect(killedPids).toHaveLength(0); - expect(removeManifestMock).toHaveBeenCalledTimes(1); - expect(spawnMock).toHaveBeenCalledTimes(1); - expect(conn.port).toBe(60000); - }); - - test("kills and respawns a healthy pre-upgrade manifest with no recorded app version", async () => { - manifestStore.current = { - ...baseManifest(5557), - spawnedByAppVersion: "", - }; - pollHealthCheckMock.mockImplementationOnce(() => Promise.resolve(true)); - - const conn = await coordinator.start("org-1", spawnConfig); - - expect(pollHealthCheckMock).toHaveBeenCalledTimes(1); - expect(killedPids).toContainEqual({ pid: 5557, signal: "SIGKILL" }); - expect(removeManifestMock).toHaveBeenCalledTimes(1); - expect(spawnMock).toHaveBeenCalledTimes(1); - expect(conn.port).toBe(60000); - expect(conn.secret).toBe("fresh-secret"); - }); - - test("removes an unhealthy pre-upgrade manifest without killing when health does not verify", async () => { - manifestStore.current = { - ...baseManifest(5558), - spawnedByAppVersion: "", - }; - pollHealthCheckMock.mockImplementationOnce(() => Promise.resolve(false)); - - const conn = await coordinator.start("org-1", spawnConfig); - - expect(pollHealthCheckMock).toHaveBeenCalledTimes(1); - expect(killedPids).toHaveLength(0); - expect(removeManifestMock).toHaveBeenCalledTimes(1); - expect(spawnMock).toHaveBeenCalledTimes(1); - expect(conn.port).toBe(60000); - }); -}); +function resetMocks(): void { + manifestStore.current = null; + readManifestMock.mockClear(); + removeManifestMock.mockClear(); + isProcessAliveMock.mockClear(); + killProcessMock.mockClear(); + pollHealthCheckMock.mockClear(); + killedPids = []; + killProcessError = null; +} describe("HostServiceCoordinator preferred ports", () => { let coordinator: InstanceType; beforeEach(() => { - manifestStore.current = null; - readManifestMock.mockClear(); - removeManifestMock.mockClear(); - isProcessAliveMock.mockClear(); - listManifestsMock.mockClear(); - killProcessMock.mockClear(); - pollHealthCheckMock.mockClear(); - + resetMocks(); testManifestRoot = fs.mkdtempSync(path.join(os.tmpdir(), "hsc-test-")); coordinator = new HostServiceCoordinator(); }); afterEach(() => { - coordinator.releaseAll(); + coordinator.stopAll(); if (testManifestRoot) { fs.rmSync(testManifestRoot, { recursive: true, force: true }); testManifestRoot = ""; } }); - test("prefers the last known port, then the manifest port, then a stable org port", () => { - manifestStore.current = baseManifest(1234, "http://127.0.0.1:45555"); + test("prefers the last known port, then a stable org port", () => { const internals = coordinator as unknown as HostServiceCoordinatorInternals; internals.rememberPort("org-1", 46666); const ports = internals.getPreferredPorts("org-1"); expect(ports[0]).toBe(46666); - expect(ports[1]).toBe(45555); - expect(ports[2]).toBeGreaterThanOrEqual(48_000); - expect(ports[2]).toBeLessThan(49_000); + expect(ports[1]).toBeGreaterThanOrEqual(48_000); + expect(ports[1]).toBeLessThan(49_000); }); test("uses a deterministic stable port when no previous port exists", () => { @@ -318,19 +167,9 @@ describe("HostServiceCoordinator.reset", () => { let spawnMock: ReturnType; beforeEach(() => { - manifestStore.current = null; - readManifestMock.mockClear(); - removeManifestMock.mockClear(); - isProcessAliveMock.mockClear(); - listManifestsMock.mockClear(); - killProcessMock.mockClear(); - pollHealthCheckMock.mockClear(); - + resetMocks(); testManifestRoot = fs.mkdtempSync(path.join(os.tmpdir(), "hsc-test-")); - killedPids = []; - killProcessError = null; - coordinator = new HostServiceCoordinator(); spawnMock = mock(async () => ({ port: 60000, @@ -341,7 +180,7 @@ describe("HostServiceCoordinator.reset", () => { }); afterEach(() => { - coordinator.releaseAll(); + coordinator.stopAll(); if (testManifestRoot) { fs.rmSync(testManifestRoot, { recursive: true, force: true }); testManifestRoot = ""; @@ -354,26 +193,22 @@ describe("HostServiceCoordinator.reset", () => { const conn = await coordinator.reset("org-1", spawnConfig); expect(killedPids).toContainEqual({ pid: 8888, signal: "SIGKILL" }); - expect(removeManifestMock).toHaveBeenCalledTimes(1); + expect(removeManifestMock).toHaveBeenCalled(); expect(spawnMock).toHaveBeenCalledTimes(1); expect(conn.port).toBe(60000); expect(conn.secret).toBe("fresh-secret"); }); - test("SIGKILLs the manifest pid even when an instance is tracked (stop's SIGTERM may not be enough)", async () => { - // First adopt a healthy instance so it's tracked in `this.instances`. - manifestStore.current = baseManifest(2468); - pollHealthCheckMock.mockImplementationOnce(() => Promise.resolve(true)); - await coordinator.start("org-1", spawnConfig); - expect(coordinator.getProcessStatus("org-1")).toBe("running"); - killedPids.length = 0; + test("swallows SIGKILL ESRCH (pid already gone) and still respawns", async () => { + manifestStore.current = baseManifest(7777); + const err: NodeJS.ErrnoException = new Error("kill ESRCH"); + err.code = "ESRCH"; + killProcessError = err; - // Adoption leaves the manifest in place; reset must read its pid before - // stop() removes it, then escalate SIGTERM → SIGKILL on a wedged process. const conn = await coordinator.reset("org-1", spawnConfig); - expect(killedPids).toContainEqual({ pid: 2468, signal: "SIGTERM" }); - expect(killedPids).toContainEqual({ pid: 2468, signal: "SIGKILL" }); + expect(killProcessMock).toHaveBeenCalledWith(7777, "SIGKILL"); + expect(removeManifestMock).toHaveBeenCalled(); expect(spawnMock).toHaveBeenCalledTimes(1); expect(conn.port).toBe(60000); }); @@ -384,61 +219,22 @@ describe("HostServiceCoordinator.reset", () => { const conn = await coordinator.reset("org-1", spawnConfig); expect(killedPids).toHaveLength(0); - // `removeManifest` is called unconditionally — that's fine, the impl + // removeManifest is called unconditionally — that's fine, the impl // in host-service-manifest treats a missing file as a no-op. - expect(removeManifestMock).toHaveBeenCalledTimes(1); + expect(removeManifestMock).toHaveBeenCalled(); expect(spawnMock).toHaveBeenCalledTimes(1); expect(conn.port).toBe(60000); }); -}); - -describe("HostServiceCoordinator.teardownKnownManifests", () => { - let coordinator: InstanceType; - - beforeEach(() => { - manifestStore.current = null; - readManifestMock.mockClear(); - removeManifestMock.mockClear(); - isProcessAliveMock.mockClear(); - listManifestsMock.mockClear(); - killProcessMock.mockClear(); - pollHealthCheckMock.mockClear(); - - testManifestRoot = fs.mkdtempSync(path.join(os.tmpdir(), "hsc-test-")); - killedPids = []; - killProcessError = null; - coordinator = new HostServiceCoordinator(); - }); + test("skips SIGKILL when the manifest pid is no longer alive", async () => { + manifestStore.current = baseManifest(9999); + isProcessAliveMock.mockImplementationOnce(() => false); - afterEach(() => { - coordinator.releaseAll(); - if (testManifestRoot) { - fs.rmSync(testManifestRoot, { recursive: true, force: true }); - testManifestRoot = ""; - } - }); + const conn = await coordinator.reset("org-1", spawnConfig); - test("health-verifies manifest-backed services before killing", async () => { - listManifestsMock.mockImplementationOnce(() => [ - baseManifest(9001), - { - ...baseManifest(9002), - organizationId: "org-2", - }, - ]); - pollHealthCheckMock - .mockImplementationOnce(() => Promise.resolve(true)) - .mockImplementationOnce(() => Promise.resolve(false)); - - await coordinator.teardownKnownManifests(); - - expect(killedPids).toContainEqual({ pid: 9001, signal: "SIGKILL" }); - expect(killedPids).not.toContainEqual({ pid: 9002, signal: "SIGKILL" }); - expect(removeManifestMock).toHaveBeenCalledWith("org-1"); - expect(removeManifestMock).toHaveBeenCalledWith("org-2"); - expect(readManifestMock).not.toHaveBeenCalled(); - expect(pollHealthCheckMock).toHaveBeenCalledTimes(2); + expect(killedPids).toHaveLength(0); + expect(spawnMock).toHaveBeenCalledTimes(1); + expect(conn.port).toBe(60000); }); }); diff --git a/apps/desktop/src/main/lib/host-service-coordinator.ts b/apps/desktop/src/main/lib/host-service-coordinator.ts index e160ad5200b..c3f35c83039 100644 --- a/apps/desktop/src/main/lib/host-service-coordinator.ts +++ b/apps/desktop/src/main/lib/host-service-coordinator.ts @@ -11,10 +11,8 @@ import { env as sharedEnv } from "shared/env.shared"; import { getProcessEnvWithShellPath } from "../../lib/trpc/routers/workspaces/utils/shell-env"; import { SUPERSET_HOME_DIR } from "./app-environment"; import { - type HostServiceManifest, isProcessAlive, killProcess, - listManifests, manifestDir, readManifest, removeManifest, @@ -56,7 +54,6 @@ interface HostServiceProcess { status: HostServiceStatus; } -const ADOPTED_LIVENESS_INTERVAL = 5_000; // High, uncommon user-space range: above usual web/dev server ports and below // macOS's default ephemeral range, while still falling back if occupied. const STABLE_PORT_BASE = 48_000; @@ -71,15 +68,6 @@ function getStablePortForOrganization(organizationId: string): number { return STABLE_PORT_BASE + ((hash >>> 0) % STABLE_PORT_COUNT); } -function getPortFromEndpoint(endpoint: string): number | undefined { - try { - const port = Number(new URL(endpoint).port); - return isValidPort(port) ? port : undefined; - } catch { - return undefined; - } -} - function isValidPort(port: number | null | undefined): port is number { return ( typeof port === "number" && @@ -90,27 +78,18 @@ function isValidPort(port: number | null | undefined): port is number { } /** - * Cap how long an adoption health check can take before we decide the adopted - * process is dead and respawn. Short enough that a Cmd+R into a wedged - * host-service heals quickly; long enough to ride out brief startup blips. + * Coupled to Electron: each child is spawned attached and SIGTERMed on + * before-quit. PTYs survive across Electron restarts via the pty-daemon + * layer host-service supervises, not via host-service itself. Manifests + * are still written by the child for the CLI's benefit. */ -const ADOPT_HEALTH_CHECK_TIMEOUT_MS = 2_000; - export class HostServiceCoordinator extends EventEmitter { private instances = new Map(); private pendingStarts = new Map>(); private lastKnownPorts = new Map(); - private adoptedLivenessTimers = new Map< - string, - ReturnType - >(); private scriptPath = path.join(__dirname, "host-service.js"); private machineId = getHostId(); private devReloadWatcher: fs.FSWatcher | null = null; - // Note: pty-daemon supervision moved into host-service itself — - // see packages/host-service/src/daemon. Host-service spawns and adopts - // the daemon when it boots, so the desktop coordinator no longer needs - // to know about it. async start( organizationId: string, @@ -136,13 +115,11 @@ export class HostServiceCoordinator extends EventEmitter { const pending = this.pendingStarts.get(organizationId); if (pending) return pending; - const startPromise = (async (): Promise => { - const resolvedPreferredPorts = - preferredPorts ?? this.getPreferredPorts(organizationId); - const adopted = await this.tryAdopt(organizationId); - if (adopted) return adopted; - return this.spawn(organizationId, config, resolvedPreferredPorts); - })(); + const startPromise = this.spawn( + organizationId, + config, + preferredPorts ?? this.getPreferredPorts(organizationId), + ); this.pendingStarts.set(organizationId, startPromise); try { @@ -156,7 +133,6 @@ export class HostServiceCoordinator extends EventEmitter { const ports = [ this.instances.get(organizationId)?.port, this.lastKnownPorts.get(organizationId), - this.getManifestPort(organizationId), getStablePortForOrganization(organizationId), ]; const uniquePorts: number[] = []; @@ -171,21 +147,6 @@ export class HostServiceCoordinator extends EventEmitter { return uniquePorts; } - private getManifestPort(organizationId: string): number | undefined { - const manifest = readManifest(organizationId); - if (!manifest) return undefined; - return this.rememberManifestPort(organizationId, manifest); - } - - private rememberManifestPort( - organizationId: string, - manifest: HostServiceManifest, - ): number | undefined { - const port = getPortFromEndpoint(manifest.endpoint); - if (port) this.rememberPort(organizationId, port); - return port; - } - private rememberPort(organizationId: string, port: number): void { if (!isValidPort(port)) return; this.lastKnownPorts.set(organizationId, port); @@ -193,8 +154,6 @@ export class HostServiceCoordinator extends EventEmitter { stop(organizationId: string): void { const instance = this.instances.get(organizationId); - this.stopAdoptedLivenessCheck(organizationId); - if (!instance) return; const previousStatus = instance.status; @@ -216,40 +175,6 @@ export class HostServiceCoordinator extends EventEmitter { } } - releaseAll(): void { - for (const [id] of this.instances) { - this.stopAdoptedLivenessCheck(id); - } - this.instances.clear(); - } - - async discoverAll(): Promise { - const manifests = listManifests(); - for (const manifest of manifests) { - if (this.instances.has(manifest.organizationId)) continue; - try { - await this.tryAdopt(manifest.organizationId); - } catch { - removeManifest(manifest.organizationId); - } - } - } - - async teardownKnownManifests(): Promise { - for (const manifest of listManifests()) { - const verified = await pollHealthCheck( - manifest.endpoint, - manifest.authToken, - ADOPT_HEALTH_CHECK_TIMEOUT_MS, - ); - if (verified) { - this.killManifestProcess(manifest.organizationId, manifest, "stale"); - } else { - removeManifest(manifest.organizationId); - } - } - } - async restart( organizationId: string, config: SpawnConfig, @@ -262,10 +187,11 @@ export class HostServiceCoordinator extends EventEmitter { /** * Forcefully reset host-service state for an org. Unlike `restart`, this * SIGKILLs whatever pid the manifest names — even when no instance is - * tracked in this process (e.g. a manifest left by a previous app session) - * — then removes the manifest so adoption can't pick up the stale entry, - * and respawns. Used by the recovery path for superset-sh/superset#4299 - * where a live-but-wedged host-service keeps getting re-adopted. + * tracked in this process (e.g. a stale manifest left by a CLI-spawned + * host-service) — then removes the manifest so callers can't pick up the + * stale entry, and respawns. Used by the recovery path for + * superset-sh/superset#4299 where a wedged host-service keeps serving + * stale state. */ async reset( organizationId: string, @@ -418,108 +344,6 @@ export class HostServiceCoordinator extends EventEmitter { }; } - // ── Adoption ────────────────────────────────────────────────────── - - private async tryAdopt(organizationId: string): Promise { - const manifest = this.readAndValidateManifest(organizationId); - if (!manifest) return null; - - const url = new URL(manifest.endpoint); - const port = Number(url.port); - this.rememberPort(organizationId, port); - - const currentAppVersion = app.getVersion(); - if (manifest.spawnedByAppVersion !== currentAppVersion) { - const reason = manifest.spawnedByAppVersion - ? `spawned by app ${manifest.spawnedByAppVersion} != current ${currentAppVersion}` - : "no recorded app version (pre-upgrade manifest)"; - const verified = await pollHealthCheck( - manifest.endpoint, - manifest.authToken, - ADOPT_HEALTH_CHECK_TIMEOUT_MS, - ); - - if (verified) { - log.info( - `[host-service:${organizationId}] Refusing to adopt stale service (${reason}); killing and respawning`, - ); - this.killManifestProcess(organizationId, manifest, "stale"); - } else { - log.warn( - `[host-service:${organizationId}] Stale manifest (${reason}) did not verify on ${manifest.endpoint}; removing manifest and respawning without kill`, - ); - removeManifest(organizationId); - } - - return null; - } - - // A live pid is not the same as a serving host-service — the process can - // be hung on migrations, deadlocked, or no longer bound to the recorded - // port. Without this check the renderer's `getConnection` keeps handing - // out a dead port forever, which is the failure mode in superset-sh/superset#4299. - const healthy = await pollHealthCheck( - manifest.endpoint, - manifest.authToken, - ADOPT_HEALTH_CHECK_TIMEOUT_MS, - ); - if (!healthy) { - log.info( - `[host-service:${organizationId}] Adopted pid=${manifest.pid} did not respond on ${manifest.endpoint}, killing and respawning`, - ); - this.killManifestProcess(organizationId, manifest, "unhealthy"); - return null; - } - - this.instances.set(organizationId, { - pid: manifest.pid, - port, - secret: manifest.authToken, - status: "running", - }); - this.startAdoptedLivenessCheck(organizationId, manifest.pid); - - log.info( - `[host-service:${organizationId}] Adopted pid=${manifest.pid} port=${port}`, - ); - this.emitStatus(organizationId, "running", null); - return { port, secret: manifest.authToken, machineId: this.machineId }; - } - - private readAndValidateManifest( - organizationId: string, - ): HostServiceManifest | null { - const manifest = readManifest(organizationId); - if (!manifest) return null; - this.rememberManifestPort(organizationId, manifest); - - if (!isProcessAlive(manifest.pid)) { - removeManifest(organizationId); - return null; - } - - return manifest; - } - - private killManifestProcess( - organizationId: string, - manifest: HostServiceManifest, - reason: "stale" | "unhealthy", - ): void { - try { - killProcess(manifest.pid, "SIGKILL"); - } catch (error) { - // ESRCH (already gone) is fine; anything else (EPERM) we want to see. - if ((error as NodeJS.ErrnoException)?.code !== "ESRCH") { - log.warn( - `[host-service:${organizationId}] SIGKILL of ${reason} pid=${manifest.pid} failed`, - error, - ); - } - } - removeManifest(organizationId); - } - // ── Spawn ───────────────────────────────────────────────────────── private async spawn( @@ -540,22 +364,14 @@ export class HostServiceCoordinator extends EventEmitter { this.instances.set(organizationId, instance); this.emitStatus(organizationId, "starting", null); - // pty-daemon is supervised by host-service itself; this coordinator - // only spawns host-service and steps out. See - // packages/host-service/src/daemon for the supervisor lifecycle. const childEnv = await this.buildEnv(organizationId, port, secret, config); - // Host-service owns v2 PTYs, so it must survive Electron restarts in - // every environment. This mirrors the terminal-host daemon: detach the - // child and back stdio with real files so parent teardown cannot close - // pipes and take the service down with the app. const logFd = openRotatingLogFd( path.join(manifestDir(organizationId), "host-service.log"), MAX_HOST_LOG_BYTES, ); // Dev: pipe child stdout/stderr through this process so log lines // land in the developer's `bun dev` terminal. Production: hard-back - // stdio with the rotating log file so the detached child survives - // parent teardown without losing logs. + // stdio with the rotating log file. const isDev = !app.isPackaged; const stdio: childProcess.StdioOptions = isDev ? ["ignore", "pipe", "pipe"] @@ -565,11 +381,8 @@ export class HostServiceCoordinator extends EventEmitter { let child: ReturnType; try { - // Prod: detached so PTYs survive Electron restarts via manifest - // adoption (docs/HOST_SERVICE_LIFECYCLE.md). Dev: attached so a `bun dev` - // kill propagates and serve.ts's dev shutdown can stop pty-daemon. child = childProcess.spawn(process.execPath, [this.scriptPath], { - detached: !isDev, + detached: false, stdio, env: childEnv, // Avoid a flashing CMD window on Windows. @@ -586,8 +399,7 @@ export class HostServiceCoordinator extends EventEmitter { } // In dev, fan child output through to parent stdout/stderr with a - // prefix so it's identifiable in `bun dev`. The detached child has - // its own session, so closing pipes won't kill it on parent exit. + // prefix so it's identifiable in `bun dev`. if (isDev && child.stdout && child.stderr) { const tag = `[hs:${organizationId.slice(0, 8)}]`; pipeWithPrefix(child.stdout, process.stdout, tag); @@ -612,7 +424,8 @@ export class HostServiceCoordinator extends EventEmitter { removeManifest(organizationId); this.emitStatus(organizationId, "stopped", "running"); }); - if (!isDev) child.unref(); + // Don't let the child block Electron's exit — stopAll() handles teardown. + child.unref(); const endpoint = `http://127.0.0.1:${port}`; const healthy = await pollHealthCheck(endpoint, secret); @@ -664,6 +477,9 @@ export class HostServiceCoordinator extends EventEmitter { SUPERSET_APP_VERSION: app.getVersion(), AUTH_TOKEN: config.authToken, SUPERSET_API_URL: config.cloudApiUrl, + // Read by the child's parent watchdog so it can self-exit if + // Electron crashes without sending SIGTERM (orphan reparenting). + HOST_PARENT_PID: String(process.pid), }); // `getProcessEnvWithShellPath` merges in the user's interactive shell env, @@ -682,37 +498,6 @@ export class HostServiceCoordinator extends EventEmitter { return childEnv; } - // ── Liveness ────────────────────────────────────────────────────── - - private startAdoptedLivenessCheck(organizationId: string, pid: number): void { - this.stopAdoptedLivenessCheck(organizationId); - const timer = setInterval(() => { - if (!isProcessAlive(pid)) { - clearInterval(timer); - this.adoptedLivenessTimers.delete(organizationId); - const instance = this.instances.get(organizationId); - if (instance && instance.status !== "stopped") { - log.info( - `[host-service:${organizationId}] Adopted process ${pid} died`, - ); - this.rememberPort(organizationId, instance.port); - this.instances.delete(organizationId); - removeManifest(organizationId); - this.emitStatus(organizationId, "stopped", "running"); - } - } - }, ADOPTED_LIVENESS_INTERVAL); - this.adoptedLivenessTimers.set(organizationId, timer); - } - - private stopAdoptedLivenessCheck(organizationId: string): void { - const timer = this.adoptedLivenessTimers.get(organizationId); - if (timer) { - clearInterval(timer); - this.adoptedLivenessTimers.delete(organizationId); - } - } - // ── Events ──────────────────────────────────────────────────────── private emitStatus( From 06aa42373a1ec819419c96d331e7602b3a6382f6 Mon Sep 17 00:00:00 2001 From: Kiet Ho Date: Tue, 19 May 2026 16:40:38 -0700 Subject: [PATCH 2/2] chore(desktop): drop dead code from host-service coupling refactor MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Delete hasActiveInstances() — no callers post-adoption. - Delete listManifests() from apps/desktop manifest module — only callers were the adoption discoverAll/teardownKnownManifests paths, both removed. - Delete spawnedByAppVersion manifest field + SUPERSET_APP_VERSION env propagation — only consumer was the version-mismatch adoption branch. - Install parent-pid watchdog at the top of the desktop host-service main() so a crash during startup awaits can still reap the child. --- apps/desktop/src/main/host-service/env.ts | 1 - apps/desktop/src/main/host-service/index.ts | 89 ++++++++++--------- .../main/lib/host-service-coordinator.test.ts | 2 - .../src/main/lib/host-service-coordinator.ts | 9 -- .../src/main/lib/host-service-manifest.ts | 35 -------- 5 files changed, 49 insertions(+), 87 deletions(-) diff --git a/apps/desktop/src/main/host-service/env.ts b/apps/desktop/src/main/host-service/env.ts index 51c45864b93..7641208ca13 100644 --- a/apps/desktop/src/main/host-service/env.ts +++ b/apps/desktop/src/main/host-service/env.ts @@ -12,7 +12,6 @@ export const env = createEnv({ ORGANIZATION_ID: z.string().min(1), DESKTOP_VITE_PORT: z.coerce.number().int().positive(), RELAY_URL: z.string().url().optional(), - SUPERSET_APP_VERSION: z.string().min(1), }, runtimeEnv: process.env, emptyStringAsUndefined: true, diff --git a/apps/desktop/src/main/host-service/index.ts b/apps/desktop/src/main/host-service/index.ts index d7125e8ac76..ec4302982c9 100644 --- a/apps/desktop/src/main/host-service/index.ts +++ b/apps/desktop/src/main/host-service/index.ts @@ -23,7 +23,55 @@ import { loadToken } from "lib/trpc/routers/auth/utils/auth-functions"; import { writeManifest } from "main/lib/host-service-manifest"; import { env } from "./env"; +const SHUTDOWN_GRACE_MS = 3_000; +const WATCHDOG_INTERVAL_MS = 2_000; + +type Server = ReturnType; + async function main(): Promise { + // Install the parent watchdog before any awaits so a crash during + // startup can still reap this child. `serverRef` is filled in once + // serve() returns; shutdown handles both pre- and post-bind states. + const serverRef: { current: Server | null } = { current: null }; + let shuttingDown = false; + const shutdown = (reason: string) => { + if (shuttingDown) return; + shuttingDown = true; + console.log(`[host-service] shutdown (${reason}), draining connections`); + const server = serverRef.current; + if (!server) { + process.exit(0); + } + server.close(); + // SSE/WS streams (chat, watchers) ignore server.close() — give in-flight + // HTTP a brief window, then forcibly tear sockets down. + const forceExit = setTimeout(() => { + const httpServer = server as unknown as { + closeAllConnections?: () => void; + }; + httpServer.closeAllConnections?.(); + process.exit(0); + }, SHUTDOWN_GRACE_MS); + forceExit.unref(); + }; + + process.on("SIGTERM", () => shutdown("SIGTERM")); + process.on("SIGINT", () => shutdown("SIGINT")); + + // Self-exit if our Electron parent dies without sending SIGTERM + // (orphan reparenting to init/launchd). CLI-spawned host-services + // don't set HOST_PARENT_PID and skip this. + const parentPid = Number(process.env.HOST_PARENT_PID); + if (Number.isInteger(parentPid) && parentPid > 1) { + const interval = setInterval(() => { + if (!isParentAlive(parentPid)) { + clearInterval(interval); + shutdown("parent-exit"); + } + }, WATCHDOG_INTERVAL_MS); + interval.unref(); + } + const terminalBaseEnv = await resolveTerminalBaseEnv(); initTerminalBaseEnv(terminalBaseEnv); @@ -75,7 +123,6 @@ async function main(): Promise { authToken: env.HOST_SERVICE_SECRET, startedAt, organizationId: env.ORGANIZATION_ID, - spawnedByAppVersion: env.SUPERSET_APP_VERSION, }); } catch (error) { console.error("[host-service] Failed to write manifest:", error); @@ -94,48 +141,10 @@ async function main(): Promise { } }, ); + serverRef.current = server; injectWebSocket(server); - - let shuttingDown = false; - const shutdown = (reason: string) => { - if (shuttingDown) return; - shuttingDown = true; - console.log(`[host-service] shutdown (${reason}), draining connections`); - server.close(); - // SSE/WS streams (chat, watchers) ignore server.close() — give in-flight - // HTTP a brief window, then forcibly tear sockets down. - const forceExit = setTimeout(() => { - const httpServer = server as unknown as { - closeAllConnections?: () => void; - }; - httpServer.closeAllConnections?.(); - process.exit(0); - }, SHUTDOWN_GRACE_MS); - forceExit.unref(); - }; - - process.on("SIGTERM", () => shutdown("SIGTERM")); - process.on("SIGINT", () => shutdown("SIGINT")); - - // Self-exit if our Electron parent dies without sending SIGTERM - // (orphan reparenting to init/launchd). CLI-spawned host-services - // don't set HOST_PARENT_PID and skip this. - const parentPid = Number(process.env.HOST_PARENT_PID); - if (Number.isInteger(parentPid) && parentPid > 1) { - const interval = setInterval(() => { - const stillParented = isParentAlive(parentPid); - if (!stillParented) { - clearInterval(interval); - shutdown("parent-exit"); - } - }, WATCHDOG_INTERVAL_MS); - interval.unref(); - } } -const SHUTDOWN_GRACE_MS = 3_000; -const WATCHDOG_INTERVAL_MS = 2_000; - function isParentAlive(parentPid: number): boolean { try { process.kill(parentPid, 0); diff --git a/apps/desktop/src/main/lib/host-service-coordinator.test.ts b/apps/desktop/src/main/lib/host-service-coordinator.test.ts index 7d5f20c41ef..411dfe4d689 100644 --- a/apps/desktop/src/main/lib/host-service-coordinator.test.ts +++ b/apps/desktop/src/main/lib/host-service-coordinator.test.ts @@ -22,7 +22,6 @@ const manifestStore: { authToken: string; startedAt: number; organizationId: string; - spawnedByAppVersion: string; } | null; } = { current: null }; @@ -100,7 +99,6 @@ const baseManifest = (pid: number, endpoint = "http://127.0.0.1:55555") => ({ authToken: "manifest-secret", startedAt: 0, organizationId: "org-1", - spawnedByAppVersion: APP_VERSION, }); const spawnConfig = { authToken: "token", cloudApiUrl: "https://api.example" }; diff --git a/apps/desktop/src/main/lib/host-service-coordinator.ts b/apps/desktop/src/main/lib/host-service-coordinator.ts index c3f35c83039..283776c51d3 100644 --- a/apps/desktop/src/main/lib/host-service-coordinator.ts +++ b/apps/desktop/src/main/lib/host-service-coordinator.ts @@ -236,14 +236,6 @@ export class HostServiceCoordinator extends EventEmitter { return this.instances.get(organizationId)?.status ?? "stopped"; } - hasActiveInstances(): boolean { - for (const instance of this.instances.values()) { - if (instance.status === "running" || instance.status === "starting") - return true; - } - return this.pendingStarts.size > 0; - } - getActiveOrganizationIds(): string[] { return [...this.instances.entries()] .filter(([, i]) => i.status !== "stopped") @@ -474,7 +466,6 @@ export class HostServiceCoordinator extends EventEmitter { SUPERSET_HOME_DIR: SUPERSET_HOME_DIR, SUPERSET_AGENT_HOOK_PORT: String(sharedEnv.DESKTOP_NOTIFICATIONS_PORT), SUPERSET_AGENT_HOOK_VERSION: HOOK_PROTOCOL_VERSION, - SUPERSET_APP_VERSION: app.getVersion(), AUTH_TOKEN: config.authToken, SUPERSET_API_URL: config.cloudApiUrl, // Read by the child's parent watchdog so it can self-exit if diff --git a/apps/desktop/src/main/lib/host-service-manifest.ts b/apps/desktop/src/main/lib/host-service-manifest.ts index 26eca98d104..27b8a92b244 100644 --- a/apps/desktop/src/main/lib/host-service-manifest.ts +++ b/apps/desktop/src/main/lib/host-service-manifest.ts @@ -1,7 +1,6 @@ import { existsSync, mkdirSync, - readdirSync, readFileSync, unlinkSync, writeFileSync, @@ -15,12 +14,6 @@ export interface HostServiceManifest { authToken: string; startedAt: number; organizationId: string; - /** - * Desktop app version that spawned this host-service. Desktop uses this to - * replace the detached host-service after an app update even when the - * host-service package version was not bumped. - */ - spawnedByAppVersion: string; } export function manifestDir(organizationId: string): string { @@ -66,40 +59,12 @@ export function readManifest( return null; } - // `spawnedByAppVersion` is required going forward, but pre-existing - // manifests on upgraded users won't have it. Coerce to empty string so - // `tryAdopt` can treat it as stale and still health-verify before - // signaling any PID. - if (typeof data.spawnedByAppVersion !== "string") { - data.spawnedByAppVersion = ""; - } - return data as HostServiceManifest; } catch { return null; } } -/** Scan the host directory for all valid manifests on disk. */ -export function listManifests(): HostServiceManifest[] { - const hostDir = join(SUPERSET_HOME_DIR, "host"); - if (!existsSync(hostDir)) return []; - - const manifests: HostServiceManifest[] = []; - try { - for (const entry of readdirSync(hostDir, { withFileTypes: true })) { - if (!entry.isDirectory()) continue; - const manifest = readManifest(entry.name); - if (manifest) { - manifests.push(manifest); - } - } - } catch { - // Best-effort scan - } - return manifests; -} - export function removeManifest(organizationId: string): void { const filePath = manifestPath(organizationId); try {