fix(desktop): don't dial per-profile sockets for profiles served by the shared global-remote primary (#85665)
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).
This commit is contained in:
@@ -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<typeof vi.fn> }
|
||||
|
||||
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)
|
||||
})
|
||||
})
|
||||
@@ -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<boolean> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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) {
|
||||
|
||||
Reference in New Issue
Block a user