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:
@@ -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
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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))
|
||||
|
||||
Reference in New Issue
Block a user