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' const here = path.dirname(fileURLToPath(import.meta.url)) const mainSource = fs.readFileSync(path.join(here, 'main.ts'), 'utf8').replace(/\r\n/g, '\n') describe('BackendDialClaims (#90812)', () => { it('coalesces two concurrent dials for the same (connectionId, profile) onto ONE backend spawn', async () => { const claims = new BackendDialClaims() let spawns = 0 let resolveSpawn: ((value: { baseUrl: string }) => void) | undefined const dial = vi.fn(() => { spawns += 1 return new Promise<{ baseUrl: string }>(resolve => { resolveSpawn = resolve }) }) // Two renderer windows race the same reconnect: reconnectGateway()'s // in-flight lock is per-renderer, so BOTH invoke the main-process dial. const first = claims.run('conn:office-ssh::default', dial) const second = claims.run('conn:office-ssh::default', dial) expect(spawns).toBe(1) resolveSpawn?.({ baseUrl: 'http://127.0.0.1:53150' }) const [firstResult, secondResult] = await Promise.all([first, second]) // The second caller receives the FIRST dial's result, not its own spawn. expect(firstResult).toBe(secondResult) expect(firstResult).toEqual({ baseUrl: 'http://127.0.0.1:53150' }) expect(dial).toHaveBeenCalledTimes(1) }) it('scopes claims by key: different (connectionId, profile) pairs dial independently', async () => { const claims = new BackendDialClaims() const dialA = vi.fn(async () => 'a') const dialB = vi.fn(async () => 'b') const [a, b] = await Promise.all([ claims.run('conn:office-ssh::default', dialA), claims.run('conn:office-ssh::work', dialB) ]) expect(a).toBe('a') expect(b).toBe('b') expect(dialA).toHaveBeenCalledTimes(1) expect(dialB).toHaveBeenCalledTimes(1) }) it('releases the claim once the dial settles so a later reconnect can dial again (bounded, not latched)', async () => { const claims = new BackendDialClaims() const dial = vi.fn(async () => 'fresh') await claims.run('default', dial) expect(claims.inFlight('default')).toBe(false) await claims.run('default', dial) expect(dial).toHaveBeenCalledTimes(2) }) it('propagates a failed dial to every coalesced waiter and never caches the rejection', async () => { const claims = new BackendDialClaims() let rejectSpawn: ((error: Error) => void) | undefined const failingDial = vi.fn( () => new Promise((_resolve, reject) => { rejectSpawn = reject }) ) const first = claims.run('conn:office-ssh::default', failingDial) const second = claims.run('conn:office-ssh::default', failingDial) expect(failingDial).toHaveBeenCalledTimes(1) rejectSpawn?.(new Error('ssh dial failed')) await expect(first).rejects.toThrow('ssh dial failed') await expect(second).rejects.toThrow('ssh dial failed') // Fail closed but not latched: the NEXT dial attempt runs fresh. const recovered = vi.fn(async () => 'recovered') await expect(claims.run('conn:office-ssh::default', recovered)).resolves.toBe('recovered') expect(recovered).toHaveBeenCalledTimes(1) }) it('a synchronously-throwing dial rejects the claim instead of escaping the coalescing seam', async () => { const claims = new BackendDialClaims() await expect( claims.run('default', () => { throw new Error('spawn refused') }) ).rejects.toThrow('spawn refused') expect(claims.inFlight('default')).toBe(false) }) }) describe('parseBackendScopeKey (#90812/#93910)', () => { it('round-trips the composite pool key back to (connectionId, profile)', () => { expect(parseBackendScopeKey('conn:office-ssh::default')).toEqual({ connectionId: 'office-ssh', profile: 'default' }) expect(parseBackendScopeKey('conn:office-ssh::work')).toEqual({ connectionId: 'office-ssh', profile: 'work' }) }) it('treats a bare profile key as the local/primary scope', () => { expect(parseBackendScopeKey('default')).toEqual({ connectionId: null, profile: 'default' }) 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)') }) })