From 30f3d00c1417382fe7864ee3d4fc8def6f993f25 Mon Sep 17 00:00:00 2001 From: fangliquanflq Date: Thu, 30 Jul 2026 10:20:33 +0800 Subject: [PATCH] fix(apps): dial primary sleep/wake reconnect at window backend not active profile --- .../gateway/hooks/use-gateway-boot.test.tsx | 83 ++++++++++++++++--- .../src/app/gateway/hooks/use-gateway-boot.ts | 15 +++- 2 files changed, 85 insertions(+), 13 deletions(-) diff --git a/apps/desktop/src/app/gateway/hooks/use-gateway-boot.test.tsx b/apps/desktop/src/app/gateway/hooks/use-gateway-boot.test.tsx index a86446d3a168..925147cdac44 100644 --- a/apps/desktop/src/app/gateway/hooks/use-gateway-boot.test.tsx +++ b/apps/desktop/src/app/gateway/hooks/use-gateway-boot.test.tsx @@ -2,7 +2,9 @@ import { act, cleanup, render } from '@testing-library/react' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { $desktopBoot } from '@/store/boot' -import { $currentCwd, $gatewayState } from '@/store/session' +import { closeSecondaryGateways, isActivePrimary } from '@/store/gateway' +import { $activeGatewayProfile, ensureGatewayProfile } from '@/store/profile' +import { $connection, $currentCwd, $gatewayState } from '@/store/session' import { takeGatewaySurvivor } from './gateway-hmr-survivor' import { useGatewayBoot } from './use-gateway-boot' @@ -76,18 +78,30 @@ class FakeWebSocket { } } -function fakeDesktop() { - const conn = { - authMode: 'token' as const, - baseUrl: 'https://vps.example.com', - profile: 'default', - token: 't', - wsUrl: 'wss://vps.example.com/api/ws?token=t' - } +const primaryConn = { + authMode: 'token' as const, + baseUrl: 'https://vps.example.com', + profile: 'default', + token: 't', + wsUrl: 'wss://vps.example.com/api/ws?token=t' +} +const coderConn = { + authMode: 'token' as const, + baseUrl: 'https://coder.example.com', + profile: 'coder', + token: 'c', + wsUrl: 'wss://coder.example.com/api/ws?token=c' +} + +function fakeDesktop() { return { - getConnection: vi.fn(async () => conn), - getGatewayWsUrl: vi.fn(async () => conn.wsUrl), + getConnection: vi.fn(async (profile?: null | string) => { + const key = (profile ?? '').trim() + + return !key || key === 'default' ? primaryConn : coderConn + }), + getGatewayWsUrl: vi.fn(async (conn?: { wsUrl?: string }) => conn?.wsUrl ?? primaryConn.wsUrl), getBootProgress: vi.fn(async () => ({ error: null, fakeMode: false, @@ -143,6 +157,9 @@ beforeEach(() => { } } + closeSecondaryGateways() + $activeGatewayProfile.set('default') + $connection.set(null) vi.useFakeTimers() FakeWebSocket.mode = 'open' FakeWebSocket.instances = [] @@ -177,6 +194,9 @@ afterEach(() => { } } + closeSecondaryGateways() + $activeGatewayProfile.set('default') + $connection.set(null) vi.useRealTimers() ;(globalThis as { WebSocket: unknown }).WebSocket = originalWebSocket delete (window as { hermesDesktop?: unknown }).hermesDesktop @@ -383,4 +403,45 @@ describe('useGatewayBoot remote reconnect loop (real hook, fake socket)', () => expect(cwdAtConnect).toBe('C:\\Hermes') expect($currentCwd.get()).toBe('C:\\Hermes') }) + + it('FIX: primary sleep/wake reconnect dials the window backend, not the active secondary profile', async () => { + const desktop = fakeDesktop() + ;(window as { hermesDesktop?: unknown }).hermesDesktop = desktop + + render() + await flushAsync() + expect($gatewayState.get()).toBe('open') + expect(FakeWebSocket.instances).toHaveLength(1) + expect(FakeWebSocket.instances[0].url).toBe(primaryConn.wsUrl) + + // Profile swap opens a secondary WS; briefly use real timers so that + // handshake isn't wedged behind the suite's fake clock. + vi.useRealTimers() + await ensureGatewayProfile('coder') + vi.useFakeTimers() + + expect(isActivePrimary()).toBe(false) + expect($activeGatewayProfile.get()).toBe('coder') + expect($connection.get()?.profile).toBe('coder') + expect($connection.get()?.baseUrl).toBe(coderConn.baseUrl) + + const callsBeforeDrop = desktop.getConnection.mock.calls.length + const socketsBeforeDrop = FakeWebSocket.instances.length + const primarySocket = FakeWebSocket.instances[0] + + act(() => primarySocket.drop()) + await flushAsync() + await advanceBackoff() + + const reconnectCalls = desktop.getConnection.mock.calls.slice(callsBeforeDrop) + expect(reconnectCalls.some(args => (args[0] ?? '').trim() === 'coder')).toBe(false) + expect(reconnectCalls.some(args => args.length === 0 || args[0] == null || args[0] === '')).toBe(true) + + const primaryReconnectSockets = FakeWebSocket.instances + .slice(socketsBeforeDrop) + .filter(socket => socket.url === primaryConn.wsUrl) + expect(primaryReconnectSockets.length).toBeGreaterThan(0) + expect($connection.get()?.profile).toBe('coder') + expect($connection.get()?.baseUrl).toBe(coderConn.baseUrl) + }) }) diff --git a/apps/desktop/src/app/gateway/hooks/use-gateway-boot.ts b/apps/desktop/src/app/gateway/hooks/use-gateway-boot.ts index a2dd1565dc7a..3fd2d74c19b1 100644 --- a/apps/desktop/src/app/gateway/hooks/use-gateway-boot.ts +++ b/apps/desktop/src/app/gateway/hooks/use-gateway-boot.ts @@ -19,6 +19,7 @@ import { configureGatewayRegistry, disposeSecondariesForConnection, ensureGatewayForProfile, + isActivePrimary, pruneSecondaryGateways, reconnectSecondaryGateways, reportPrimaryGatewayState, @@ -168,13 +169,23 @@ export function useGatewayBoot({ // "Starting Hermes…". The probe is a no-op for a healthy or local backend. await desktop.revalidateConnection?.().catch(() => undefined) - const conn = await desktop.getConnection($activeGatewayProfile.get()) + // Primary sleep/wake reconnect must dial the WINDOW-owned primary backend + // (same as boot/softSwitch). Passing $activeGatewayProfile would retarget + // this primary socket at a secondary profile's backend after a live swap. + // Secondaries reconnect via reconnectSecondaryGateways(). + const conn = await desktop.getConnection() if (cancelled) { return } - publish(conn) + // Only publish the primary descriptor when the primary is active. + // Otherwise a background-profile view would inherit the primary's + // mode/baseUrl and break image.attach / fs / media routing (#46651). + if (isActivePrimary()) { + publish(conn) + } + // Re-mint the WS URL before reconnecting. OAuth tickets are single-use // with a short TTL, so the ticket baked into the cached conn.wsUrl is // dead on every reconnect after the initial boot — reusing it surfaces