From dc0053412e4abfff4e242b3f62d60bfdb364039b Mon Sep 17 00:00:00 2001 From: Kiet Ho Date: Thu, 7 May 2026 16:39:18 -0700 Subject: [PATCH 1/8] docs(desktop): plan to pin host-service adoption to bundled version Replace MIN_HOST_SERVICE_VERSION floor check in tryAdopt with an equality check against a build-time-injected BUNDLED_HOST_SERVICE_VERSION, so auto-updated desktops always end up running the host-service that shipped with them. Pty-daemon (the durable session state) is untouched. --- ...260507-host-service-bundled-version-pin.md | 52 +++++++++++++++++++ 1 file changed, 52 insertions(+) create mode 100644 apps/desktop/plans/20260507-host-service-bundled-version-pin.md diff --git a/apps/desktop/plans/20260507-host-service-bundled-version-pin.md b/apps/desktop/plans/20260507-host-service-bundled-version-pin.md new file mode 100644 index 00000000000..fb4533b8927 --- /dev/null +++ b/apps/desktop/plans/20260507-host-service-bundled-version-pin.md @@ -0,0 +1,52 @@ +# Pin host-service adoption to bundled version + +## Problem + +Today `host-service-coordinator.tryAdopt()` adopts any running host-service whose version satisfies `>= MIN_HOST_SERVICE_VERSION` — a manually-bumped floor in `packages/shared/src/host-version.ts`. After an Electron auto-update, the new desktop happily adopts a stale host-service as long as it clears the floor, so non-breaking host-service changes (patches, additive features) never reach users until someone remembers to bump the floor. + +We want auto-updated desktops to always end up running the host-service binary that shipped with them. + +## Why respawn is safe + +Pty-daemon — the only process holding durable session state — has its own manifest + supervisor (`packages/host-service/src/daemon/DaemonSupervisor.ts`) and is adopted independently. Killing host-service drops only: + +- tunnel-client WebSockets (auto-reconnect) +- the in-memory terminal sessions Map (rebuilt on re-attach via daemon-client) +- cached env + +Real PTY state stays in the daemon across the swap. + +## Change + +Replace the floor check with an equality check against the host-service bundled into this Electron build. Reuse the existing kill+respawn path. + +1. Inject `BUNDLED_HOST_SERVICE_VERSION` into the desktop main bundle at build time (read `packages/host-service/package.json` version), mirroring the `EXPECTED_DAEMON_VERSION` pattern at `packages/host-service/src/daemon/expected-version.ts`. +2. In `apps/desktop/src/main/lib/host-service-coordinator.ts:289-308`, replace + ```ts + !semver.satisfies(version, `>=${MIN_HOST_SERVICE_VERSION}`) + ``` + with + ```ts + version !== BUNDLED_HOST_SERVICE_VERSION + ``` + (or `!semver.gte(version, BUNDLED_HOST_SERVICE_VERSION)` if we want a newer dev daemon to win — see open question). +3. Keep `MIN_HOST_SERVICE_VERSION` only for the renderer-side **remote** host gate at `apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/hooks/useRemoteHostStatus/useRemoteHostStatus.ts:91`. We can't kill remote hosts — a floor is still the right shape there. + +Everything else is unchanged: `detached: true` spawn, manifest re-adoption on next start, crash circuit breaker, daemon lifecycle. + +## Behavior after change + +- **Auto-update ships new host-service version** → next Electron launch: adoption check fails, manifest PID gets SIGTERM, fresh host-service spawns from the bundled binary. One brief tunnel reconnect. +- **Auto-update ships same host-service version** → adoption succeeds, no respawn. +- **Pty-daemon** → never killed by this path; survives across host-service swaps as designed. + +## Open questions + +- **Strict equality vs `>=bundled`?** Strict equality means a dev build pointing at a locally newer host-service would get killed on Electron start. `>=bundled` avoids that but lets a hand-rolled newer daemon stick around indefinitely. Default to strict equality unless dev-flow friction shows up. +- **Drain before SIGTERM?** Today line 304 is a hard kill. If respawn cadence becomes user-visible (tunnel reconnect storms), add a short drain — stop accepting new connections, wait N seconds — before SIGTERM. Not needed in v1. + +## Out of scope + +- Pty-daemon version pinning (already handled by `DaemonSupervisor` + `EXPECTED_DAEMON_VERSION`). +- Changing `MIN_HOST_SERVICE_VERSION` semantics for the remote-host renderer gate. +- Any auto-update lifecycle changes — host-service and pty-daemon still must not be torn down by the auto-updater itself; the respawn happens on the *next* launch via the existing adoption path. From f83da6648760edbb6ff83ad2b38db56743e02fed Mon Sep 17 00:00:00 2001 From: Kiet Ho Date: Thu, 7 May 2026 16:46:31 -0700 Subject: [PATCH 2/8] docs(desktop): commit to strict-equality version pin in host-service plan MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Drop the >=bundled alternative — dev builds pointing at a hand-rolled newer host-service should also get killed and replaced, so only one host-service version is ever live alongside a given desktop. --- .../plans/20260507-host-service-bundled-version-pin.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/apps/desktop/plans/20260507-host-service-bundled-version-pin.md b/apps/desktop/plans/20260507-host-service-bundled-version-pin.md index fb4533b8927..00793589b42 100644 --- a/apps/desktop/plans/20260507-host-service-bundled-version-pin.md +++ b/apps/desktop/plans/20260507-host-service-bundled-version-pin.md @@ -25,11 +25,11 @@ Replace the floor check with an equality check against the host-service bundled ```ts !semver.satisfies(version, `>=${MIN_HOST_SERVICE_VERSION}`) ``` - with + with strict equality: ```ts version !== BUNDLED_HOST_SERVICE_VERSION ``` - (or `!semver.gte(version, BUNDLED_HOST_SERVICE_VERSION)` if we want a newer dev daemon to win — see open question). + The Electron build is the source of truth for which host-service runs against it. Any drift — older or newer — gets killed and respawned from the bundled binary. This keeps the cloud/desktop deploy contract tight (only one host-service version is ever live alongside a given desktop) and avoids the "hand-rolled newer daemon sticks around indefinitely" failure mode. 3. Keep `MIN_HOST_SERVICE_VERSION` only for the renderer-side **remote** host gate at `apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/hooks/useRemoteHostStatus/useRemoteHostStatus.ts:91`. We can't kill remote hosts — a floor is still the right shape there. Everything else is unchanged: `detached: true` spawn, manifest re-adoption on next start, crash circuit breaker, daemon lifecycle. @@ -38,11 +38,11 @@ Everything else is unchanged: `detached: true` spawn, manifest re-adoption on ne - **Auto-update ships new host-service version** → next Electron launch: adoption check fails, manifest PID gets SIGTERM, fresh host-service spawns from the bundled binary. One brief tunnel reconnect. - **Auto-update ships same host-service version** → adoption succeeds, no respawn. +- **Dev: locally newer host-service** → killed and replaced with the bundled version. If you need to test against a newer daemon in dev, point the desktop at a build that bundles it; don't hand-roll the running process. - **Pty-daemon** → never killed by this path; survives across host-service swaps as designed. ## Open questions -- **Strict equality vs `>=bundled`?** Strict equality means a dev build pointing at a locally newer host-service would get killed on Electron start. `>=bundled` avoids that but lets a hand-rolled newer daemon stick around indefinitely. Default to strict equality unless dev-flow friction shows up. - **Drain before SIGTERM?** Today line 304 is a hard kill. If respawn cadence becomes user-visible (tunnel reconnect storms), add a short drain — stop accepting new connections, wait N seconds — before SIGTERM. Not needed in v1. ## Out of scope From 416d2b81a5903b7dbd646c3deb993ba0bb46c378 Mon Sep 17 00:00:00 2001 From: Kiet Ho Date: Thu, 7 May 2026 17:06:50 -0700 Subject: [PATCH 3/8] feat(desktop): pin host-service adoption to bundled version MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace the `>= MIN_HOST_SERVICE_VERSION` floor in `host-service-coordinator.tryAdopt` with strict equality against the host-service version bundled with this Electron build. After auto-update, any running host-service whose version differs is killed and respawned from the bundled binary on the next launch — no more stale daemons clinging to old code as long as they clear a manually-bumped floor. - Promote host-service version to a single source of truth at `packages/shared/src/host-version.ts` (`HOST_SERVICE_VERSION`). - `host.info` reads from there, replacing the previously hardcoded constant in `packages/host-service/src/trpc/router/host/host.ts`. - Desktop coordinator imports it as `BUNDLED_HOST_SERVICE_VERSION` and pins to strict equality. `semver` import dropped from the coordinator since it's no longer needed there. - `MIN_HOST_SERVICE_VERSION` retained for the renderer-side remote-host gate (`useRemoteHostStatus`) where a floor is still the right shape. Pty-daemon — which holds durable session state — has its own manifest + supervisor and is unaffected by host-service swaps, so terminal sessions survive the respawn. Plan: apps/desktop/plans/done/20260507-host-service-bundled-version-pin.md --- ...260507-host-service-bundled-version-pin.md | 11 +++++++ .../src/main/lib/host-service-coordinator.ts | 10 ++---- .../host-service/src/trpc/router/host/host.ts | 22 +------------ packages/shared/src/host-version.ts | 33 ++++++++++++------- 4 files changed, 36 insertions(+), 40 deletions(-) rename apps/desktop/plans/{ => done}/20260507-host-service-bundled-version-pin.md (80%) diff --git a/apps/desktop/plans/20260507-host-service-bundled-version-pin.md b/apps/desktop/plans/done/20260507-host-service-bundled-version-pin.md similarity index 80% rename from apps/desktop/plans/20260507-host-service-bundled-version-pin.md rename to apps/desktop/plans/done/20260507-host-service-bundled-version-pin.md index 00793589b42..4fe7e357ad3 100644 --- a/apps/desktop/plans/20260507-host-service-bundled-version-pin.md +++ b/apps/desktop/plans/done/20260507-host-service-bundled-version-pin.md @@ -50,3 +50,14 @@ Everything else is unchanged: `detached: true` spawn, manifest re-adoption on ne - Pty-daemon version pinning (already handled by `DaemonSupervisor` + `EXPECTED_DAEMON_VERSION`). - Changing `MIN_HOST_SERVICE_VERSION` semantics for the remote-host renderer gate. - Any auto-update lifecycle changes — host-service and pty-daemon still must not be torn down by the auto-updater itself; the respawn happens on the *next* launch via the existing adoption path. + +## Outcomes & Retrospective + +**Shipped:** +- Promoted the host-service version constant to `HOST_SERVICE_VERSION` in `packages/shared/src/host-version.ts` (single source of truth). `host.info` now reads from there, replacing the previously hardcoded `HOST_SERVICE_VERSION = "0.8.0"` in `packages/host-service/src/trpc/router/host/host.ts`. +- Desktop coordinator (`apps/desktop/src/main/lib/host-service-coordinator.ts`) imports it as `BUNDLED_HOST_SERVICE_VERSION`. Adoption now requires strict equality — `version !== BUNDLED_HOST_SERVICE_VERSION` triggers kill + respawn. +- `MIN_HOST_SERVICE_VERSION` retained for the **remote**-host renderer gate (`useRemoteHostStatus.ts`) where a floor is still the right shape. +- `semver` import dropped from the coordinator — no longer needed there. + +**Deferred:** +- Drain-before-SIGTERM. The hard kill is unchanged; revisit if respawn cadence becomes user-visible. diff --git a/apps/desktop/src/main/lib/host-service-coordinator.ts b/apps/desktop/src/main/lib/host-service-coordinator.ts index 454a0761386..d963c42a7a1 100644 --- a/apps/desktop/src/main/lib/host-service-coordinator.ts +++ b/apps/desktop/src/main/lib/host-service-coordinator.ts @@ -5,10 +5,9 @@ import * as fs from "node:fs"; import path from "node:path"; import { settings } from "@superset/local-db"; import { getHostId, getHostName } from "@superset/shared/host-info"; -import { MIN_HOST_SERVICE_VERSION } from "@superset/shared/host-version"; +import { HOST_SERVICE_VERSION as BUNDLED_HOST_SERVICE_VERSION } from "@superset/shared/host-version"; import { app } from "electron"; import { env } from "main/env.main"; -import semver from "semver"; 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"; @@ -290,12 +289,9 @@ export class HostServiceCoordinator extends EventEmitter { manifest.endpoint, manifest.authToken, ); - if ( - !version || - !semver.satisfies(version, `>=${MIN_HOST_SERVICE_VERSION}`) - ) { + if (!version || version !== BUNDLED_HOST_SERVICE_VERSION) { const reason = version - ? `version ${version} < ${MIN_HOST_SERVICE_VERSION}` + ? `version ${version} != bundled ${BUNDLED_HOST_SERVICE_VERSION}` : "version unknown"; console.log( `[host-service:${organizationId}] Adopted service ${reason}, killing`, diff --git a/packages/host-service/src/trpc/router/host/host.ts b/packages/host-service/src/trpc/router/host/host.ts index f49b99423f4..c6bd124554b 100644 --- a/packages/host-service/src/trpc/router/host/host.ts +++ b/packages/host-service/src/trpc/router/host/host.ts @@ -1,30 +1,10 @@ import os from "node:os"; import { getHostId, getHostName } from "@superset/shared/host-info"; +import { HOST_SERVICE_VERSION } from "@superset/shared/host-version"; import { TRPCError } from "@trpc/server"; import type { ApiClient } from "../../../types"; import { protectedProcedure, router } from "../../index"; -// 0.4.0: terminal launch moved from `terminal.ensureSession` to -// `terminal.launchSession` plus WebSocket attach params. -// 0.3.0: cloud `device.*` router renamed to `host.*`; `device.ensureV2Host` -// is now `host.ensure`, host registrations are keyed on (orgId, machineId) -// composite, and `targetHostId`/`v2_workspaces.host_id` are machineId text -// not uuid. Older host-service binaries call the now-removed `device.*` -// procedures and fail at registration. -// 0.2.0: `workspaceCreation.adopt` accepts optional `worktreePath`. -// 0.5.0: pty-daemon supervision moved into host-service. New -// `terminal.daemon` tRPC namespace; existing 0.4.x host-services -// don't expose it, so the desktop coordinator must refuse to adopt -// them on upgrade and respawn with the new bundle. Adopting in -// place would leave the new desktop talking to old code with no -// `terminal.daemon.*` routes, breaking Settings → Manage daemon. -// 0.7.0: canonical `workspaces.create` flow + `settings.hostAgentConfigs` -// router (PR1, #3893). 0.6.x host-services don't expose either, so -// adopting one in place would break new-project creation and the -// agent-config settings UI. -// 0.8.0: terminal creation moved to `terminal.createSession`; WebSocket -// `/terminal/:terminalId` is attach-only. -const HOST_SERVICE_VERSION = "0.8.0"; const ORGANIZATION_CACHE_TTL_MS = 60 * 60 * 1000; let cachedOrganization: { diff --git a/packages/shared/src/host-version.ts b/packages/shared/src/host-version.ts index b028017228f..d267b59ee1f 100644 --- a/packages/shared/src/host-version.ts +++ b/packages/shared/src/host-version.ts @@ -1,8 +1,22 @@ /** - * Minimum host-service version this app can work with. Bumping this forces - * the desktop coordinator to kill + respawn any adopted local service older - * than this, and gates v2 workspace UIs from mounting against a remote host - * whose CLI is still on an older version. + * Canonical version of host-service in this checkout. Reported by + * `host.info` at runtime, and bundled into the desktop main process as + * the version the Electron build expects to run against. Bump on every + * host-service change shipping a new build. + * + * The desktop coordinator pins adoption to this exact value: if a running + * host-service reports a different version (older or newer), it is killed + * and respawned from the bundled binary. Pty-daemon — which holds durable + * session state — has its own manifest + supervisor and survives the swap. + */ +export const HOST_SERVICE_VERSION = "0.8.0"; + +/** + * Minimum host-service version a v2 workspace UI can work with against a + * **remote** host whose binary we don't control (gates renderer mounting + * via `useRemoteHostStatus`). For the local host-service we bundle, the + * desktop coordinator pins to `HOST_SERVICE_VERSION` exactly — this floor + * does not apply. * * 0.4.0: terminal launch moved from `terminal.ensureSession` to * `terminal.launchSession` plus WebSocket attach params. @@ -13,17 +27,12 @@ * * 0.5.0 — pty-daemon supervision migrated into host-service. New * `terminal.daemon` tRPC namespace; older 0.4.x host-services don't - * expose it. Adopting one in place would leave the new desktop - * talking to old code: Settings → Manage daemon would silently - * fail, and the v2 PTY survival promise is broken. + * expose it. * * 0.7.0 — canonical `workspaces.create` flow + `settings.hostAgentConfigs` - * router (PR1, #3893). Older 0.6.x host-services don't expose either, - * so adopting one in place would break new-project creation and the - * agent-config settings UI. + * router (PR1, #3893). Older 0.6.x host-services don't expose either. * * 0.8.0 — v2 terminal creation moved to `terminal.createSession`; the - * WebSocket route is attach-only by `terminalId`. Older host-services would - * reject the renderer's creation call and still expect socket-side startup. + * WebSocket route is attach-only by `terminalId`. */ export const MIN_HOST_SERVICE_VERSION = "0.8.0"; From 6e2fea82bef8e901048d7f09dbc863bf0ec39bad Mon Sep 17 00:00:00 2001 From: Kiet Ho Date: Thu, 7 May 2026 19:21:00 -0700 Subject: [PATCH 4/8] docs(desktop): align done/ plan step 1 with what shipped The plan said BUNDLED_HOST_SERVICE_VERSION would be injected at build time from packages/host-service/package.json (mirroring EXPECTED_DAEMON_ VERSION). What actually shipped is a regular compile-time import of HOST_SERVICE_VERSION from packages/shared/src/host-version.ts. As a done/ plan kept as a permanent record, the contradiction was misleading. Also note why the auto-derive-from-package.json approach was deferred: host-service's package.json has historically lagged the runtime-reported version (host.ts hardcoded "0.8.0" while package.json was at "0.1.0"), so reconciling that is its own change. --- .../plans/done/20260507-host-service-bundled-version-pin.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/desktop/plans/done/20260507-host-service-bundled-version-pin.md b/apps/desktop/plans/done/20260507-host-service-bundled-version-pin.md index 4fe7e357ad3..cbcd5ea671b 100644 --- a/apps/desktop/plans/done/20260507-host-service-bundled-version-pin.md +++ b/apps/desktop/plans/done/20260507-host-service-bundled-version-pin.md @@ -20,7 +20,7 @@ Real PTY state stays in the daemon across the swap. Replace the floor check with an equality check against the host-service bundled into this Electron build. Reuse the existing kill+respawn path. -1. Inject `BUNDLED_HOST_SERVICE_VERSION` into the desktop main bundle at build time (read `packages/host-service/package.json` version), mirroring the `EXPECTED_DAEMON_VERSION` pattern at `packages/host-service/src/daemon/expected-version.ts`. +1. Promote the host-service version to `HOST_SERVICE_VERSION` in `packages/shared/src/host-version.ts` as the single source of truth. Both `host.info` (runtime, returned to the desktop on adoption probe) and the desktop coordinator (compile-time import as `BUNDLED_HOST_SERVICE_VERSION`) read from this constant — so the value the desktop checks against is the same value the bundled host-service reports. Drift between this constant and `packages/host-service/package.json#version` is a manual concern; the auto-derive-from-package.json approach (mirroring `EXPECTED_DAEMON_VERSION` at `packages/host-service/src/daemon/expected-version.ts`) was deferred because host-service's package.json has historically lagged the runtime-reported version (`packages/host-service/src/trpc/router/host/host.ts` previously hardcoded "0.8.0" while `package.json` was still at "0.1.0"); reconciling that is its own change. 2. In `apps/desktop/src/main/lib/host-service-coordinator.ts:289-308`, replace ```ts !semver.satisfies(version, `>=${MIN_HOST_SERVICE_VERSION}`) @@ -29,7 +29,7 @@ Replace the floor check with an equality check against the host-service bundled ```ts version !== BUNDLED_HOST_SERVICE_VERSION ``` - The Electron build is the source of truth for which host-service runs against it. Any drift — older or newer — gets killed and respawned from the bundled binary. This keeps the cloud/desktop deploy contract tight (only one host-service version is ever live alongside a given desktop) and avoids the "hand-rolled newer daemon sticks around indefinitely" failure mode. + The Electron build is the source of truth for which host-service runs against it. Any drift — older or newer — gets killed and respawned from the bundled binary. This keeps the cloud/desktop deploy contract tight (only one host-service version is ever live alongside a given desktop) and avoids the "hand-rolled newer host-service sticks around indefinitely" failure mode. 3. Keep `MIN_HOST_SERVICE_VERSION` only for the renderer-side **remote** host gate at `apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/hooks/useRemoteHostStatus/useRemoteHostStatus.ts:91`. We can't kill remote hosts — a floor is still the right shape there. Everything else is unchanged: `detached: true` spawn, manifest re-adoption on next start, crash circuit breaker, daemon lifecycle. From beaa55bc6c766b9212d6361bb856ef2a7a8eec86 Mon Sep 17 00:00:00 2001 From: Kiet Ho Date: Thu, 7 May 2026 19:25:00 -0700 Subject: [PATCH 5/8] refactor(desktop): derive host-service version from package.json MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Switch HOST_SERVICE_VERSION / BUNDLED_HOST_SERVICE_VERSION from a manual constant in packages/shared/src/host-version.ts to a build-time JSON import of packages/host-service/package.json — mirroring the EXPECTED_DAEMON_VERSION pattern at packages/host-service/src/daemon/expected-version.ts. - Bump packages/host-service/package.json version 0.1.0 → 0.8.0 to reconcile pre-existing drift: host.ts had been hard-coding "0.8.0" for several breaking-change ratchets while package.json was never bumped past 0.1.0. With the package.json now load-bearing, every bump kills running host-services on the next launch. - Add `"./package.json": "./package.json"` to host-service's exports so the desktop coordinator can import it (same pattern as pty-daemon). - Drop HOST_SERVICE_VERSION export from packages/shared; keep MIN_HOST_SERVICE_VERSION (still load-bearing for the remote-host renderer gate in useRemoteHostStatus). Addresses CodeRabbit review on PR #4218. --- ...20260507-host-service-bundled-version-pin.md | 9 ++++++--- .../src/main/lib/host-service-coordinator.ts | 8 +++++++- packages/host-service/package.json | 5 +++-- .../host-service/src/trpc/router/host/host.ts | 9 ++++++++- packages/shared/src/host-version.ts | 17 ++--------------- 5 files changed, 26 insertions(+), 22 deletions(-) diff --git a/apps/desktop/plans/done/20260507-host-service-bundled-version-pin.md b/apps/desktop/plans/done/20260507-host-service-bundled-version-pin.md index cbcd5ea671b..e4b41bfab78 100644 --- a/apps/desktop/plans/done/20260507-host-service-bundled-version-pin.md +++ b/apps/desktop/plans/done/20260507-host-service-bundled-version-pin.md @@ -20,7 +20,9 @@ Real PTY state stays in the daemon across the swap. Replace the floor check with an equality check against the host-service bundled into this Electron build. Reuse the existing kill+respawn path. -1. Promote the host-service version to `HOST_SERVICE_VERSION` in `packages/shared/src/host-version.ts` as the single source of truth. Both `host.info` (runtime, returned to the desktop on adoption probe) and the desktop coordinator (compile-time import as `BUNDLED_HOST_SERVICE_VERSION`) read from this constant — so the value the desktop checks against is the same value the bundled host-service reports. Drift between this constant and `packages/host-service/package.json#version` is a manual concern; the auto-derive-from-package.json approach (mirroring `EXPECTED_DAEMON_VERSION` at `packages/host-service/src/daemon/expected-version.ts`) was deferred because host-service's package.json has historically lagged the runtime-reported version (`packages/host-service/src/trpc/router/host/host.ts` previously hardcoded "0.8.0" while `package.json` was still at "0.1.0"); reconciling that is its own change. +1. `packages/host-service/package.json#version` is the single source of truth, mirroring the `EXPECTED_DAEMON_VERSION` pattern at `packages/host-service/src/daemon/expected-version.ts`. Both `host.info` (runtime, returned to the desktop on adoption probe) and the desktop coordinator import the package.json directly with `with { type: "json" }` and read `.version`. Bumping `packages/host-service/package.json` automatically bumps both — no shared constant to keep in sync. + + To enable the cross-package import on the desktop side, `packages/host-service/package.json` adds `"./package.json": "./package.json"` to its `exports` map (same as `packages/pty-daemon/package.json`). The package.json version is also bumped from `0.1.0` to `0.8.0` to reconcile a pre-existing drift: `host.info` had been hard-coding `"0.8.0"` for several breaking-change ratchets while `package.json` was never bumped past `0.1.0`. After this PR, `package.json` becomes load-bearing — bumping it kills every running host-service on the next launch. 2. In `apps/desktop/src/main/lib/host-service-coordinator.ts:289-308`, replace ```ts !semver.satisfies(version, `>=${MIN_HOST_SERVICE_VERSION}`) @@ -54,8 +56,9 @@ Everything else is unchanged: `detached: true` spawn, manifest re-adoption on ne ## Outcomes & Retrospective **Shipped:** -- Promoted the host-service version constant to `HOST_SERVICE_VERSION` in `packages/shared/src/host-version.ts` (single source of truth). `host.info` now reads from there, replacing the previously hardcoded `HOST_SERVICE_VERSION = "0.8.0"` in `packages/host-service/src/trpc/router/host/host.ts`. -- Desktop coordinator (`apps/desktop/src/main/lib/host-service-coordinator.ts`) imports it as `BUNDLED_HOST_SERVICE_VERSION`. Adoption now requires strict equality — `version !== BUNDLED_HOST_SERVICE_VERSION` triggers kill + respawn. +- `packages/host-service/package.json#version` is now the single source of truth. Bumped `0.1.0` → `0.8.0` to reconcile with the previously hard-coded value, and added `"./package.json": "./package.json"` to its `exports` map so consumers can import it (same pattern as `packages/pty-daemon/package.json`). +- `host.info` (`packages/host-service/src/trpc/router/host/host.ts`) imports the package.json with `with { type: "json" }` and returns `pkg.version` — no more hard-coded constant. +- Desktop coordinator (`apps/desktop/src/main/lib/host-service-coordinator.ts`) imports the same package.json as `BUNDLED_HOST_SERVICE_VERSION`. Adoption now requires strict equality — `version !== BUNDLED_HOST_SERVICE_VERSION` triggers kill + respawn. - `MIN_HOST_SERVICE_VERSION` retained for the **remote**-host renderer gate (`useRemoteHostStatus.ts`) where a floor is still the right shape. - `semver` import dropped from the coordinator — no longer needed there. diff --git a/apps/desktop/src/main/lib/host-service-coordinator.ts b/apps/desktop/src/main/lib/host-service-coordinator.ts index d963c42a7a1..51c0d803d27 100644 --- a/apps/desktop/src/main/lib/host-service-coordinator.ts +++ b/apps/desktop/src/main/lib/host-service-coordinator.ts @@ -3,9 +3,11 @@ import { randomBytes } from "node:crypto"; import { EventEmitter } from "node:events"; import * as fs from "node:fs"; import path from "node:path"; +import hostServicePackageJson from "@superset/host-service/package.json" with { + type: "json", +}; import { settings } from "@superset/local-db"; import { getHostId, getHostName } from "@superset/shared/host-info"; -import { HOST_SERVICE_VERSION as BUNDLED_HOST_SERVICE_VERSION } from "@superset/shared/host-version"; import { app } from "electron"; import { env } from "main/env.main"; import { env as sharedEnv } from "shared/env.shared"; @@ -29,6 +31,10 @@ import { import { localDb } from "./local-db"; import { HOOK_PROTOCOL_VERSION } from "./terminal/env"; +// Bundled at compile time — kept in lockstep with what `host.info` reports +// because both sides read the same package.json. +const BUNDLED_HOST_SERVICE_VERSION: string = hostServicePackageJson.version; + export type HostServiceStatus = "starting" | "running" | "stopped"; export interface Connection { diff --git a/packages/host-service/package.json b/packages/host-service/package.json index d48953e4b65..9a99dec8bed 100644 --- a/packages/host-service/package.json +++ b/packages/host-service/package.json @@ -1,6 +1,6 @@ { "name": "@superset/host-service", - "version": "0.1.0", + "version": "0.8.0", "private": true, "type": "module", "exports": { @@ -39,7 +39,8 @@ "./attachments": { "types": "./src/trpc/router/attachments/index.ts", "default": "./src/trpc/router/attachments/index.ts" - } + }, + "./package.json": "./package.json" }, "scripts": { "clean": "git clean -xdf .cache .turbo dist node_modules", diff --git a/packages/host-service/src/trpc/router/host/host.ts b/packages/host-service/src/trpc/router/host/host.ts index c6bd124554b..a73ad330854 100644 --- a/packages/host-service/src/trpc/router/host/host.ts +++ b/packages/host-service/src/trpc/router/host/host.ts @@ -1,10 +1,17 @@ import os from "node:os"; +import hostServicePackageJson from "@superset/host-service/package.json" with { + type: "json", +}; import { getHostId, getHostName } from "@superset/shared/host-info"; -import { HOST_SERVICE_VERSION } from "@superset/shared/host-version"; import { TRPCError } from "@trpc/server"; import type { ApiClient } from "../../../types"; import { protectedProcedure, router } from "../../index"; +// Auto-derived from this package's package.json so a host-service version +// bump automatically flows through to `host.info` and the desktop's +// strict-equality adoption check (see host-service-coordinator.tryAdopt). +const HOST_SERVICE_VERSION: string = hostServicePackageJson.version; + const ORGANIZATION_CACHE_TTL_MS = 60 * 60 * 1000; let cachedOrganization: { diff --git a/packages/shared/src/host-version.ts b/packages/shared/src/host-version.ts index d267b59ee1f..0e5f5b0c2c2 100644 --- a/packages/shared/src/host-version.ts +++ b/packages/shared/src/host-version.ts @@ -1,22 +1,9 @@ -/** - * Canonical version of host-service in this checkout. Reported by - * `host.info` at runtime, and bundled into the desktop main process as - * the version the Electron build expects to run against. Bump on every - * host-service change shipping a new build. - * - * The desktop coordinator pins adoption to this exact value: if a running - * host-service reports a different version (older or newer), it is killed - * and respawned from the bundled binary. Pty-daemon — which holds durable - * session state — has its own manifest + supervisor and survives the swap. - */ -export const HOST_SERVICE_VERSION = "0.8.0"; - /** * Minimum host-service version a v2 workspace UI can work with against a * **remote** host whose binary we don't control (gates renderer mounting * via `useRemoteHostStatus`). For the local host-service we bundle, the - * desktop coordinator pins to `HOST_SERVICE_VERSION` exactly — this floor - * does not apply. + * desktop coordinator pins to the bundled version exactly (read from + * `@superset/host-service/package.json`) — this floor does not apply. * * 0.4.0: terminal launch moved from `terminal.ensureSession` to * `terminal.launchSession` plus WebSocket attach params. From 791316c507556cf19f1002e1aca2f8492fba1076 Mon Sep 17 00:00:00 2001 From: Kiet Ho Date: Thu, 7 May 2026 19:37:19 -0700 Subject: [PATCH 6/8] chore(host-service): bump to 0.8.1 to force respawn on next release MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit No code changes — bumping the package.json version so existing installs running host-service@0.8.0 get killed and respawned exactly once on their next desktop launch, exercising the new strict-equality adoption path end-to-end. Pty-daemon survives the swap, so terminal sessions are preserved. --- packages/host-service/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/host-service/package.json b/packages/host-service/package.json index 9a99dec8bed..fd032f2ff1d 100644 --- a/packages/host-service/package.json +++ b/packages/host-service/package.json @@ -1,6 +1,6 @@ { "name": "@superset/host-service", - "version": "0.8.0", + "version": "0.8.1", "private": true, "type": "module", "exports": { From bb094c6fa2f4f8977142b0bccbb095b818e0f00b Mon Sep 17 00:00:00 2001 From: Kiet Ho Date: Thu, 7 May 2026 19:58:48 -0700 Subject: [PATCH 7/8] feat(desktop): respawn host-service on every Electron auto-update MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add an app-version pin to the host-service manifest as belt + suspenders alongside the existing host-service version pin. Either mismatch triggers kill + respawn on the next Electron startup, so an auto-updated desktop always lands on a freshly spawned host-service — even if a host-service code change shipped without bumping its package.json version. - HostServiceManifest gains `spawnedByAppVersion`; child writes it from a new SUPERSET_APP_VERSION env var that the coordinator sets from `app.getVersion()` when spawning. - tryAdopt in the coordinator checks `manifest.spawnedByAppVersion === app.getVersion()` before the existing host-service version check. - Pre-existing manifests without the field are coerced to empty string by readManifest so we still find the old PID and kill it on first launch after upgrade — instead of orphaning it. Pty-daemon still has its own manifest + supervisor and is unaffected. --- ...20260507-host-service-bundled-version-pin.md | 3 ++- apps/desktop/src/main/host-service/env.ts | 1 + apps/desktop/src/main/host-service/index.ts | 1 + .../src/main/lib/host-service-coordinator.ts | 16 ++++++++++++++++ .../src/main/lib/host-service-manifest.ts | 17 +++++++++++++++++ bun.lock | 2 +- 6 files changed, 38 insertions(+), 2 deletions(-) diff --git a/apps/desktop/plans/done/20260507-host-service-bundled-version-pin.md b/apps/desktop/plans/done/20260507-host-service-bundled-version-pin.md index e4b41bfab78..40025c69554 100644 --- a/apps/desktop/plans/done/20260507-host-service-bundled-version-pin.md +++ b/apps/desktop/plans/done/20260507-host-service-bundled-version-pin.md @@ -56,9 +56,10 @@ Everything else is unchanged: `detached: true` spawn, manifest re-adoption on ne ## Outcomes & Retrospective **Shipped:** -- `packages/host-service/package.json#version` is now the single source of truth. Bumped `0.1.0` → `0.8.0` to reconcile with the previously hard-coded value, and added `"./package.json": "./package.json"` to its `exports` map so consumers can import it (same pattern as `packages/pty-daemon/package.json`). +- `packages/host-service/package.json#version` is now the single source of truth. Bumped `0.1.0` → `0.8.0` to reconcile with the previously hard-coded value, then `0.8.0` → `0.8.1` so existing installs respawn once on next launch. Added `"./package.json": "./package.json"` to its `exports` map so consumers can import it (same pattern as `packages/pty-daemon/package.json`). - `host.info` (`packages/host-service/src/trpc/router/host/host.ts`) imports the package.json with `with { type: "json" }` and returns `pkg.version` — no more hard-coded constant. - Desktop coordinator (`apps/desktop/src/main/lib/host-service-coordinator.ts`) imports the same package.json as `BUNDLED_HOST_SERVICE_VERSION`. Adoption now requires strict equality — `version !== BUNDLED_HOST_SERVICE_VERSION` triggers kill + respawn. +- **App-version pin (belt + suspenders).** Added `spawnedByAppVersion` to the host-service manifest; the child writes it from `SUPERSET_APP_VERSION` (passed by the coordinator from `app.getVersion()`). On adoption the coordinator additionally requires `manifest.spawnedByAppVersion === app.getVersion()`. Either pin mismatching triggers respawn — so every desktop auto-update guarantees a fresh host-service even if someone forgets to bump the host-service version on a host-service code change. Pre-existing manifests without the field are coerced to empty string and naturally trip the pin on first launch. - `MIN_HOST_SERVICE_VERSION` retained for the **remote**-host renderer gate (`useRemoteHostStatus.ts`) where a floor is still the right shape. - `semver` import dropped from the coordinator — no longer needed there. diff --git a/apps/desktop/src/main/host-service/env.ts b/apps/desktop/src/main/host-service/env.ts index 7641208ca13..51c45864b93 100644 --- a/apps/desktop/src/main/host-service/env.ts +++ b/apps/desktop/src/main/host-service/env.ts @@ -12,6 +12,7 @@ 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 e67d14bbec3..d7443cfcbbd 100644 --- a/apps/desktop/src/main/host-service/index.ts +++ b/apps/desktop/src/main/host-service/index.ts @@ -74,6 +74,7 @@ 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); diff --git a/apps/desktop/src/main/lib/host-service-coordinator.ts b/apps/desktop/src/main/lib/host-service-coordinator.ts index 51c0d803d27..b30a62e70a2 100644 --- a/apps/desktop/src/main/lib/host-service-coordinator.ts +++ b/apps/desktop/src/main/lib/host-service-coordinator.ts @@ -291,6 +291,21 @@ export class HostServiceCoordinator extends EventEmitter { const url = new URL(manifest.endpoint); const port = Number(url.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)"; + console.log( + `[host-service:${organizationId}] Adopted service ${reason}, killing`, + ); + try { + process.kill(manifest.pid, "SIGTERM"); + } catch {} + removeManifest(organizationId); + return null; + } + const version = await this.fetchHostVersion( manifest.endpoint, manifest.authToken, @@ -494,6 +509,7 @@ 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, }); diff --git a/apps/desktop/src/main/lib/host-service-manifest.ts b/apps/desktop/src/main/lib/host-service-manifest.ts index 57b55fda736..8e1d61901fc 100644 --- a/apps/desktop/src/main/lib/host-service-manifest.ts +++ b/apps/desktop/src/main/lib/host-service-manifest.ts @@ -15,6 +15,15 @@ export interface HostServiceManifest { authToken: string; startedAt: number; organizationId: string; + /** + * Desktop app version that spawned this host-service. Compared against + * the current `app.getVersion()` on adoption — any mismatch triggers a + * kill + respawn so every Electron auto-update lands on a freshly + * spawned host-service, even when the host-service version pin alone + * would have allowed adoption (e.g. host-service code changed but its + * `package.json#version` was not bumped). + */ + spawnedByAppVersion: string; } export function manifestDir(organizationId: string): string { @@ -60,6 +69,14 @@ 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` still finds the old PID, then trip the app-version pin + // (current version !== "") so the stale daemon gets killed and respawned. + if (typeof data.spawnedByAppVersion !== "string") { + data.spawnedByAppVersion = ""; + } + return data as HostServiceManifest; } catch { return null; diff --git a/bun.lock b/bun.lock index c17af3d7927..a6941f5fcba 100644 --- a/bun.lock +++ b/bun.lock @@ -775,7 +775,7 @@ }, "packages/host-service": { "name": "@superset/host-service", - "version": "0.1.0", + "version": "0.8.1", "dependencies": { "@hono/node-server": "^1.14.1", "@hono/node-ws": "^1.3.0", From 7039c5220407418e396b7262895b929925e0833d Mon Sep 17 00:00:00 2001 From: Kiet Ho Date: Thu, 7 May 2026 20:03:01 -0700 Subject: [PATCH 8/8] refactor(desktop): drop redundant host-service version pin MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The app-version pin (manifest.spawnedByAppVersion vs app.getVersion()) strictly subsumes the host-service version pin for the auto-update use case: host-service ships with the desktop, so a host-service version change always entails an app-version change. The host-service version pin only added value in dev scenarios with hand-rolled binaries — not load-bearing for the actual goal. Removes: - BUNDLED_HOST_SERVICE_VERSION constant + package.json import in the coordinator. - The version-fetch round-trip (fetchHostVersion + host.info probe) on every adoption attempt. Keeps: - App-version pin in tryAdopt. - host.info still returns version from package.json (used by the renderer-side remote-host gate via MIN_HOST_SERVICE_VERSION). - packages/host-service/package.json version bump and exports update. --- ...260507-host-service-bundled-version-pin.md | 14 ++++-- .../src/main/lib/host-service-coordinator.ts | 46 ------------------- 2 files changed, 9 insertions(+), 51 deletions(-) diff --git a/apps/desktop/plans/done/20260507-host-service-bundled-version-pin.md b/apps/desktop/plans/done/20260507-host-service-bundled-version-pin.md index 40025c69554..9b3ec85d9cb 100644 --- a/apps/desktop/plans/done/20260507-host-service-bundled-version-pin.md +++ b/apps/desktop/plans/done/20260507-host-service-bundled-version-pin.md @@ -55,13 +55,17 @@ Everything else is unchanged: `detached: true` spawn, manifest re-adoption on ne ## Outcomes & Retrospective +The plan above describes a host-service version pin (steps 2–3). That approach was implemented and then **simplified out** — see the retrospective below for what actually shipped. + **Shipped:** -- `packages/host-service/package.json#version` is now the single source of truth. Bumped `0.1.0` → `0.8.0` to reconcile with the previously hard-coded value, then `0.8.0` → `0.8.1` so existing installs respawn once on next launch. Added `"./package.json": "./package.json"` to its `exports` map so consumers can import it (same pattern as `packages/pty-daemon/package.json`). -- `host.info` (`packages/host-service/src/trpc/router/host/host.ts`) imports the package.json with `with { type: "json" }` and returns `pkg.version` — no more hard-coded constant. -- Desktop coordinator (`apps/desktop/src/main/lib/host-service-coordinator.ts`) imports the same package.json as `BUNDLED_HOST_SERVICE_VERSION`. Adoption now requires strict equality — `version !== BUNDLED_HOST_SERVICE_VERSION` triggers kill + respawn. -- **App-version pin (belt + suspenders).** Added `spawnedByAppVersion` to the host-service manifest; the child writes it from `SUPERSET_APP_VERSION` (passed by the coordinator from `app.getVersion()`). On adoption the coordinator additionally requires `manifest.spawnedByAppVersion === app.getVersion()`. Either pin mismatching triggers respawn — so every desktop auto-update guarantees a fresh host-service even if someone forgets to bump the host-service version on a host-service code change. Pre-existing manifests without the field are coerced to empty string and naturally trip the pin on first launch. -- `MIN_HOST_SERVICE_VERSION` retained for the **remote**-host renderer gate (`useRemoteHostStatus.ts`) where a floor is still the right shape. +- **App-version pin in `tryAdopt`.** The host-service manifest gains `spawnedByAppVersion`; the child writes it from `SUPERSET_APP_VERSION` (passed by the coordinator from `app.getVersion()`). On adoption the coordinator requires `manifest.spawnedByAppVersion === app.getVersion()` — any mismatch kills the manifest PID and falls through to `spawn()` from the bundled binary. Pre-existing manifests without the field are coerced to empty string by `readManifest` so the old PID is still found and killed (no orphan). +- **Why app-version, not host-service version.** Host-service ships with the desktop, so `app.getVersion()` strictly subsumes the host-service version pin for the auto-update use case. Bumping the host-service version on a host-service code change is no longer load-bearing for adoption. +- **Host-service version still flows through `host.info`** (`packages/host-service/src/trpc/router/host/host.ts`), now derived from `packages/host-service/package.json` via a `with { type: "json" }` import — kept for telemetry/debugging and because the renderer-side **remote**-host gate (`useRemoteHostStatus`) still compares it against `MIN_HOST_SERVICE_VERSION`. +- `packages/host-service/package.json` was bumped `0.1.0` → `0.8.0` (reconciles the historical drift where `host.info` hard-coded `"0.8.0"` while `package.json` lagged) and then `0.8.0` → `0.8.1`. Added `"./package.json": "./package.json"` to its `exports` so the import works cross-package. - `semver` import dropped from the coordinator — no longer needed there. +**Tried and removed:** +- A `BUNDLED_HOST_SERVICE_VERSION` pin in `tryAdopt` (compile-time read of `@superset/host-service/package.json#version`, strict-equality check against `host.info.version`). Implemented, but redundant once the app-version pin landed: in our build flow host-service ships with the desktop, so the app-version pin catches every case the host-service version pin caught — plus the case where someone forgets to bump the host-service version on a host-service code change. Removed to avoid carrying a second concept and a per-adoption HTTP probe that no longer mattered. + **Deferred:** - Drain-before-SIGTERM. The hard kill is unchanged; revisit if respawn cadence becomes user-visible. diff --git a/apps/desktop/src/main/lib/host-service-coordinator.ts b/apps/desktop/src/main/lib/host-service-coordinator.ts index b30a62e70a2..e32c75759f5 100644 --- a/apps/desktop/src/main/lib/host-service-coordinator.ts +++ b/apps/desktop/src/main/lib/host-service-coordinator.ts @@ -3,9 +3,6 @@ import { randomBytes } from "node:crypto"; import { EventEmitter } from "node:events"; import * as fs from "node:fs"; import path from "node:path"; -import hostServicePackageJson from "@superset/host-service/package.json" with { - type: "json", -}; import { settings } from "@superset/local-db"; import { getHostId, getHostName } from "@superset/shared/host-info"; import { app } from "electron"; @@ -31,10 +28,6 @@ import { import { localDb } from "./local-db"; import { HOOK_PROTOCOL_VERSION } from "./terminal/env"; -// Bundled at compile time — kept in lockstep with what `host.info` reports -// because both sides read the same package.json. -const BUNDLED_HOST_SERVICE_VERSION: string = hostServicePackageJson.version; - export type HostServiceStatus = "starting" | "running" | "stopped"; export interface Connection { @@ -306,24 +299,6 @@ export class HostServiceCoordinator extends EventEmitter { return null; } - const version = await this.fetchHostVersion( - manifest.endpoint, - manifest.authToken, - ); - if (!version || version !== BUNDLED_HOST_SERVICE_VERSION) { - const reason = version - ? `version ${version} != bundled ${BUNDLED_HOST_SERVICE_VERSION}` - : "version unknown"; - console.log( - `[host-service:${organizationId}] Adopted service ${reason}, killing`, - ); - try { - process.kill(manifest.pid, "SIGTERM"); - } catch {} - removeManifest(organizationId); - return null; - } - this.instances.set(organizationId, { pid: manifest.pid, port, @@ -339,27 +314,6 @@ export class HostServiceCoordinator extends EventEmitter { return { port, secret: manifest.authToken, machineId: this.machineId }; } - private async fetchHostVersion( - endpoint: string, - secret: string, - ): Promise { - try { - const controller = new AbortController(); - const timeout = setTimeout(() => controller.abort(), 3_000); - const response = await fetch(`${endpoint}/trpc/host.info`, { - signal: controller.signal, - headers: { Authorization: `Bearer ${secret}` }, - }); - clearTimeout(timeout); - if (!response.ok) return null; - const data = await response.json(); - const result = data?.result?.data; - return result?.json?.version ?? result?.version ?? null; - } catch { - return null; - } - } - private readAndValidateManifest( organizationId: string, ): HostServiceManifest | null {