fix(desktop): session calls for a profile on the shared host backend ride the primary socket

Since #118246 one local `hermes serve` serves every profile, and main marks
those routes `sharedPrimary: true`. `requestGatewayForProfile` honoured the
flag, but the session-owner family (`requestGatewayForAgent`, retain/open/
ensure) went through `isAttachedSharedRemote`, whose `primaryConnectionMode
=== 'local'` early return (#113956, written while every local profile had
its own pooled child) sent every non-default local profile to a registry
secondary: a SECOND WebSocket to the SAME backend process. The backend joins
any socket that sends a session call to the chat's transport fan-out, so the
renderer received every event twice — "HelloHello from from the the mock
mock…" while streaming, duplicate interim bubbles, doubled error cards
(#120005, #119131, #119566, #119540, #118934).

The predicate is now `ridesPrimaryBackend`: it asks main for the route on
every call and reuses the primary socket (with the `profile` param) when the
descriptor carries `sharedRemote` (#96493) OR `sharedPrimary` (#118246).
#101416 stays fixed: when the probe fails on a LOCAL primary we never fall
back to the primary (a pooled profile's chat must not be minted under the
primary's pid), and a pooled descriptor (HERMES_DESKTOP_ISOLATED_BACKEND=1)
still opens its own secondary.

Closes #120006. Part of #120005.
This commit is contained in:
teknium1
2026-09-23 01:37:20 -07:00
committed by Teknium
parent 4a2bd7406e
commit 0bb539b472
4 changed files with 97 additions and 31 deletions

View File

@@ -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

View File

@@ -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<string, unknown>) {
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()
})
})

View File

@@ -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.

View File

@@ -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 <primary>` 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 <primary>` 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<T>(
return requestGatewayForProfile<T>(key, method, params, timeoutMs, signal, { spawnPriority })
}
if (await isAttachedSharedRemote(connectionId, key, spawnPriority)) {
if (await ridesPrimaryBackend(connectionId, key, spawnPriority)) {
return requestOnPrimaryGateway<T>(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))