From d16e2366df3f52d3d849a46a94ae3f42281fa268 Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Thu, 13 Aug 2026 15:06:00 -0700 Subject: [PATCH] fix(desktop): don't dial per-profile sockets for profiles served by the shared global-remote primary (#85665) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Under a global SSH/remote gateway, resolveProfileBackendRoute routes every profile to the shared primary backend (case 3) and getConnection() returns the primary descriptor tagged with the profile. ensureGatewayForProfile still dialed a per-profile secondary socket at that descriptor; over SSH the duplicate dial fails (per-backend tunnel/ticket) and the closed socket became the ACTIVE gateway — every profile except the primary showed 'Hermes gateway is not connected' even though the primary socket was open. Detect the shared-primary route and activate the primary socket instead; $activeGatewayProfile still tracks the selected profile so per-request ?profile= scoping is unchanged. Hover pre-warm no-ops on this route. Local pooled profiles and per-profile remote overrides are untouched (pinned by test). --- .../src/store/gateway-shared-remote.test.ts | 78 +++++++++++++++++++ apps/desktop/src/store/gateway.ts | 39 ++++++++++ 2 files changed, 117 insertions(+) create mode 100644 apps/desktop/src/store/gateway-shared-remote.test.ts diff --git a/apps/desktop/src/store/gateway-shared-remote.test.ts b/apps/desktop/src/store/gateway-shared-remote.test.ts new file mode 100644 index 0000000000..a16fe376cd --- /dev/null +++ b/apps/desktop/src/store/gateway-shared-remote.test.ts @@ -0,0 +1,78 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +// The global-remote share (backend routing case 3): every profile is served +// by the PRIMARY backend over one host, and getConnection() tags the shared +// descriptor with `profile`. Dialing a second WebSocket at that descriptor +// used to fail over SSH (per-backend tunnel/ticket) and poison the active +// gateway with a closed socket — "Hermes gateway is not connected" for every +// profile except the primary. These tests pin the fix: a profile routed to +// the shared primary activates the primary socket instead of dialing. + +vi.mock('@/hermes', () => ({ + HermesGateway: class { + connectionState = 'closed' + connect = vi.fn(async () => { + throw new Error('dialed a socket for a shared-primary profile') + }) + onEvent = vi.fn(() => () => {}) + onState = vi.fn(() => () => {}) + } +})) +vi.mock('@/store/session', () => ({ setGatewayState: vi.fn() })) +vi.mock('@/store/notify-baseline', () => ({ markNativeNotifyBaseline: vi.fn() })) + +const { $gateway, configureGatewayRegistry, ensureGatewayForProfile, setPrimaryGateway } = await import('./gateway') + +type DesktopStub = { getConnection: ReturnType } + +function installDesktop(stub: DesktopStub): void { + ;(window as unknown as { hermesDesktop: unknown }).hermesDesktop = stub +} + +function makePrimary(): { connectionState: string } { + // Only connectionState is consulted by setActive/isOpen for these paths. + return { connectionState: 'open' } +} + +beforeEach(() => { + configureGatewayRegistry({ + onEvent: vi.fn(), + primaryProfile: 'default' + } as never) +}) + +afterEach(() => { + vi.clearAllMocks() + delete (window as unknown as { hermesDesktop?: unknown }).hermesDesktop +}) + +describe('ensureGatewayForProfile under a shared global remote', () => { + it('activates the primary socket for a profile tagged onto the shared descriptor', async () => { + const primary = makePrimary() + setPrimaryGateway(primary as never, 'default') + installDesktop({ + // Shared descriptor: primary connection tagged with the profile. + getConnection: vi.fn(async () => ({ port: 4242, profile: 'venture', token: 't' })) + }) + + await ensureGatewayForProfile('venture') + + expect($gateway.get()).toBe(primary) + }) + + it('still pools a socket for profiles with their own descriptor (untagged)', async () => { + const primary = makePrimary() + setPrimaryGateway(primary as never, 'default') + installDesktop({ + // Own descriptor: no profile tag → normal pooled path (dial attempted). + getConnection: vi.fn(async () => ({ port: 5151, token: 't2' })) + }) + + await ensureGatewayForProfile('worker') + + // The pooled path dialed (our stub throws, so the socket stays closed and + // reconnect is scheduled) — the important part is it did NOT silently + // reuse the primary. + expect($gateway.get()).not.toBe(primary) + }) +}) diff --git a/apps/desktop/src/store/gateway.ts b/apps/desktop/src/store/gateway.ts index 149235e42f..0a64b3da1f 100644 --- a/apps/desktop/src/store/gateway.ts +++ b/apps/desktop/src/store/gateway.ts @@ -247,6 +247,29 @@ function createSecondary(profile: string): Secondary { return entry } +// True when `profile`'s backend route resolves to the SHARED primary backend +// (global-remote case 3 in resolveProfileBackendRoute): the descriptor comes +// back as the primary connection tagged with `profile`. Own-remote-override +// and local pooled descriptors are never tagged. Dialing a second socket at +// that descriptor is wrong — over SSH the second dial fails (tunnel/token are +// per-backend) and the closed socket poisons the active gateway with +// "not connected" even though the primary is open right next to it. +async function sharedPrimaryRoute(profile: string): Promise { + const desktop = window.hermesDesktop + + if (!desktop) { + return false + } + + try { + const conn = await desktop.getConnection(profile) + + return Boolean(conn && typeof conn === 'object' && (conn as { profile?: string }).profile) + } catch { + return false + } +} + // Open `profile`'s socket WITHOUT making it active — the hover-intent pre-warm // (store/profile). Runs the same spawn + connect chain as a real switch, so by // click time ensureGatewayForProfile finds an open socket and just activates @@ -260,6 +283,11 @@ export async function openGatewayForProfile(profile: string): Promise { return } + if (await sharedPrimaryRoute(key)) { + // Served by the primary backend — there is no per-profile socket to warm. + return + } + const entry = g.secondaries.get(key) ?? createSecondary(key) entry.wantOpen = true @@ -279,6 +307,17 @@ export async function ensureGatewayForProfile(profile: string): Promise { return } + // Global-remote share (routing case 3): one remote host serves every + // profile through the PRIMARY socket, scoped per request. Activate the + // primary instead of dialing a doomed duplicate socket at the same + // descriptor — $activeGatewayProfile still moves to `key`, so request + // scoping and profile-aware surfaces behave identically. + if (await sharedPrimaryRoute(key)) { + setActive(g.primaryProfile) + + return + } + let entry = g.secondaries.get(key) if (!entry) {