diff --git a/apps/desktop/electron/connection-config.ts b/apps/desktop/electron/connection-config.ts index ff29395742..468b874d3a 100644 --- a/apps/desktop/electron/connection-config.ts +++ b/apps/desktop/electron/connection-config.ts @@ -764,7 +764,12 @@ export function unscopableMutatingRequest(opts: ProfileRouteOptions = {}): boole * backend, with `?profile=` when the handler reads the query (handlers that * name their target in the path or `body.profile` get no query). * 6. Every other LOCAL profile also shares the one host backend - * (multiplex-only: one `hermes serve` per HOST). The two ways out are + * (multiplex-only: one `hermes serve` per HOST). The descriptor carries + * `sharedPrimary: true`, and the renderer honours it on BOTH request paths + * (`requestGatewayForProfile` and the session-owner + * `requestGatewayForAgent` family): the profile's calls ride the primary + * socket with a `profile` param, never a second socket to the same + * process (#120005). The two ways out are * `HERMES_DESKTOP_ISOLATED_BACKEND=1`, which gives this app a private * backend, and a MUTATING request the server cannot scope at all — that * one keeps a pooled backend whose HERMES_HOME does the scoping, so a diff --git a/apps/desktop/src/store/gateway-profile-request.test.ts b/apps/desktop/src/store/gateway-profile-request.test.ts index 705dbce79a..9b74079167 100644 --- a/apps/desktop/src/store/gateway-profile-request.test.ts +++ b/apps/desktop/src/store/gateway-profile-request.test.ts @@ -755,3 +755,62 @@ describe('attached shared-remote group turns (#96493)', () => { expect(secondaryGateways).toHaveLength(0) }) }) + +describe('session-owner calls for a profile on the shared local host backend (#120005)', () => { + function installLocalHost(descriptorFor: (profile: string) => Record) { + const getConnectionFor = vi.fn(async ({ connectionId, profile }: { connectionId: string; profile: string }) => ({ + connectionId, + mode: 'local', + port: 4242, + token: 't', + ...descriptorFor(profile) + })) + + ;(window as unknown as { hermesDesktop: unknown }).hermesDesktop = { + getConnection: vi.fn(async (profile: null | string) => ({ mode: 'local', port: 4242, profile, token: 't' })), + getConnectionFor, + getGatewayWsUrlFor: vi.fn(async ({ connectionId, profile }: { connectionId: string; profile: string }) => ({ + ok: true as const, + wsUrl: `ws://${connectionId}/${profile}` + })), + touchBackend: vi.fn(async () => undefined) + } + + return getConnectionFor + } + + it('reuses the primary socket when main says the profile rides the host backend (sharedPrimary)', async () => { + // Under multiplex-only (#118246) one local `hermes serve` serves every + // profile. A registry secondary here is a second WebSocket to the SAME + // process: the backend joins it to the chat and the renderer processes + // every event twice (garbled deltas, duplicate interim bubble). + const primary = makePrimary() + setPrimaryGateway(primary as never, 'default') + setPrimaryGatewayConnection({ connectionId: 'local', mode: 'local' }) + installLocalHost(profile => ({ profile, sharedPrimary: true })) + await ensureGatewayForProfile('default') + + const release = await retainGatewayForAgent('local', 'work') + await requestGatewayForAgent('local', 'work', 'session.create', { title: 'g' }) + await requestGatewayForAgent('local', 'work', 'prompt.submit', { session_id: 'rt-1', text: 'hi' }) + release() + + expect(secondaryGateways).toHaveLength(0) + expect(primary.request).toHaveBeenCalledTimes(2) + expect(primary.request).toHaveBeenNthCalledWith(1, 'session.create', { title: 'g', profile: 'work' }) + expect(primary.request).toHaveBeenNthCalledWith(2, 'prompt.submit', { session_id: 'rt-1', text: 'hi', profile: 'work' }) + }) + + it('still dials a secondary for a pooled local profile (isolated backend, #101416)', async () => { + const primary = makePrimary() + setPrimaryGateway(primary as never, 'default') + setPrimaryGatewayConnection({ connectionId: 'local', mode: 'local' }) + installLocalHost(profile => ({ port: 5151, profile })) + await ensureGatewayForProfile('default') + + await requestGatewayForAgent('local', 'work', 'session.create', { title: 'g' }) + + expect(secondaryGateways).toHaveLength(1) + expect(primary.request).not.toHaveBeenCalled() + }) +}) diff --git a/apps/desktop/src/store/gateway-spawn-priority.test.ts b/apps/desktop/src/store/gateway-spawn-priority.test.ts index e1fd131a73..20177eafbc 100644 --- a/apps/desktop/src/store/gateway-spawn-priority.test.ts +++ b/apps/desktop/src/store/gateway-spawn-priority.test.ts @@ -2,7 +2,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' // #102281: a user-initiated open must reach Electron main as a FOREGROUND dial // on its FIRST IPC, not only on the secondary's connect. Every open first -// probes the route (sharedPrimaryRoute / isAttachedSharedRemote) with +// probes the route (sharedPrimaryRoute / ridesPrimaryBackend) with // getConnection / getConnectionFor; if that probe is untagged, main starts the // spawn as a background slot wait and the click waits out the probe's 20s // timeout before anything promotes it. diff --git a/apps/desktop/src/store/gateway.ts b/apps/desktop/src/store/gateway.ts index beeaceeed6..f1b1453d1a 100644 --- a/apps/desktop/src/store/gateway.ts +++ b/apps/desktop/src/store/gateway.ts @@ -427,13 +427,17 @@ function isPrimaryRegistryRoute(connectionId: null | string, profile: string): b ) } -/** True when `connectionId` is the window's already-attached source AND that - * source is a one-host-many-profiles remote (`sharedRemote`). Named member - * profiles on that host must reuse the primary socket — a registry secondary - * dials a second WebSocket at the same Tailscale URL, which accept/closes in - * ~30ms (`messages=1`) and never runs `session.create` (#96493). Isolated - * SSH/pooled backends (`sharedRemote: false`) still get their own secondary. */ -async function isAttachedSharedRemote( +/** True when `connectionId` is the window's already-attached source AND main + * says `profile` rides the backend that source's primary socket is already + * connected to — a one-host-many-profiles remote (`sharedRemote`, #96493) or + * the one local host backend that serves every local profile under + * multiplex-only (`sharedPrimary`, #118246). Either way a registry secondary + * would be a SECOND WebSocket to the SAME process: on a remote it accept/closes + * in ~30ms and never runs `session.create`; on the local host backend it joins + * the chat's transport fan-out and the renderer receives every event twice + * (garbled streaming text + a duplicate interim bubble, #120005). Isolated + * SSH/pooled backends (neither flag) still get their own secondary. */ +async function ridesPrimaryBackend( connectionId: null | string, profile: string, spawnPriority: SpawnPriority = 'background' @@ -454,40 +458,38 @@ async function isAttachedSharedRemote( return false } - // A local primary is one `hermes serve --profile ` child; every other - // local profile has its own pooled child and `sharedRemote` is a remote-only - // answer, so the probe below can only cost the pooled dial a 20 s timeout. - if (g.primaryConnectionMode === 'local') { - return false - } - const desktop = window.hermesDesktop if (!desktop?.getConnectionFor) { return false } + // Resolved per call, never cached: main answers the route per request + // (`resolveProfileBackendRoute` case 6 keeps a pooled backend for + // `HERMES_DESKTOP_ISOLATED_BACKEND=1`), and for a pooled profile this is the + // same dial `openSecondary` makes next, coalesced by main's claim key. try { const conn = await withTimeout( desktop.getConnectionFor({ connectionId: id, profile: key, ...dialPriority(spawnPriority) }), RECONNECT_ATTEMPT_TIMEOUT_MS, - `Timed out resolving shared-remote route for "${key}"` + `Timed out resolving the backend route for "${key}"` ) - return Boolean(conn && typeof conn === 'object' && (conn as { sharedRemote?: boolean }).sharedRemote === true) + const flags = conn && typeof conn === 'object' ? (conn as { sharedPrimary?: boolean; sharedRemote?: boolean }) : null + + return flags?.sharedRemote === true || flags?.sharedPrimary === true } catch { // Probe failed on a remote (or not-yet-classified) primary: a secondary at // this already-attached source is the #96493 ghost WebSocket (accept/close, // messages=1), so prefer the primary until a later probe can prove - // isolation (`sharedRemote: false`). A LOCAL primary never reaches here - // (early return above): it is one `hermes serve --profile ` child - // and every other local profile has its own pooled child. The primary - // would still ACCEPT a `profile`-tagged session.create (profile_home - // multiplexing) and mint the session under its own pid, but the exact-owner - // route then names the pool backend — after a renderer reload or a pool - // respawn the resume dials that backend and is refused SESSION_NOT_OWNED by - // a pid of the same Desktop (#101416). - return true + // isolation (`sharedRemote: false`). A LOCAL primary must NOT get that + // fallback: when main routes the profile to a pooled child (isolated + // backend), the primary would still ACCEPT a `profile`-tagged + // session.create (profile_home multiplexing) and mint the session under + // its own pid, but the exact-owner route names the pool backend — after a + // renderer reload or a pool respawn the resume dials that backend and is + // refused SESSION_NOT_OWNED by a pid of the same Desktop (#101416). + return g.primaryConnectionMode !== 'local' } } @@ -1204,7 +1206,7 @@ export async function requestGatewayForAgent( return requestGatewayForProfile(key, method, params, timeoutMs, signal, { spawnPriority }) } - if (await isAttachedSharedRemote(connectionId, key, spawnPriority)) { + if (await ridesPrimaryBackend(connectionId, key, spawnPriority)) { return requestOnPrimaryGateway(method, { ...params, profile: key }, timeoutMs, signal) } @@ -1422,7 +1424,7 @@ export async function retainGatewayForAgent( return route.release } - if (isPrimaryRegistryRoute(connectionId, key) || (await isAttachedSharedRemote(connectionId, key, spawnPriority))) { + if (isPrimaryRegistryRoute(connectionId, key) || (await ridesPrimaryBackend(connectionId, key, spawnPriority))) { // Primary socket stays open for the window lifetime — no secondary to hold. return () => undefined } @@ -1702,7 +1704,7 @@ export async function openGatewayForAgent( return openGatewayForProfile(profile, { spawnPriority }) } - if (await isAttachedSharedRemote(connectionId, profile, spawnPriority)) { + if (await ridesPrimaryBackend(connectionId, profile, spawnPriority)) { if (!isOpen(g.primaryGateway)) { throw new Error('Hermes gateway unavailable') } @@ -1758,7 +1760,7 @@ export async function ensureGatewayForAgent( const activationEpoch = beginGatewayActivation() - if (await isAttachedSharedRemote(connectionId, profile, 'foreground')) { + if (await ridesPrimaryBackend(connectionId, profile, 'foreground')) { // A retained primary can be open while the foreground still points at a // different source. Reusing its socket must also move the active route. return Boolean(isOpen(g.primaryGateway) && !signal?.aborted && applyActive(g.primaryProfile, activationEpoch))