diff --git a/apps/desktop/electron/backend-dial-claim.test.ts b/apps/desktop/electron/backend-dial-claim.test.ts index 6aa7ce1353893..34b7fc25e852f 100644 --- a/apps/desktop/electron/backend-dial-claim.test.ts +++ b/apps/desktop/electron/backend-dial-claim.test.ts @@ -1,14 +1,290 @@ -import fs from 'node:fs' -import path from 'node:path' -import { fileURLToPath } from 'node:url' - import { describe, expect, it, vi } from 'vitest' -import { BackendDialClaims } from './backend-dial-claim' -import { parseBackendScopeKey } from './connection-registry' +import { createBackendConnectionState } from './backend-connection-state' +import { assertDelegatedLocalDialCurrent, BackendDialClaims, type RegistryBackendDial, resolveRegistryDialOptions } from './backend-dial-claim' +import { backendScopeKey, normalizeRegistry, parseBackendScopeKey } from './connection-registry' + +const registry = () => normalizeRegistry({ + primary: 'gateway', connections: [ + { id: 'local', kind: 'local', label: 'This device' }, + { id: 'gateway', kind: 'remote', label: 'Gateway', url: 'http://127.0.0.1:38951' }, + { id: 'other', kind: 'remote', label: 'Other', url: 'http://127.0.0.1:38952' } + ] +}) + +const options = (globalRemote = true) => ({ globalRemote, profileRemoteOverride: false, primaryProfile: 'default' }) + +function deferred() { + let resolve!: (value: T) => void + let reject!: (error: Error) => void + const promise = new Promise((yes, no) => { resolve = yes; reject = no }) + + return { promise, resolve, reject } +} + +describe('resolved registry dial admission', () => { + for (const first of ['local', 'legacy'] as const) { + it(`keeps configured remote primary and explicit local independent when ${first} starts first`, async () => { + const claims = new BackendDialClaims() + const local = deferred() + const remote = deferred() + + const localDial = vi.fn((route: RegistryBackendDial) => { + expect(route.source.kind).toBe('local') + expect(route.localRoute).toEqual({ delegate: false, poolKey: 'conn:local::default' }) + + return local.promise + }) + + const remoteDial = vi.fn(() => remote.promise) + const startLocal = () => claims.runRegistry(registry(), 'local', 'default', options(), localDial) + const startRemote = () => claims.run(backendScopeKey(null, 'default'), remoteDial) + + const [localResult, remoteResult] = first === 'local' + ? [startLocal(), startRemote()] : (() => { const result = startRemote(); + + return [startLocal(), result] })() + + local.resolve('native-local39609') + remote.resolve('remote-primary38951') + + expect(await localResult).toBe('native-local39609') + expect(await remoteResult).toBe('remote-primary38951') + expect(localDial).toHaveBeenCalledTimes(1) + expect(remoteDial).toHaveBeenCalledTimes(1) + expect(claims.inFlight('default')).toBe(false) + expect(claims.inFlight('conn:local::default')).toBe(false) + }) + } + + it('coalesces identical forced-local targets while the legacy remote stays pending', async () => { + const claims = new BackendDialClaims() + const local = deferred() + const dial = vi.fn(() => local.promise) + const a = claims.runRegistry(registry(), 'local', 'work', options(), dial) + const b = claims.runRegistry(registry(), 'local', ' work ', options(), dial) + expect(a).toBe(b) + expect(dial).toHaveBeenCalledTimes(1) + local.resolve('local-work') + expect(await b).toBe('local-work') + }) + + it('coalesces registry-local with the legacy backend only when the route delegates', async () => { + const claims = new BackendDialClaims() + const ready = deferred() + const legacy = claims.run('default', () => ready.promise) + const extra = vi.fn(() => 'duplicate') + const local = claims.runRegistry(registry(), 'local', null, options(false), extra) + expect(local).toBe(legacy) + expect(extra).not.toHaveBeenCalled() + ready.resolve('same-local') + expect(await local).toBe('same-local') + }) + + it('delegated blank-profile registry requests coalesce with the actual selected primary profile', async () => { + const claims = new BackendDialClaims() + const ready = deferred() + const legacy = claims.run('work', () => ready.promise) + const extra = vi.fn(() => 'duplicate') + const local = claims.runRegistry(registry(), 'local', null, { ...options(false), primaryProfile: 'work' }, extra) + expect(local).toBe(legacy) + expect(extra).not.toHaveBeenCalled() + ready.resolve('same-work') + expect(await local).toBe('same-work') + }) + + it('pins the delegated primary profile before an async dial can observe a later selection', async () => { + const claims = new BackendDialClaims() + const opts = { ...options(false), primaryProfile: 'work' } + const ready = deferred() + + const dial = claims.runRegistry(registry(), 'local', null, opts, async route => { + await ready.promise + + return route.delegatedProfile + }) + + opts.primaryProfile = 'later' + ready.resolve() + expect(await dial).toBe('work') + }) + + it('rejects a late remote configuration instead of retargeting an admitted local delegate', async () => { + const claims = new BackendDialClaims() + const current = options(false) + const ready = deferred() + const transport = vi.fn(() => 'retargeted') + + const old = claims.runRegistry(registry(), 'local', 'default', current, async route => { + await ready.promise + assertDelegatedLocalDialCurrent(route, current) + + return transport() + }) + + current.globalRemote = true + const rejection = expect(old).rejects.toThrow('superseded by a remote route') + ready.resolve() + await rejection + expect(transport).not.toHaveBeenCalled() + expect(claims.inFlight('default')).toBe(false) + expect(await claims.runRegistry(registry(), 'local', 'default', current, route => { + assertDelegatedLocalDialCurrent(route, current) + + return route.localRoute?.poolKey + })).toBe('conn:local::default') + }) + + it('checks a captured nondefault primary for a late per-profile remote override', async () => { + const claims = new BackendDialClaims() + const overrides = new Map() + const lookup = vi.fn((profile: string) => overrides.get(profile)) + const initial = resolveRegistryDialOptions(null, 'work', false, lookup) + const ready = deferred() + const transport = vi.fn(() => 'retargeted') + + const old = claims.runRegistry(registry(), 'local', null, initial, async route => { + await ready.promise + const current = resolveRegistryDialOptions(route.delegatedProfile ?? route.profile, 'later', false, lookup) + assertDelegatedLocalDialCurrent(route, current) + + return transport() + }) + + overrides.set('work', true) + const rejection = expect(old).rejects.toThrow('superseded by a remote route') + ready.resolve() + await rejection + expect(lookup.mock.calls.map(([profile]) => profile)).toEqual(['work', 'work']) + expect(transport).not.toHaveBeenCalled() + expect(claims.inFlight('work')).toBe(false) + const subsequent = resolveRegistryDialOptions(null, 'work', false, lookup) + expect(await claims.runRegistry(registry(), 'local', null, subsequent, route => route.localRoute)) + .toEqual({ delegate: false, poolKey: 'conn:local::default' }) + }) + + it('uses the actual local resolver for per-profile remote overrides', async () => { + const claims = new BackendDialClaims() + const legacy = deferred() + const remote = claims.run('work', () => legacy.promise) -const here = path.dirname(fileURLToPath(import.meta.url)) -const mainSource = fs.readFileSync(path.join(here, 'main.ts'), 'utf8').replace(/\r\n/g, '\n') + const local = claims.runRegistry(registry(), 'local', 'work', + { ...options(false), profileRemoteOverride: true }, route => { + expect(route.localRoute).toEqual({ delegate: false, poolKey: 'conn:local::work' }) + + return 'local-work' + }) + + legacy.resolve('remote-work') + expect(await local).toBe('local-work') + expect(await remote).toBe('remote-work') + }) + + it('resolves blank connection IDs before admission and keeps source/profile pairs independent', async () => { + const claims = new BackendDialClaims() + const ready = deferred() + const dial = vi.fn(() => ready.promise) + const a = claims.runRegistry(registry(), '', 'default', options(), dial) + const b = claims.runRegistry(registry(), 'gateway', 'default', options(), dial) + expect(a).toBe(b) + expect(dial).toHaveBeenCalledTimes(1) + const other = claims.runRegistry(registry(), 'other', 'default', options(), route => route.source.id) + const work = claims.runRegistry(registry(), 'gateway', 'work', options(), route => route.profile) + ready.resolve('gateway-default') + expect(await a).toBe('gateway-default') + expect(await other).toBe('other') + expect(await work).toBe('work') + }) + + it('rejects a missing source before it can borrow an existing claim', async () => { + const claims = new BackendDialClaims() + const ready = deferred() + const live = claims.runRegistry(registry(), 'gateway', 'default', options(), () => ready.promise) + const removed = registry() + removed.connections = removed.connections.filter(source => source.id !== 'gateway') + const dial = vi.fn(() => 'wrong') + await expect(claims.runRegistry(removed, 'gateway', 'default', options(), dial)).rejects.toThrow('No connection') + expect(dial).not.toHaveBeenCalled() + ready.resolve('owned') + expect(await live).toBe('owned') + }) + + it('keeps the resolved route across an async factory wait and later configuration changes', async () => { + const claims = new BackendDialClaims() + const opts = options() + const current = registry() + const ready = deferred() + + const first = claims.runRegistry(current, 'local', 'default', opts, async route => { + await ready.promise + + return { id: route.connectionId, source: route.source.kind, localRoute: route.localRoute } + }) + + opts.globalRemote = false + current.primary = 'other' + current.connections = current.connections.filter(source => source.id !== 'local') + const next = claims.runRegistry(registry(), 'local', 'default', opts, route => route.localRoute) + ready.resolve() + expect(await first).toEqual({ id: 'local', source: 'local', localRoute: { delegate: false, poolKey: 'conn:local::default' } }) + expect(await next).toEqual({ delegate: true, poolKey: 'default' }) + }) + + it('releases cancelled and failed local dials without cancelling the independent primary', async () => { + const claims = new BackendDialClaims() + const primaryReady = deferred() + const primary = claims.run('default', () => primaryReady.promise) + const controller = new AbortController() + + const cancelled = claims.runRegistry(registry(), 'local', 'default', options(), () => new Promise((_resolve, reject) => { + controller.signal.addEventListener('abort', () => reject(new Error('dial cancelled')), { once: true }) + })) + + const waiter = claims.runRegistry(registry(), 'local', 'default', options(), () => 'duplicate') + const outcomes = Promise.allSettled([cancelled, waiter]) + controller.abort() + expect((await outcomes).every(result => result.status === 'rejected')).toBe(true) + expect(claims.inFlight('conn:local::default')).toBe(false) + expect(claims.inFlight('default')).toBe(true) + await expect(claims.runRegistry(registry(), 'local', 'default', options(), () => { throw new Error('dial failed') })).rejects.toThrow('dial failed') + expect(claims.inFlight('conn:local::default')).toBe(false) + expect(await claims.runRegistry(registry(), 'local', 'default', options(), () => 'replacement')).toBe('replacement') + primaryReady.resolve('unchanged-primary') + expect(await primary).toBe('unchanged-primary') + }) + + it('retains production generation and process-owner fences through cancellation, replacement and stale cleanup', async () => { + const claims = new BackendDialClaims() + const state = createBackendConnectionState<{ id: string }, string>() + const oldAttempt = state.startAttempt() + const oldTransport = deferred() + const old = claims.runRegistry(registry(), 'local', 'default', options(), () => oldTransport.promise) + state.setPromise(oldAttempt, old) + const oldOwner = state.attachProcess(oldAttempt, { id: 'old' })! + const rejection = expect(old).rejects.toThrow('cancelled') + state.invalidate() + oldTransport.reject(new Error('cancelled')) + await rejection + const nextAttempt = state.startAttempt() + const nextReady = deferred() + const next = claims.runRegistry(registry(), 'local', 'default', options(), () => nextReady.promise) + state.setPromise(nextAttempt, next) + const nextProcess = { id: 'replacement' } + const nextOwner = state.attachProcess(nextAttempt, nextProcess)! + expect(state.attachProcess(oldAttempt, { id: 'late-old' })).toBeNull() + expect(state.setPromise(oldAttempt, Promise.resolve('late-old'))).toBe(false) + expect(state.clearPromiseForAttempt(oldAttempt)).toBe(false) + expect(state.clearForCurrentProcess(oldOwner)).toBe(false) + expect(state.getPromise()).toBe(next) + expect(state.getProcess()).toBe(nextProcess) + expect(claims.inFlight('conn:local::default')).toBe(true) + nextReady.resolve('replacement') + expect(await next).toBe('replacement') + expect(claims.inFlight('conn:local::default')).toBe(false) + expect(state.clearForCurrentProcess(nextOwner)).toBe(true) + expect(state.getPromise()).toBeNull() + }) +}) describe('BackendDialClaims (#90812)', () => { it('coalesces two concurrent dials for the same (connectionId, profile) onto ONE backend spawn', async () => { @@ -121,82 +397,3 @@ describe('parseBackendScopeKey (#90812/#93910)', () => { expect(parseBackendScopeKey('work')).toEqual({ connectionId: null, profile: 'work' }) }) }) - -describe('main.ts wiring for #90812', () => { - it('routes the profile-scoped dial IPC through the single-owner claim', () => { - const handlerStart = mainSource.indexOf("ipcMain.handle('hermes:connection', ") - expect(handlerStart).toBeGreaterThan(-1) - const body = mainSource.slice(handlerStart, handlerStart + 900) - - expect(body).toContain('backendDialClaims.run(') - expect(body).toContain('ensureBackend(profile)') - }) - - it('routes the registry-scoped dial IPC through the claim keyed by backendScopeKey(connectionId, profile)', () => { - const handlerStart = mainSource.indexOf("ipcMain.handle('hermes:connection:for', ") - expect(handlerStart).toBeGreaterThan(-1) - const body = mainSource.slice(handlerStart, handlerStart + 1_200) - - expect(body).toContain('backendDialClaims.run(backendScopeKey(id, profile)') - expect(body).toContain('ensureRegistryBackend(id, profile)') - }) - - // The four IPC/probe surfaces below call ensureRegistryBackend()/ensureBackend() - // directly, bypassing backendDialClaims entirely — so a renderer's guarded - // reconnect dial and one of these can independently race the SAME - // ensureRegistryBackend() await-before-pool-check window (main.ts) and each - // bootstrap its own SSH tunnel / remote dashboard for the same - // (connectionId, profile) scope. - - it('routes a media-stream connection resolve through the single-owner claim', () => { - const handlerStart = mainSource.indexOf('resolveRemoteConnection: ({ connectionId, profile }) =>') - expect(handlerStart).toBeGreaterThan(-1) - const body = mainSource.slice(handlerStart, handlerStart + 300) - - expect(body).toContain('backendDialClaims.run(backendScopeKey(connectionId, profile)') - expect(body).toContain('ensureRegistryBackend(connectionId, profile)') - expect(body).toContain('ensureBackend(profile)') - }) - - it('routes a terminal-pane backend resolve through the single-owner claim on both the registry and local branches', () => { - const handlerStart = mainSource.indexOf('async function ensureTerminalBackend(webContentsId: number) {') - expect(handlerStart).toBeGreaterThan(-1) - const body = mainSource.slice(handlerStart, handlerStart + 900) - - expect(body).toContain('backendDialClaims.run(backendScopeKey(windowRoute.connectionId, windowRoute.profile)') - expect(body).toContain('ensureRegistryBackend(windowRoute.connectionId, windowRoute.profile)') - expect(body).toContain('backendDialClaims.run(backendScopeKey(null, profile)') - expect(body).toContain('ensureBackend(profile)') - }) - - it('routes the roster-enumeration probe through the single-owner claim', () => { - const handlerStart = mainSource.indexOf('async function enumerateRegistryAgentSources') - expect(handlerStart).toBeGreaterThan(-1) - const body = mainSource.slice(handlerStart, handlerStart + 3_700) - - expect(body).toContain('backendDialClaims.run(backendScopeKey(connection.id, null)') - expect(body).toContain('ensureRegistryBackend(connection.id, null)') - expect(body).toContain("getJsonForBackend(descriptor, '/api/profiles'") - }) - - it('routes the connections update-all dispatch through the single-owner claim', () => { - const handlerStart = mainSource.indexOf("ipcMain.handle('hermes:connections:update-all',") - expect(handlerStart).toBeGreaterThan(-1) - // The handler grew on main (renderer-side exclusions + the managed-SSH - // dispatch branch) — keep the scan window comfortably past the dial. - const body = mainSource.slice(handlerStart, handlerStart + 3_000) - - expect(body).toContain('backendDialClaims.run(backendScopeKey(connection.id, null)') - expect(body).toContain('ensureRegistryBackend(connection.id, null)') - expect(body).toContain("postJsonForBackend(descriptor, '/api/hermes/update'") - }) - - it('routes every registry-scoped REST dispatch (hermes:api) through the single-owner claim', () => { - const handlerStart = mainSource.indexOf('async function dispatchRegistryApiRequest(') - expect(handlerStart).toBeGreaterThan(-1) - const body = mainSource.slice(handlerStart, handlerStart + 900) - - expect(body).toContain('backendDialClaims.run(backendScopeKey(registryConnectionId, routeProfile)') - expect(body).toContain('ensureRegistryBackend(registryConnectionId, routeProfile)') - }) -}) diff --git a/apps/desktop/electron/backend-dial-claim.ts b/apps/desktop/electron/backend-dial-claim.ts index e71ffe5f4ea1d..4fa37b6708cb6 100644 --- a/apps/desktop/electron/backend-dial-claim.ts +++ b/apps/desktop/electron/backend-dial-claim.ts @@ -2,7 +2,8 @@ * backend-dial-claim.ts * * Single-owner reconnect/dial claim for backend spawns, keyed by the pool - * scope key from backendScopeKey(connectionId, profile) (#90812). + * resolved pool scope (#90812). Registry-local can have a different backend + * from the legacy primary even though backendScopeKey aliases their labels. * * Why this exists: reconnectGateway()'s in-flight lock lives at renderer * module scope, so it only dedupes reconnects INSIDE one window. Two windows @@ -17,9 +18,83 @@ * unsettled — both outcomes release it, so a failed dial is never cached and * the next reconnect attempt runs fresh (fail closed, not latched). */ +import { + backendScopeKey, + type ConnectionRegistry, + type RegistryConnection, + type RegistryLocalRoute, + resolveRegistryLocalRoute +} from './connection-registry' + +export type RegistryBackendDial = { + registry: ConnectionRegistry + source: RegistryConnection + connectionId: string + profile: string + localRoute: RegistryLocalRoute | null + delegatedProfile: string | null +} + +type RegistryDialOptions = { + globalRemote: boolean + profileRemoteOverride: boolean + primaryProfile: string +} + +export function resolveRegistryDialOptions( + profile: null | string | undefined, + primaryProfile: string, + globalRemote: boolean, + profileHasRemoteOverride: (profile: string) => unknown +): RegistryDialOptions { + const key = String(profile ?? '').trim() || primaryProfile + + return { globalRemote, profileRemoteOverride: Boolean(profileHasRemoteOverride(key)), primaryProfile } +} + +// A registry factory can await primary matching before reaching the local +// branch. A formerly delegated route must fail if v1 has become remote; +// otherwise ensureBackend would silently send this local dial elsewhere. +export function assertDelegatedLocalDialCurrent(route: RegistryBackendDial, options: RegistryDialOptions): void { + if (route.localRoute?.delegate && !resolveRegistryLocalRoute(route.profile, options).delegate) { + throw new Error('Local backend dial was superseded by a remote route. Retry the connection.') + } +} + export class BackendDialClaims { readonly #inflightByKey = new Map>() + // Resolve the actual local pool before admitting a claim. Carry the same + // routing snapshot into the factory: an async wait must not re-resolve it + // against a later registry/configuration and silently retarget the dial. + runRegistry( + registry: ConnectionRegistry, + connectionId: null | string | undefined, + profile: null | string | undefined, + options: RegistryDialOptions, + dial: (route: RegistryBackendDial) => Promise | T + ): Promise { + const id = String(connectionId || '').trim() || registry.primary + const source = registry.connections.find(connection => connection.id === id) + + if (!source) { + return Promise.reject(new Error(`No connection with id "${id}".`)) + } + + const profileKey = String(profile ?? '').trim() || 'default' + const localRoute = source.kind === 'local' ? resolveRegistryLocalRoute(profileKey, options) : null + const delegatedProfile = localRoute?.delegate ? String(profile ?? '').trim() || options.primaryProfile : null + const route = { registry, source, connectionId: id, profile: profileKey, localRoute, delegatedProfile } + + const key = localRoute + ? localRoute.delegate + ? backendScopeKey(null, delegatedProfile) + : localRoute.poolKey + : backendScopeKey(id, profile) + + return this.run(key, () => dial(route)) + } + /** Whether a dial for this key is currently in flight (test/diagnostic seam). */ inFlight(key: string): boolean { return this.#inflightByKey.has(key) diff --git a/apps/desktop/electron/main.ts b/apps/desktop/electron/main.ts index a1fc2b73c5077..2a063acbf90dc 100644 --- a/apps/desktop/electron/main.ts +++ b/apps/desktop/electron/main.ts @@ -46,7 +46,7 @@ import { } from './backend-claim' import { dashboardFallbackArgs, sourceDeclaresServe } from './backend-command' import { createBackendConnectionState } from './backend-connection-state' -import { BackendDialClaims } from './backend-dial-claim' +import { assertDelegatedLocalDialCurrent, BackendDialClaims, type RegistryBackendDial, resolveRegistryDialOptions } from './backend-dial-claim' import { buildDesktopBackendEnv, hermesManagedNodePathEntries, normalizeHermesHomeRoot } from './backend-env' import { isReauthRequiredError, @@ -1377,9 +1377,7 @@ function registerMediaProtocol() { // reconnect dial for the same (connectionId, profile) scope; coalescing // here avoids bootstrapping a second SSH tunnel / remote dashboard. resolveRemoteConnection: ({ connectionId, profile }) => - backendDialClaims.run(backendScopeKey(connectionId, profile), () => - connectionId ? ensureRegistryBackend(connectionId, profile) : ensureBackend(profile) - ) + connectionId ? claimRegistryBackend(connectionId, profile) : claimBackend(profile) }) protocol.handle(MEDIA_PROTOCOL, handler) @@ -9971,14 +9969,12 @@ async function ensureTerminalBackend(webContentsId: number) { // reconnect dial for the same (connectionId, profile) scope; coalescing // here avoids bootstrapping a second SSH tunnel / remote dashboard. if (windowRoute?.registryScoped && windowRoute.connectionId) { - return backendDialClaims.run(backendScopeKey(windowRoute.connectionId, windowRoute.profile), () => - ensureRegistryBackend(windowRoute.connectionId, windowRoute.profile) - ) + return claimRegistryBackend(windowRoute.connectionId, windowRoute.profile) } const profile = windowRoute?.profile ?? primaryProfileKey() - return backendDialClaims.run(backendScopeKey(null, profile), () => ensureBackend(profile)) + return claimBackend(profile) } // Loopback reach for the browser pane. Scoped to the SSH connection that @@ -11077,10 +11073,24 @@ async function ensureBackend(profile) { // a genuinely-local child when the v1 mode says remote; non-local connections // pool under the composite key from backendScopeKey() and reuse the same pool // entry lifecycle (LRU, idle reaper, touch) as per-profile local backends. -async function ensureRegistryBackend(connectionId, profile, managedUpdateCorrelation = '') { - const registry = readDesktopConnectionsRegistry() - const id = String(connectionId || '').trim() || registry.primary - const source = registry.connections.find(c => c.id === id) +function claimBackend(profile) { + const profileKey = profile && String(profile).trim() ? String(profile).trim() : primaryProfileKey() + + return backendDialClaims.run(backendScopeKey(null, profileKey), () => ensureBackend(profile)) +} + +function claimRegistryBackend(connectionId, profile) { + return backendDialClaims.runRegistry( + readDesktopConnectionsRegistry(), connectionId, profile, + resolveRegistryDialOptions(profile, primaryProfileKey(), globalRemoteActive(), profileHasRemoteOverride), + route => ensureRegistryBackend(route.connectionId, profile, '', route) + ) +} + +async function ensureRegistryBackend(connectionId, profile, managedUpdateCorrelation = '', resolvedDial?: RegistryBackendDial) { + const registry = resolvedDial?.registry || readDesktopConnectionsRegistry() + const id = resolvedDial?.connectionId || String(connectionId || '').trim() || registry.primary + const source = resolvedDial?.source || registry.connections.find(c => c.id === id) if (!source) { throw new Error(`No connection with id "${id}".`) @@ -11179,13 +11189,20 @@ async function ensureRegistryBackend(connectionId, profile, managedUpdateCorrela specialistDispatchQuiesce.assertCanStart(profileKey) profileDeletionGate.assertCanStart(profileKey) - const localRoute = resolveRegistryLocalRoute(profileKey, { + const localRoute = resolvedDial?.localRoute || resolveRegistryLocalRoute(profileKey, { globalRemote: globalRemoteActive(), profileRemoteOverride: Boolean(profileHasRemoteOverride(profileKey)) }) if (localRoute.delegate) { - return ensureBackend(profile) + if (resolvedDial) { + assertDelegatedLocalDialCurrent(resolvedDial, resolveRegistryDialOptions( + resolvedDial.delegatedProfile ?? profileKey, + primaryProfileKey(), globalRemoteActive(), profileHasRemoteOverride + )) + } + + return ensureBackend(resolvedDial?.delegatedProfile ?? profile) } const stoppingLocal = poolStopper.inFlight(localRoute.poolKey) @@ -14297,12 +14314,8 @@ function createWindow() { } ipcMain.handle('hermes:connection', async (_event, profile) => { - // Coalesce concurrent renderer dials for one profile scope (#90812): the - // renderer-side reconnect lock is per-window, so two windows waking at once - // both land here. The claim key mirrors ensureBackend()'s own profile - // normalization so every spelling of the primary coalesces onto one dial. - const profileKey = profile && String(profile).trim() ? String(profile).trim() : primaryProfileKey() - const connection = await backendDialClaims.run(backendScopeKey(null, profileKey), () => ensureBackend(profile)) + // Match ensureBackend's profile normalization before claim admission. + const connection = await claimBackend(profile) const connectionId = resolvedConnectionId(readDesktopConnectionsRegistry(), connection) return connectionId ? { ...connection, connectionId } : connection @@ -14316,10 +14329,9 @@ ipcMain.handle('hermes:connection:for', async (_event, payload) => { const { connectionId, profile } = payload && typeof payload === 'object' ? (payload as any) : ({} as any) const registry = readDesktopConnectionsRegistry() const id = String(connectionId || '').trim() || registry.primary - // Same single-owner claim as 'hermes:connection', keyed by the composite - // (connectionId, profile) scope (#90812): concurrent registry dials for one - // scope share the first spawn instead of bootstrapping duplicate remotes. - const connection = await backendDialClaims.run(backendScopeKey(id, profile), () => ensureRegistryBackend(id, profile)) + // Resolve the actual backend before claiming: explicit local can differ + // from the legacy remote primary even when their scope labels alias. + const connection = await claimRegistryBackend(id, profile) return { ...connection, connectionId: id, registryScoped: true } }) @@ -14418,9 +14430,7 @@ function revalidatePool() { function redialPoolBackendAfterResume(poolKey: string) { const { connectionId, profile } = parseBackendScopeKey(poolKey) - return backendDialClaims.run(poolKey, () => - connectionId ? ensureRegistryBackend(connectionId, profile) : ensureBackend(profile) - ) + return connectionId ? claimRegistryBackend(connectionId, profile) : claimBackend(profile) } // Identity for coalescing post-resume sweeps in the shared revalidation @@ -15118,9 +15128,7 @@ async function enumerateRegistryAgentSources(registry = readDesktopConnectionsRe // bootstrapping a second SSH tunnel / remote dashboard. const descriptor: any = await withEnumerationDeadline( Promise.resolve( - backendDialClaims.run(backendScopeKey(connection.id, null), () => - ensureRegistryBackend(connection.id, null) - ) + claimRegistryBackend(connection.id, null) ) ) @@ -15338,9 +15346,7 @@ ipcMain.handle('hermes:connections:update-all', async (_event, payload) => { // Claim-guarded (#90812): coalesce with a concurrent renderer dial // for the same connection instead of bootstrapping a second backend. - const descriptor: any = await backendDialClaims.run(backendScopeKey(connection.id, null), () => - ensureRegistryBackend(connection.id, null) - ) + const descriptor: any = await claimRegistryBackend(connection.id, null) const body: any = await postJsonForBackend(descriptor, '/api/hermes/update', {}, { timeoutMs: 15_000 }) @@ -15984,9 +15990,7 @@ async function dispatchRegistryApiRequest( // here, so it can race a renderer's own WS reconnect dial for the same // (connectionId, profile) scope; coalescing avoids bootstrapping a second // SSH tunnel / remote dashboard. - const connection: any = await backendDialClaims.run(backendScopeKey(registryConnectionId, routeProfile), () => - ensureRegistryBackend(registryConnectionId, routeProfile) - ) + const connection: any = await claimRegistryBackend(registryConnectionId, routeProfile) const requestPath = pathForRegistryBackendRequest(request.path, requestProfile, connection) diff --git a/apps/desktop/electron/power-resume-remote-revalidation.test.ts b/apps/desktop/electron/power-resume-remote-revalidation.test.ts index 7da53d4cc1123..3640a96f28c99 100644 --- a/apps/desktop/electron/power-resume-remote-revalidation.test.ts +++ b/apps/desktop/electron/power-resume-remote-revalidation.test.ts @@ -284,12 +284,28 @@ describe('main.ts wiring for #93910', () => { }) it('re-dials a retired pool key through the single-owner dial claim', () => { - const fnStart = mainSource.indexOf('function redialPoolBackendAfterResume(') - expect(fnStart).toBeGreaterThan(-1) - const body = mainSource.slice(fnStart, fnStart + 1_200) + const functionSource = (name: string) => { + const fnStart = mainSource.indexOf(`function ${name}(`) + expect(fnStart).toBeGreaterThan(-1) + + const fnEnd = mainSource.indexOf('\n}', fnStart) + expect(fnEnd).toBeGreaterThan(fnStart) + + return mainSource.slice(fnStart, fnEnd + 2) + } + + const body = functionSource('redialPoolBackendAfterResume') expect(body).toContain('parseBackendScopeKey(') - expect(body).toContain('backendDialClaims.run(') - expect(body).toContain('ensureRegistryBackend(') + expect(body).toMatch(/return\s+connectionId\s*\?\s*claimRegistryBackend\(connectionId,\s*profile\)\s*:\s*claimBackend\(profile\)/) + + const registryClaim = functionSource('claimRegistryBackend') + expect(registryClaim).toContain('backendDialClaims.runRegistry(') + expect(registryClaim).toContain('readDesktopConnectionsRegistry(), connectionId, profile') + expect(registryClaim).toContain('resolveRegistryDialOptions(profile, primaryProfileKey(), globalRemoteActive(), profileHasRemoteOverride)') + expect(registryClaim).toContain("route => ensureRegistryBackend(route.connectionId, profile, '', route)") + + const primaryClaim = functionSource('claimBackend') + expect(primaryClaim).toContain('backendDialClaims.run(backendScopeKey(null, profileKey), () => ensureBackend(profile))') }) })