From 38bc801502d82946fa3020fad2592421b4202595 Mon Sep 17 00:00:00 2001 From: zhaozian <1161954287@qq.com> Date: Sun, 20 Sep 2026 16:33:21 +0800 Subject: [PATCH] fix(browser): auto-discover Desktop bridge on ephemeral ports Follow DSH Desktop random ports when settings.bridgeUrl is empty via cached auto URL, known CLI ports, loopback tabs, and a bounded ephemeral scan, and rediscover after repeated probe failures without overriding manual addresses. --- .../src/background/bridge-discovery.ts | 320 ++++++++++++++++++ .../dsh-browser/src/background/bridge.ts | 29 +- .../dsh-browser/src/background/index.ts | 127 ++++--- .../tests/background-bridge-lifecycle.spec.ts | 9 +- .../background-tab-affinity-rebind.spec.ts | 23 +- .../tests/bridge-discovery.spec.ts | 209 ++++++++++++ extensions/dsh-browser/tests/bridge.spec.ts | 27 ++ 7 files changed, 682 insertions(+), 62 deletions(-) create mode 100644 extensions/dsh-browser/src/background/bridge-discovery.ts create mode 100644 extensions/dsh-browser/tests/bridge-discovery.spec.ts diff --git a/extensions/dsh-browser/src/background/bridge-discovery.ts b/extensions/dsh-browser/src/background/bridge-discovery.ts new file mode 100644 index 000000000..5b62eed64 --- /dev/null +++ b/extensions/dsh-browser/src/background/bridge-discovery.ts @@ -0,0 +1,320 @@ +/** + * Layered local-bridge URL discovery for the extension background. + * + * Order: manual override → cached auto URL → known CLI/Desktop ports → + * open loopback tabs → bounded ephemeral-port scan. Every candidate is + * confirmed by fetching `/ext/bridge-config` and checking `wsUrl`. + * + * @module + */ + +import { bridgeWsUrlFromLocation } from '@yuxianglin/dsh-bridge-browser/src/bridge-url.ts' +import { BRIDGE_CONFIG_PATH, BRIDGE_PATH } from '@yuxianglin/dsh-bridge-browser/src/protocol.ts' + +/** CLI defaults plus pinned Desktop / legacy ports. */ +export const DISCOVERY_PORTS = [3080, 3081, 3090, 14389, 43189] as const + +/** chrome.storage.local key for automatic discovery results (never write settings.bridgeUrl). */ +export const AUTO_STORAGE_KEY = 'dshBridgeAuto' + +/** macOS / typical ephemeral client port range used by Desktop `--port 0`. */ +export const EPHEMERAL_PORT_RANGE = { from: 49152, to: 65535 } as const + +export const SCAN_CONCURRENCY = 8 +export const SCAN_PER_REQUEST_TIMEOUT_MS = 300 +export const SCAN_TOTAL_BUDGET_MS = 10_000 +export const SCAN_COOLDOWN_MS = 30_000 + +/** Persisted automatic discovery cache. */ +export interface BridgeAutoCache { + url?: string + lastGoodPort?: number + discoveredAt?: number +} + +/** Where a resolved URL came from. */ +export type BridgeResolveSource = + | 'manual' + | 'auto-cache' + | 'known-ports' + | 'open-tabs' + | 'ephemeral-scan' + +/** One successful resolution. */ +export interface BridgeResolveResult { + url: string + source: BridgeResolveSource +} + +/** Injectable dependencies for tests. */ +export interface BridgeDiscoveryDeps { + probe: (url: string) => Promise + loadAuto: () => Promise + saveAuto: (cache: BridgeAutoCache) => Promise + queryLoopbackTabs: () => Promise> + now: () => number + fetchImpl: typeof fetch +} + +/** Process-local scan bookkeeping (not persisted). */ +export interface ScanRuntimeState { + lastScanAt: number + triedPorts: Set +} + +const defaultScanState = (): ScanRuntimeState => ({ lastScanAt: 0, triedPorts: new Set() }) + +let scanState: ScanRuntimeState = defaultScanState() + +/** Reset in-process scan cooldown / tried set (tests). */ +export function resetBridgeScanState(): void { + scanState = defaultScanState() +} + +/** Expose scan state for assertions. */ +export function getBridgeScanState(): ScanRuntimeState { + return scanState +} + +/** Whether a URL is loopback http(s) suitable for tab-derived discovery. */ +export function isLoopbackHttpUrl(raw: string): boolean { + try { + const url = new URL(raw) + if (url.protocol !== 'http:' && url.protocol !== 'https:') return false + return url.hostname === '127.0.0.1' || url.hostname === 'localhost' + } catch { + return false + } +} + +/** Normalize a bridge URL so bare hosts get `/ext/bridge`. */ +export function normalizeBridgeUrl(url: string): string { + try { + const parsed = new URL(url) + if (parsed.pathname === '' || parsed.pathname === '/') parsed.pathname = BRIDGE_PATH + return parsed.toString() + } catch { + return url + } +} + +/** Probe `/ext/bridge-config` for a candidate ws(s) URL (loopback only). */ +export async function probeBridgeConfig( + url: string, + fetchImpl: typeof fetch = fetch, + timeoutMs = 1_500, +): Promise { + try { + const target = new URL(url) + if (target.hostname !== '127.0.0.1' && target.hostname !== 'localhost') return true + target.protocol = target.protocol === 'wss:' ? 'https:' : 'http:' + target.pathname = BRIDGE_CONFIG_PATH + target.search = '' + target.hash = '' + const response = await fetchImpl(target, { signal: AbortSignal.timeout(timeoutMs) }) + if (!response.ok) return false + const body = await response.json() as { wsUrl?: unknown } + return typeof body.wsUrl === 'string' + && (body.wsUrl.startsWith('ws://') || body.wsUrl.startsWith('wss://')) + } catch { + return false + } +} + +/** Fetch config on a known HTTP port and return its wsUrl when valid. */ +async function fetchWsUrlOnPort( + port: number, + fetchImpl: typeof fetch, + timeoutMs: number, + shouldContinue: () => boolean, +): Promise { + if (!shouldContinue()) return undefined + try { + const response = await fetchImpl(`http://127.0.0.1:${port}${BRIDGE_CONFIG_PATH}`, { + signal: AbortSignal.timeout(timeoutMs), + }) + if (!shouldContinue()) return undefined + if (!response.ok) return undefined + const body = await response.json() as { wsUrl?: unknown } + if (typeof body.wsUrl === 'string' && body.wsUrl.startsWith('ws://')) { + return normalizeBridgeUrl(body.wsUrl) + } + } catch { + // Port empty or not a DSH bridge. + } + return undefined +} + +async function fromKnownPorts( + deps: BridgeDiscoveryDeps, + shouldContinue: () => boolean, + tried: Set, +): Promise { + for (const port of DISCOVERY_PORTS) { + tried.add(port) + const url = await fetchWsUrlOnPort(port, deps.fetchImpl, 1_500, shouldContinue) + if (url !== undefined) return url + if (!shouldContinue()) return undefined + } + return undefined +} + +async function fromOpenTabs( + deps: BridgeDiscoveryDeps, + shouldContinue: () => boolean, +): Promise { + let tabs: Array<{ url?: string }> + try { + tabs = await deps.queryLoopbackTabs() + } catch { + return undefined + } + for (const tab of tabs) { + if (!shouldContinue()) return undefined + if (tab.url === undefined || !isLoopbackHttpUrl(tab.url)) continue + let page: URL + try { + page = new URL(tab.url) + } catch { + continue + } + const candidate = normalizeBridgeUrl(bridgeWsUrlFromLocation(page)) + if (await deps.probe(candidate)) return candidate + } + return undefined +} + +async function fromEphemeralScan( + deps: BridgeDiscoveryDeps, + shouldContinue: () => boolean, + auto: BridgeAutoCache, +): Promise { + const now = deps.now() + if (now - scanState.lastScanAt < SCAN_COOLDOWN_MS) return undefined + scanState.lastScanAt = now + + const preferred: number[] = [] + if ( + typeof auto.lastGoodPort === 'number' + && auto.lastGoodPort >= EPHEMERAL_PORT_RANGE.from + && auto.lastGoodPort <= EPHEMERAL_PORT_RANGE.to + && !scanState.triedPorts.has(auto.lastGoodPort) + ) { + preferred.push(auto.lastGoodPort) + } + + const queue: number[] = [...preferred] + for (let port = EPHEMERAL_PORT_RANGE.from; port <= EPHEMERAL_PORT_RANGE.to; port += 1) { + if (scanState.triedPorts.has(port) || preferred.includes(port)) continue + queue.push(port) + } + + const deadline = now + SCAN_TOTAL_BUDGET_MS + let cursor = 0 + + while (cursor < queue.length) { + if (!shouldContinue() || deps.now() >= deadline) return undefined + const batch = queue.slice(cursor, cursor + SCAN_CONCURRENCY) + cursor += batch.length + for (const port of batch) scanState.triedPorts.add(port) + + const results = await Promise.all(batch.map(async (port) => { + if (!shouldContinue() || deps.now() >= deadline) return undefined + return fetchWsUrlOnPort(port, deps.fetchImpl, SCAN_PER_REQUEST_TIMEOUT_MS, shouldContinue) + })) + for (const url of results) { + if (url !== undefined) return url + } + } + return undefined +} + +/** + * Resolve a bridge WebSocket URL using the layered discovery policy. + * Manual `bridgeUrl` always wins and is never overwritten by this function. + */ +export async function resolveBridgeUrl( + manualUrl: string, + shouldContinue: () => boolean, + deps: BridgeDiscoveryDeps, +): Promise { + const trimmed = manualUrl.trim() + if (trimmed !== '') { + return { url: normalizeBridgeUrl(trimmed), source: 'manual' } + } + + const tried = new Set() + const auto = await deps.loadAuto() + + if (typeof auto.url === 'string' && auto.url !== '') { + const cached = normalizeBridgeUrl(auto.url) + if (await deps.probe(cached)) { + return { url: cached, source: 'auto-cache' } + } + try { + const port = Number(new URL(cached).port) + if (Number.isFinite(port) && port > 0) tried.add(port) + } catch { /* ignore */ } + } + + const known = await fromKnownPorts(deps, shouldContinue, tried) + if (known !== undefined) return { url: known, source: 'known-ports' } + if (!shouldContinue()) return undefined + + const fromTabs = await fromOpenTabs(deps, shouldContinue) + if (fromTabs !== undefined) return { url: fromTabs, source: 'open-tabs' } + if (!shouldContinue()) return undefined + + for (const port of tried) scanState.triedPorts.add(port) + const scanned = await fromEphemeralScan(deps, shouldContinue, auto) + if (scanned !== undefined) return { url: scanned, source: 'ephemeral-scan' } + return undefined +} + +/** Persist a successful automatic discovery for the next cold start. */ +export async function rememberAutoBridgeUrl( + url: string, + saveAuto: (cache: BridgeAutoCache) => Promise, + loadAuto: () => Promise, + now: () => number = Date.now, +): Promise { + const normalized = normalizeBridgeUrl(url) + let lastGoodPort: number | undefined + try { + const port = Number(new URL(normalized).port) + if (Number.isFinite(port) && port > 0) lastGoodPort = port + } catch { /* ignore */ } + const previous = await loadAuto() + if ( + previous.url === normalized + && previous.lastGoodPort === lastGoodPort + ) { + return + } + await saveAuto({ + ...previous, + url: normalized, + discoveredAt: now(), + ...lastGoodPort !== undefined ? { lastGoodPort } : {}, + }) +} + +/** Default chrome.storage-backed auto cache IO. */ +export function createChromeAutoCacheIo(): Pick { + return { + loadAuto: async () => { + const stored = await chrome.storage.local.get(AUTO_STORAGE_KEY) + const raw = stored[AUTO_STORAGE_KEY] + if (typeof raw !== 'object' || raw === null) return {} + const record = raw as Record + return { + ...typeof record.url === 'string' ? { url: record.url } : {}, + ...typeof record.lastGoodPort === 'number' ? { lastGoodPort: record.lastGoodPort } : {}, + ...typeof record.discoveredAt === 'number' ? { discoveredAt: record.discoveredAt } : {}, + } + }, + saveAuto: async (cache) => { + await chrome.storage.local.set({ [AUTO_STORAGE_KEY]: cache }) + }, + } +} diff --git a/extensions/dsh-browser/src/background/bridge.ts b/extensions/dsh-browser/src/background/bridge.ts index 33c9da70a..0293e3b36 100644 --- a/extensions/dsh-browser/src/background/bridge.ts +++ b/extensions/dsh-browser/src/background/bridge.ts @@ -4,7 +4,9 @@ * exponential-backoff reconnects, and answers protocol pings. * * The reconnect policy mirrors the dsh GUI's own ConnectionController: base - * 500ms, ×2 per attempt, capped at 10s, jittered 0.5–1×. + * 500ms, ×2 per attempt, capped at 10s, jittered 0.5–1×. After a streak of + * failed probes the optional `resolveUrl` hook may replace the target URL so + * Desktop random-port restarts can be followed without user action. * * @module */ @@ -29,9 +31,14 @@ export interface BridgeSinks { /** Resolve whether opening a WebSocket is expected to succeed. */ type BridgeProbe = (url: string) => Promise +/** Re-discover a bridge URL after repeated probe failures. */ +export type BridgeResolveUrl = (shouldContinue: () => boolean) => Promise + const BACKOFF_BASE_MS = 500 const BACKOFF_MAX_MS = 10_000 const HELLO_ACK_TIMEOUT_MS = 5_000 +/** Probe failures before asking `resolveUrl` for a replacement address. */ +const REDISCOVER_AFTER_FAILURES = 3 /** * Owns one WebSocket connection generation and the reconnect loop. @@ -51,11 +58,17 @@ export class BridgeClient { private readonly probe: BridgeProbe = async () => true, /** Whether a disconnected client still has an active user-owned lease. */ private readonly shouldReconnect: () => boolean = () => true, + private readonly resolveUrl?: BridgeResolveUrl, ) {} /** Current coarse state (mirrors the last emitted sink value). */ state: BridgeState = 'stopped' + /** Active bridge WebSocket URL (may change after rediscovery). */ + get currentUrl(): string { + return this.url + } + /** * Connect (or reconnect) to the bridge. Idempotent: calling again with the * same url/token restarts the loop from attempt 0. @@ -109,17 +122,31 @@ export class BridgeClient { } private async loop(generation: number): Promise { + let failStreak = 0 while (this.running && generation === this.generation) { if (!this.retryAllowed()) return const reachable = await this.probe(this.url).catch(() => false) if (!this.running || generation !== this.generation) return if (!this.retryAllowed()) return if (!reachable) { + failStreak += 1 + if (failStreak >= REDISCOVER_AFTER_FAILURES && this.resolveUrl !== undefined) { + const next = await this.resolveUrl(() => this.running && generation === this.generation) + failStreak = 0 + if (!this.running || generation !== this.generation) return + if (next !== undefined && next !== this.url) { + this.url = next + this.attempt = 0 + this.emitState('connecting') + continue + } + } this.emitState('reconnecting') await this.waitBeforeRetry() continue } + failStreak = 0 const socket = new WebSocket(this.url) this.ws = socket // A replacement is an ownership handoff, not a transient transport diff --git a/extensions/dsh-browser/src/background/index.ts b/extensions/dsh-browser/src/background/index.ts index 2ca9ea6a3..60c0bdf11 100644 --- a/extensions/dsh-browser/src/background/index.ts +++ b/extensions/dsh-browser/src/background/index.ts @@ -39,8 +39,16 @@ import { type RespondResult, } from '@yuxianglin/dsh-bridge-browser/src/protocol.ts' import type { ServerFrame } from '@yuxianglin/dsh-bridge-browser/src/protocol.ts' -import { BRIDGE_CONFIG_PATH, BRIDGE_PATH } from '@yuxianglin/dsh-bridge-browser/src/protocol.ts' import { BridgeClient, type BridgeState } from './bridge.ts' +import { + createChromeAutoCacheIo, + isLoopbackHttpUrl, + normalizeBridgeUrl, + probeBridgeConfig, + rememberAutoBridgeUrl, + resolveBridgeUrl, + type BridgeDiscoveryDeps, +} from './bridge-discovery.ts' import { createRpc } from './rpc.ts' import { dispatchOpenTab, @@ -107,51 +115,42 @@ const SETTINGS_DEFAULTS: Settings = { autoResumeSession: true, } -/** - * 自动探测的候选端口: - * - dsh web(CLI)默认 3080,端口被占时依次回退 3081 / 3090; - * - DSH Desktop 默认由系统随机分配本地 Web 端口(`dsh-desktop.port: 0`), - * 用户指南推荐固定为 43189(见 deepseek-harness-desktop docs/user-guide); - * - 14389 为历史桌面应用端口,保留兼容旧版。 - */ -const DISCOVERY_PORTS = [3080, 3081, 3090, 14389, 43189] +/** Legacy default that used to be written as a "manual" override; treat as empty. */ const LEGACY_LOCAL_URL = 'ws://127.0.0.1:3080' -/** 探测本机 dsh 的桥地址:fetch /ext/bridge-config 直到成功。 */ -async function discoverBridge(shouldContinue: () => boolean = () => true): Promise { - for (const port of DISCOVERY_PORTS) { - if (!shouldContinue()) return undefined - try { - const response = await fetch(`http://127.0.0.1:${port}/ext/bridge-config`, { - signal: AbortSignal.timeout(1_500), - }) - if (!shouldContinue()) return undefined - if (!response.ok) continue - const body = await response.json() as { wsUrl?: unknown } - if (typeof body.wsUrl === 'string' && body.wsUrl.startsWith('ws://')) return body.wsUrl - } catch { - // 该端口没有 dsh 或未挂桥:试下一个。 - } - } - return undefined +const autoCacheIo = createChromeAutoCacheIo() + +const discoveryDeps: BridgeDiscoveryDeps = { + probe: probeBridgeConfig, + loadAuto: autoCacheIo.loadAuto, + saveAuto: autoCacheIo.saveAuto, + queryLoopbackTabs: async () => chrome.tabs.query({ url: ['http://127.0.0.1/*', 'http://localhost/*'] }), + now: Date.now, + fetchImpl: fetch, } /** Avoid opening a noisy loopback WebSocket until the local bridge responds. */ async function probeBridge(url: string): Promise { - try { - const target = new URL(url) - if (target.hostname !== '127.0.0.1') return true - target.protocol = target.protocol === 'wss:' ? 'https:' : 'http:' - target.pathname = BRIDGE_CONFIG_PATH - target.search = '' - target.hash = '' - const response = await fetch(target, { signal: AbortSignal.timeout(1_500) }) - if (!response.ok) return false - const body = await response.json() as { wsUrl?: unknown } - return typeof body.wsUrl === 'string' && body.wsUrl.startsWith('ws://') - } catch { - return false + return probeBridgeConfig(url) +} + +async function persistAutoBridgeUrl(url: string): Promise { + await rememberAutoBridgeUrl(url, autoCacheIo.saveAuto, autoCacheIo.loadAuto) +} + +/** + * Re-run layered discovery while the panel lease is active. + * Manual bridgeUrl always wins and is never overwritten. + */ +async function resolveActiveBridgeUrl( + shouldContinue: () => boolean, +): Promise<{ url: string; auto: boolean } | undefined> { + const resolved = await resolveBridgeUrl(settings.bridgeUrl, shouldContinue, discoveryDeps) + if (resolved === undefined) return undefined + if (resolved.source !== 'manual') { + console.info(`[dsh-browser] resolved bridge ${resolved.url} via ${resolved.source}`) } + return { url: resolved.url, auto: resolved.source !== 'manual' } } const STORAGE_KEY = 'dshSettings' @@ -1228,29 +1227,20 @@ function cancelAllToolCalls(): void { async function startBridge(): Promise { const revision = ++bridgeStartRevision if (panelPorts.size === 0) return - let url = settings.bridgeUrl - if (url === '') { - url = await discoverBridge(() => revision === bridgeStartRevision && panelPorts.size > 0) ?? '' - } + const resolved = await resolveActiveBridgeUrl( + () => revision === bridgeStartRevision && panelPorts.size > 0, + ) // Discovery is asynchronous. A panel may have closed or a newer settings // update may have started while its fetches were in flight. if (revision !== bridgeStartRevision || panelPorts.size === 0) return - if (url === '') { + if (resolved === undefined) { bridge?.stop() bridge = null rpc = null broadcastStatus() return } - // 手动填的地址常只有主机部分(如 ws://127.0.0.1:3080);桥路径是协议 - // 常量,缺省时自动补全,避免连到根路径失败。 - try { - const parsed = new URL(url) - if (parsed.pathname === '' || parsed.pathname === '/') parsed.pathname = BRIDGE_PATH - url = parsed.toString() - } catch { - // 非法 URL 原样交给 WebSocket 构造函数报错。 - } + const url = normalizeBridgeUrl(resolved.url) if (bridge === null) { const client = new BridgeClient({ onStateChange: (state) => { @@ -1277,14 +1267,38 @@ async function startBridge(): Promise { caps = negotiated broadcastStatus() void pushBudgetToControlledTab(negotiated) + if (settings.bridgeUrl.trim() === '' && client.currentUrl !== '') { + void persistAutoBridgeUrl(client.currentUrl) + } }, - }, probeBridge, () => panelPorts.size > 0) + }, probeBridge, () => panelPorts.size > 0, async (shouldContinue) => { + // Manual override never rediscovers into a different address. + if (settings.bridgeUrl.trim() !== '') return undefined + const next = await resolveActiveBridgeUrl(shouldContinue) + if (next !== undefined) void persistAutoBridgeUrl(next.url) + return next?.url + }) bridge = client rpc = createRpc(client) } bridge.start(url, settings.token) } +/** Debounced rediscovery when a loopback DSH page commits navigation. */ +let loopbackDiscoveryTimer: ReturnType | undefined +function scheduleLoopbackBridgeRefresh(): void { + if (settings.bridgeUrl.trim() !== '') return + if (panelPorts.size === 0) return + if (loopbackDiscoveryTimer !== undefined) clearTimeout(loopbackDiscoveryTimer) + loopbackDiscoveryTimer = setTimeout(() => { + loopbackDiscoveryTimer = undefined + void settingsReady.then(() => { + if (settings.bridgeUrl.trim() !== '' || panelPorts.size === 0) return + void startBridge() + }) + }, 400) +} + /** Gateway RPC with a helpful error when the bridge is down. */ async function gatewayRpc(method: string, payload: unknown): Promise { if (rpc === null || bridge === null || !bridge.connected) { @@ -1690,8 +1704,11 @@ chrome.tabs.onRemoved.addListener((tabId) => { // fragment update does not, and must not drop a quote still on the screen. // Matching the exact frame keeps an iframe's navigation from invalidating a // quote taken from its parent page, and vice versa. -chrome.webNavigation.onCommitted.addListener(({ tabId, frameId }) => { +chrome.webNavigation.onCommitted.addListener(({ tabId, frameId, url }) => { broadcastSelections(selections.clearTab(tabId, frameId)) + if (frameId === 0 && typeof url === 'string' && isLoopbackHttpUrl(url)) { + scheduleLoopbackBridgeRefresh() + } }) // Ports are cleaned up by their own disconnect; only the window's quote is diff --git a/extensions/dsh-browser/tests/background-bridge-lifecycle.spec.ts b/extensions/dsh-browser/tests/background-bridge-lifecycle.spec.ts index 2dc7cb633..ce1f501ae 100644 --- a/extensions/dsh-browser/tests/background-bridge-lifecycle.spec.ts +++ b/extensions/dsh-browser/tests/background-bridge-lifecycle.spec.ts @@ -239,7 +239,14 @@ describe('background bridge lifecycle', () => { type: 'settings', settings: { bridgeUrl: 'ws://127.0.0.1:3081', token: 'new-token' }, }) - await vi.waitFor(() => { expect(chrome.storage.local.set).toHaveBeenCalledOnce() }) + await vi.waitFor(() => { + expect(chrome.storage.local.set).toHaveBeenCalledWith(expect.objectContaining({ + dshSettings: expect.objectContaining({ + bridgeUrl: 'ws://127.0.0.1:3081', + token: 'new-token', + }), + })) + }) panel.onDisconnect.emit() expect(originalSocket.readyState).toBe(FakeWebSocket.OPEN) diff --git a/extensions/dsh-browser/tests/background-tab-affinity-rebind.spec.ts b/extensions/dsh-browser/tests/background-tab-affinity-rebind.spec.ts index 21a552f20..d52352a69 100644 --- a/extensions/dsh-browser/tests/background-tab-affinity-rebind.spec.ts +++ b/extensions/dsh-browser/tests/background-tab-affinity-rebind.spec.ts @@ -67,7 +67,9 @@ function mockChrome() { }, storage: { local: { - get: vi.fn(async () => ({})), + get: vi.fn(async () => ({ + dshSettings: { bridgeUrl: 'wss://bridge.example/ext/bridge' }, + })), set: vi.fn(async () => {}), }, session: { @@ -86,7 +88,7 @@ function mockChrome() { onRemoved: chromeEvent<[number]>(), }, webNavigation: { - onCommitted: chromeEvent<[{ tabId: number; frameId: number }]>(), + onCommitted: chromeEvent<[{ tabId: number; frameId: number; url?: string }]>(), }, windows: { WINDOW_ID_NONE: -1, @@ -99,9 +101,18 @@ function mockChrome() { async function connectPanelForTest() { const chromeMock = mockChrome() - vi.stubGlobal('fetch', vi.fn(async () => new Response(null, { status: 503 }))) + vi.stubGlobal('WebSocket', class extends EventTarget { + static CONNECTING = 0 + static OPEN = 1 + static CLOSED = 3 + readyState = 0 + send(): void {} + close(): void {} + constructor(public url: string) { + super() + } + }) await import('../src/background/index.ts') - await vi.waitFor(() => { expect(chromeMock.query).toHaveBeenCalled() }) const panel = panelPort() chromeMock.onConnect.emit(panel.port) @@ -109,6 +120,7 @@ async function connectPanelForTest() { expect(panel.postMessage).toHaveBeenCalledWith(expect.objectContaining({ type: 'tab-affinity' })) }) panel.postMessage.mockClear() + chromeMock.query.mockClear() return { ...chromeMock, ...panel } } @@ -211,13 +223,14 @@ describe('background tab-affinity rebind protocol', () => { expect(postMessage).toHaveBeenCalledWith({ type: 'tab-affinity.rebind.result', id: 'initial-bind', ok: true }) }) postMessage.mockClear() + query.mockClear() let finishQuery!: (tabs: chrome.tabs.Tab[]) => void query.mockImplementationOnce(async () => await new Promise((resolve) => { finishQuery = resolve })) onMessage.emit({ type: 'tab-affinity.rebind', id: 'disconnected-rebind' }) - await vi.waitFor(() => { expect(query).toHaveBeenCalledTimes(4) }) + await vi.waitFor(() => { expect(query).toHaveBeenCalled() }) onDisconnect.emit() finishQuery([tab(2)]) await Promise.resolve() diff --git a/extensions/dsh-browser/tests/bridge-discovery.spec.ts b/extensions/dsh-browser/tests/bridge-discovery.spec.ts new file mode 100644 index 000000000..ba098a7ff --- /dev/null +++ b/extensions/dsh-browser/tests/bridge-discovery.spec.ts @@ -0,0 +1,209 @@ +// @vitest-environment jsdom + +import { afterEach, describe, expect, it, vi } from 'vitest' +import { + DISCOVERY_PORTS, + EPHEMERAL_PORT_RANGE, + SCAN_CONCURRENCY, + SCAN_TOTAL_BUDGET_MS, + isLoopbackHttpUrl, + normalizeBridgeUrl, + rememberAutoBridgeUrl, + resetBridgeScanState, + resolveBridgeUrl, + type BridgeDiscoveryDeps, +} from '../src/background/bridge-discovery.ts' + +afterEach(() => { + resetBridgeScanState() + vi.useRealTimers() +}) + +function deps(overrides: Partial = {}): BridgeDiscoveryDeps { + return { + probe: async () => false, + loadAuto: async () => ({}), + saveAuto: async () => {}, + queryLoopbackTabs: async () => [], + now: Date.now, + fetchImpl: async () => new Response(null, { status: 503 }), + ...overrides, + } +} + +function configOk(wsUrl: string): Response { + return new Response(JSON.stringify({ wsUrl }), { status: 200 }) +} + +describe('isLoopbackHttpUrl', () => { + it('accepts only loopback http(s)', () => { + expect(isLoopbackHttpUrl('http://127.0.0.1:50403/')).toBe(true) + expect(isLoopbackHttpUrl('https://localhost/app')).toBe(true) + expect(isLoopbackHttpUrl('ws://127.0.0.1:3080/ext/bridge')).toBe(false) + expect(isLoopbackHttpUrl('http://example.com/')).toBe(false) + }) +}) + +describe('normalizeBridgeUrl', () => { + it('appends /ext/bridge when the path is missing', () => { + expect(normalizeBridgeUrl('ws://127.0.0.1:3080')).toBe('ws://127.0.0.1:3080/ext/bridge') + }) +}) + +describe('resolveBridgeUrl layering', () => { + it('returns a manual override without probing', async () => { + const probe = vi.fn(async () => true) + const fetchImpl = vi.fn() + const result = await resolveBridgeUrl('ws://127.0.0.1:9999', () => true, deps({ probe, fetchImpl })) + expect(result).toEqual({ + url: 'ws://127.0.0.1:9999/ext/bridge', + source: 'manual', + }) + expect(probe).not.toHaveBeenCalled() + expect(fetchImpl).not.toHaveBeenCalled() + }) + + it('prefers a reachable auto-cache before known ports', async () => { + const probe = vi.fn(async (url: string) => url.includes('50403')) + const fetchImpl = vi.fn() + const result = await resolveBridgeUrl('', () => true, deps({ + probe, + fetchImpl, + loadAuto: async () => ({ url: 'ws://127.0.0.1:50403/ext/bridge', lastGoodPort: 50403 }), + })) + expect(result).toEqual({ + url: 'ws://127.0.0.1:50403/ext/bridge', + source: 'auto-cache', + }) + expect(fetchImpl).not.toHaveBeenCalled() + }) + + it('tries known ports before open tabs and skips L4/L5 on a CLI hit', async () => { + const order: string[] = [] + const queryLoopbackTabs = vi.fn(async () => { + order.push('tabs') + return [{ url: 'http://127.0.0.1:50403/' }] + }) + const fetchImpl = vi.fn(async (input: RequestInfo | URL) => { + const href = String(input) + order.push(`fetch:${href}`) + if (href.includes(':3080/')) return configOk('ws://127.0.0.1:3080/ext/bridge') + return new Response(null, { status: 503 }) + }) + const result = await resolveBridgeUrl('', () => true, deps({ fetchImpl, queryLoopbackTabs })) + expect(result).toEqual({ + url: 'ws://127.0.0.1:3080/ext/bridge', + source: 'known-ports', + }) + expect(order[0]).toBe('fetch:http://127.0.0.1:3080/ext/bridge-config') + expect(queryLoopbackTabs).not.toHaveBeenCalled() + expect(order.some((entry) => entry === 'tabs')).toBe(false) + }) + + it('uses an open loopback tab after known ports fail', async () => { + const fetchImpl = vi.fn(async (input: RequestInfo | URL) => { + const href = String(input) + if (DISCOVERY_PORTS.some((port) => href.includes(`:${port}/`))) { + return new Response(null, { status: 503 }) + } + if (href.includes(':50403/')) return configOk('ws://127.0.0.1:50403/ext/bridge') + return new Response(null, { status: 503 }) + }) + const probe = vi.fn(async (url: string) => url.includes('50403')) + const result = await resolveBridgeUrl('', () => true, deps({ + fetchImpl, + probe, + queryLoopbackTabs: async () => [{ url: 'http://127.0.0.1:50403/chat' }], + })) + expect(result?.source).toBe('open-tabs') + expect(result?.url).toBe('ws://127.0.0.1:50403/ext/bridge') + expect(probe).toHaveBeenCalled() + // Known ports are contacted before tab derivation. + const firstFetch = String(fetchImpl.mock.calls[0]![0]) + expect(firstFetch).toContain(`:${DISCOVERY_PORTS[0]}/`) + }) + + it('scans ephemeral ports with bounded concurrency after L1–L4 fail', async () => { + vi.useFakeTimers() + let inFlight = 0 + let maxInFlight = 0 + const seen = new Set() + const fetchImpl = vi.fn(async (input: RequestInfo | URL) => { + const href = String(input) + const match = /:(\d+)\//.exec(href) + const port = match === null ? 0 : Number(match[1]) + seen.add(port) + inFlight += 1 + maxInFlight = Math.max(maxInFlight, inFlight) + try { + await new Promise((resolve) => { setTimeout(resolve, 20) }) + if (port === EPHEMERAL_PORT_RANGE.from + 20) { + return configOk(`ws://127.0.0.1:${port}/ext/bridge`) + } + return new Response(null, { status: 503 }) + } finally { + inFlight -= 1 + } + }) + + const pending = resolveBridgeUrl('', () => true, deps({ + fetchImpl, + now: () => Date.now(), + queryLoopbackTabs: async () => [], + })) + await vi.advanceTimersByTimeAsync(SCAN_TOTAL_BUDGET_MS) + const result = await pending + + expect(result?.source).toBe('ephemeral-scan') + expect(result?.url).toBe(`ws://127.0.0.1:${EPHEMERAL_PORT_RANGE.from + 20}/ext/bridge`) + expect(maxInFlight).toBeLessThanOrEqual(SCAN_CONCURRENCY) + for (const port of DISCOVERY_PORTS) expect(seen.has(port)).toBe(true) + }) + + it('stops the ephemeral scan when shouldContinue becomes false', async () => { + let continueScan = true + let fetches = 0 + const fetchImpl = vi.fn(async () => { + fetches += 1 + if (fetches > DISCOVERY_PORTS.length + 4) continueScan = false + await new Promise((resolve) => { setTimeout(resolve, 5) }) + return new Response(null, { status: 503 }) + }) + const result = await resolveBridgeUrl('', () => continueScan, deps({ + fetchImpl, + queryLoopbackTabs: async () => [], + })) + expect(result).toBeUndefined() + expect(fetches).toBeLessThan(200) + }) +}) + +describe('rememberAutoBridgeUrl', () => { + it('writes url and lastGoodPort without touching settings', async () => { + const saved: unknown[] = [] + await rememberAutoBridgeUrl( + 'ws://127.0.0.1:50403/ext/bridge', + async (cache) => { saved.push(cache) }, + async () => ({}), + () => 1_700_000_000_000, + ) + expect(saved).toEqual([{ + url: 'ws://127.0.0.1:50403/ext/bridge', + discoveredAt: 1_700_000_000_000, + lastGoodPort: 50403, + }]) + }) + + it('skips a no-op write when the cache already matches', async () => { + const saveAuto = vi.fn(async () => {}) + await rememberAutoBridgeUrl( + 'ws://127.0.0.1:50403/ext/bridge', + saveAuto, + async () => ({ + url: 'ws://127.0.0.1:50403/ext/bridge', + lastGoodPort: 50403, + }), + ) + expect(saveAuto).not.toHaveBeenCalled() + }) +}) diff --git a/extensions/dsh-browser/tests/bridge.spec.ts b/extensions/dsh-browser/tests/bridge.spec.ts index 5750398d3..96342c389 100644 --- a/extensions/dsh-browser/tests/bridge.spec.ts +++ b/extensions/dsh-browser/tests/bridge.spec.ts @@ -127,4 +127,31 @@ describe('BridgeClient connection probe', () => { expect(states.at(-1)).toBe('stopped') expect(FakeWebSocket.instances).toHaveLength(1) }) + + it('rediscovers a new URL after repeated probe failures and resets backoff', async () => { + vi.useFakeTimers() + vi.stubGlobal('WebSocket', FakeWebSocket) + const states: BridgeState[] = [] + let probes = 0 + const resolveUrl = vi.fn(async () => 'ws://127.0.0.1:50403/ext/bridge') + const client = new BridgeClient({ + onStateChange: (state) => { states.push(state) }, + onFrame: () => {}, + onHelloOk: () => {}, + }, async () => { + probes += 1 + return probes > 3 + }, () => true, resolveUrl) + + client.start('ws://127.0.0.1:3080/ext/bridge', '') + await vi.advanceTimersByTimeAsync(0) + expect(states.at(-1)).toBe('reconnecting') + await vi.advanceTimersByTimeAsync(30_000) + + expect(resolveUrl).toHaveBeenCalled() + expect(client.currentUrl).toBe('ws://127.0.0.1:50403/ext/bridge') + expect(states).toContain('connecting') + expect(FakeWebSocket.instances.some((socket) => socket.url.includes('50403'))).toBe(true) + client.stop() + }) })