diff --git a/apps/desktop/electron/main.ts b/apps/desktop/electron/main.ts index 77a1bfe586..5116cdb7bc 100644 --- a/apps/desktop/electron/main.ts +++ b/apps/desktop/electron/main.ts @@ -411,7 +411,11 @@ import { isHermesOwnedVenvDaemon } from './venv-holder-select' import { fetchMarketplaceThemes, searchMarketplaceThemes } from './vscode-marketplace' import { createWakeIndicatorWindowController } from './wake-indicator-window' import { enumerateWindowsFrontToBack, enumerationFailed, readWindowBelow } from './window-below' -import { registrySshScopeForWindowRoute, WindowConnectionRouteRegistry } from './window-connection-route' +import { + registrySshPoolScopeByConnectionId, + registrySshScopeForWindowRoute, + WindowConnectionRouteRegistry +} from './window-connection-route' import { createWindowOpenHandler } from './window-open-policy' import { installWindowRendererLifecycle } from './window-renderer-lifecycle' import { createWindowRevealController } from './window-reveal' @@ -10315,7 +10319,18 @@ function activeSshTerminalTarget(webContentsId?: number) { const state = sshConnections.get(scope) - return state && state.ssh ? { ssh: state.ssh, scope } : 'pending' + if (state && state.ssh) { + return { ssh: state.ssh, scope } + } + + // The pool's single writer publishes under the per-profile bootstrap key + // while stamping the entry with its registry connection id (#97345), so a + // composite-key miss must still resolve the live tunnel by that identity + // instead of reporting 'pending' forever. + const pooledScope = registrySshPoolScopeByConnectionId(sshConnections, windowRoute.connectionId) + const pooledState = pooledScope === null ? null : sshConnections.get(pooledScope) + + return pooledState && pooledState.ssh ? { ssh: pooledState.ssh, scope: pooledScope } : 'pending' } const profile = windowRoute?.profile ?? primaryProfileKey() diff --git a/apps/desktop/electron/window-connection-route.test.ts b/apps/desktop/electron/window-connection-route.test.ts index 17a4a488f4..400f151db7 100644 --- a/apps/desktop/electron/window-connection-route.test.ts +++ b/apps/desktop/electron/window-connection-route.test.ts @@ -4,6 +4,7 @@ import { test } from 'vitest' import { normalizeWindowConnectionRoute, + registrySshPoolScopeByConnectionId, registrySshScopeForWindowRoute, WindowConnectionRouteRegistry } from './window-connection-route' @@ -120,3 +121,20 @@ test('uses the canonical default profile scope when a registry SSH route has no 'conn:source-b::default' ) }) + +test('recovers the bootstrap pool key a registry SSH tunnel was published under', () => { + const pool = new Map([['', { registryConnectionId: 'source-b', ssh: { alive: true } }]]) + + assert.equal(registrySshPoolScopeByConnectionId(pool, 'source-b'), '') +}) + +test('does not match another connection, an unlabelled entry, or a torn-down tunnel', () => { + const pool = new Map([ + ['research', { registryConnectionId: 'source-a', ssh: { alive: true } }], + ['', { registryConnectionId: '', ssh: { alive: true } }], + ['worker', { registryConnectionId: 'source-b' }] + ]) + + assert.equal(registrySshPoolScopeByConnectionId(pool, 'source-b'), null) + assert.equal(registrySshPoolScopeByConnectionId(pool, 'source-c'), null) +}) diff --git a/apps/desktop/electron/window-connection-route.ts b/apps/desktop/electron/window-connection-route.ts index 7c0a83cee5..806f690508 100644 --- a/apps/desktop/electron/window-connection-route.ts +++ b/apps/desktop/electron/window-connection-route.ts @@ -40,6 +40,30 @@ export function registrySshScopeForWindowRoute( return backendScopeKey(route.connectionId, route.profile) } +export interface RegistrySshPoolEntry { + registryConnectionId?: null | string + ssh?: unknown +} + +// The sshConnections pool has a single writer that publishes every tunnel under +// its per-profile bootstrap key while stamping the entry with the registry +// connection that owns it. A registry-scoped lookup under the composite +// backendScopeKey can therefore miss a live tunnel; this recovers the writer's +// actual key from the stamped identity, the same match managedSshScopeRole +// applies to pool entries. +export function registrySshPoolScopeByConnectionId( + entries: Iterable, + connectionId: string +): null | string { + for (const [scope, entry] of entries) { + if (entry?.ssh && entry.registryConnectionId === connectionId) { + return scope + } + } + + return null +} + export class WindowConnectionRouteRegistry { private readonly routes = new Map()