From b07ca48db19179f247bb4b0a5b1e415948cabc69 Mon Sep 17 00:00:00 2001 From: Owen McGirr Date: Tue, 25 Aug 2026 13:17:05 +0100 Subject: [PATCH 1/3] fix: detect stale PC disconnections --- docs/accessibility.md | 1 + docs/physical-smoke-test.md | 2 + src/connection/ConnectionManager.ts | 75 ++++++- src/connection/ProtocolClient.test.ts | 7 +- src/connection/ProtocolClient.ts | 14 +- src/connection/connection.test.ts | 1 + src/connection/integration.test.ts | 205 +++++++++++++++++- src/diagnostics/DiagnosticLog.ts | 2 + src/remote/DisconnectedRemote.test.ts | 6 + src/remote/RemoteScreen.test.tsx | 9 + src/transport/BleTransport.ts | 1 + src/transport/ReactNativeBleTransport.test.ts | 67 ++++++ src/transport/ReactNativeBleTransport.ts | 19 ++ 13 files changed, 394 insertions(+), 15 deletions(-) diff --git a/docs/accessibility.md b/docs/accessibility.md index 27c51c4..98a2b17 100644 --- a/docs/accessibility.md +++ b/docs/accessibility.md @@ -9,6 +9,7 @@ Switchify Remote is designed for VoiceOver, TalkBack, iOS Switch Control, and An - Connected Remote control screens add one "Scroll to top" scan stop after the Surface selector pins. The 48-point control stays above the PC switcher, announces "Top of Remote" once after use, and does not animate when Reduce Motion is enabled. - Remote keeps a quick PC switcher immediately above the tab bar in every connection state. It announces the active connection, exposes selected state for the current saved PC, and restores focus after its modal closes. - Headings, connection changes, failures, repeat state, pairing approval, and pointer-profile recovery transitions are announced once without moving focus unexpectedly. +- If the PC connection is lost, connected controls disappear immediately and Remote announces the first reconnect attempt once. Idle connections are checked in the background without adding focus or scanning stops. - In live typing, Return and the visible Enter control restore the text field and software keyboard after delivery. Failed Enter attempts keep the text available for retry and return focus without sending it again. - The pairing code is announced one digit at a time. - Text scales with the operating system; controls grow rather than shrinking text below the user's chosen size. diff --git a/docs/physical-smoke-test.md b/docs/physical-smoke-test.md index 7606df8..7b245ed 100644 --- a/docs/physical-smoke-test.md +++ b/docs/physical-smoke-test.md @@ -10,6 +10,8 @@ Run the matrix on a physical Android phone and iPhone against current Switchify 2. Clear app data and repeat setup. Choose Allow Bluetooth, deny access once, verify the platform-appropriate explanation, then grant access from Settings and discover the desktop. Also test Bluetooth off, unsupported hardware where available, and a forced scan failure. 3. Pair and verify the six-digit code on both devices. Reject a second request and confirm the mobile error is sanitized. 4. Disconnect and reconnect from Saved PCs, select and clear a default PC, then press Unpair. Confirm Cancel, Android Back, and outside dismissal preserve the pairing. Confirm Unpair removes it and re-pairing is required. + - While Remote is idle and connected, quit Switchify PC, use its Disconnect action, disable Bluetooth, and move the phone out of range. Confirm connected controls disappear within nine seconds, attempt 1 is announced once, and the existing automatic retries either restore the correct PC or end in the sanitized failure state. + - Repeat while sending a non-sensitive fixture command. Confirm a failed native write shows reconnection immediately, while one deliberately dropped response remains connected when the active health check succeeds. - Confirm a new pairing shows the phone model rather than a generic Remote name. Change Remote name while connected and confirm both desktop platforms update without re-pairing. Change it while offline, reconnect, and confirm the new name appears. Restart the PC application and confirm the name persists. - On the OPD2403 against Windows, complete at least 20 connections and reconnects. Confirm controls become ready every time. Simulate one dropped pointer-profile response and confirm the app shows "Restoring controls" before controls appear without another reconnect. 5. Exercise eight-way movement, repeat/stop, all clicks, both scroll directions, drag cleanup, speed limits, and monitor movement. diff --git a/src/connection/ConnectionManager.ts b/src/connection/ConnectionManager.ts index a9e0410..2591271 100644 --- a/src/connection/ConnectionManager.ts +++ b/src/connection/ConnectionManager.ts @@ -5,7 +5,7 @@ import type { JsonObject, PointerProfile, ProtocolResponse } from '@/domain/prot import { DiagnosticLog } from '@/diagnostics/DiagnosticLog'; import type { PairingStorage, SavedPc } from '@/storage/PairingStore'; import type { BleAvailability, BleTransport, DiscoveredDesktop, Unsubscribe } from '@/transport/BleTransport'; -import { ProtocolClient } from './ProtocolClient'; +import { ProtocolClient, ProtocolWriteError } from './ProtocolClient'; import { pairingVerificationCode } from './verificationCode'; export type ConnectionState = @@ -37,6 +37,9 @@ export class ConnectionManager { #switchIntent = 0; #invalidSavedDesktopIds = new Set(); #profileRecoveryTimers = new Map, (active: boolean) => void>(); + #healthTimer: ReturnType | null = null; + #healthProbe: Promise | null = null; + #protocolOperations = 0; constructor( private readonly transport: BleTransport, @@ -252,12 +255,23 @@ export class ConnectionManager { } async request(type: string, payload: JsonObject = {}, responseMode: 'ack' | 'none' = 'ack'): Promise { - if (!this.#client || !this.#token || !this.#deviceId) return null; + await this.#healthProbe; + const client = this.#client; + const token = this.#token; + const deviceId = this.#deviceId; + const sourceOperation = this.#operation; + const desktop = this.#state.kind === 'connected' ? this.#state.desktop : null; + if (!client || !token || !deviceId || !desktop) return null; + this.#cancelHealthTimer(); + this.#protocolOperations += 1; + let healthyActivity = false; + let shouldProbe = false; const id = this.id(); - const message = authenticatedCommand({ id, deviceId: this.#deviceId, token: this.#token, timestamp: this.now(), type, payload, responseMode }); + const message = authenticatedCommand({ id, deviceId, token, timestamp: this.now(), type, payload, responseMode }); try { - if (responseMode === 'none') { await this.#client.send(message); return { kind: 'ack', id }; } - const response = await this.#client.request(message, id, 5_000); + if (responseMode === 'none') { await client.send(message); healthyActivity = true; return { kind: 'ack', id }; } + const response = await client.request(message, id, 5_000); + healthyActivity = true; if (response.kind === 'ack') { if (type === 'pointer.speed.set' && typeof payload.scalePercent === 'number' && this.#state.kind === 'connected' && this.#state.profile) { this.#set({ ...this.#state, profile: { ...this.#state.profile, capabilities: { ...this.#state.profile.capabilities, pointerSpeed: { ...this.#state.profile.capabilities.pointerSpeed, scalePercent: payload.scalePercent } } } }); @@ -270,7 +284,18 @@ export class ConnectionManager { this.diagnostics.add('remote_name_sync_failed', 'warning'); return response; } - } catch { /* sanitized below */ } + } catch (error) { + if (error instanceof ProtocolWriteError || responseMode === 'none') { + void this.#unexpectedDisconnect(desktop, sourceOperation); + } else { + shouldProbe = true; + } + } finally { + this.#protocolOperations -= 1; + if (this.#protocolOperations === 0 && this.#current(sourceOperation) && this.#state.kind === 'connected') { + this.#scheduleHealth(healthyActivity ? 5_000 : shouldProbe ? 0 : 5_000); + } + } this.diagnostics.add('command_failed', 'warning'); return null; } @@ -327,6 +352,7 @@ export class ConnectionManager { this.diagnostics.add('connected'); if (profile) { this.#set({ kind: 'connected', desktop, profile, profileStatus: 'ready' }); + this.#scheduleHealth(); } else { this.#set({ kind: 'connected', desktop, profile: null, profileStatus: 'recovering' }); this.diagnostics.add('profile_recovery_started'); @@ -352,6 +378,7 @@ export class ConnectionManager { if (this.#state.kind === 'connected' && this.#state.profileStatus === 'recovering') { this.#set({ ...this.#state, profile, profileStatus: 'ready' }); this.diagnostics.add('profile_recovered'); + this.#scheduleHealth(); } return; } @@ -359,6 +386,7 @@ export class ConnectionManager { if (this.#current(operation) && this.#state.kind === 'connected' && this.#state.profileStatus === 'recovering') { this.#set({ ...this.#state, profileStatus: 'unavailable' }); this.diagnostics.add('profile_recovery_exhausted', 'warning'); + this.#scheduleHealth(); } } @@ -390,8 +418,11 @@ export class ConnectionManager { } async #unexpectedDisconnect(desktop: DiscoveredDesktop, sourceOperation: number): Promise { - if (!this.#current(sourceOperation) || this.#state.kind === 'idle' || this.#state.kind === 'reconnecting') return; + if (!this.#current(sourceOperation) || this.#state.kind === 'idle' || this.#state.kind === 'reconnecting' || this.#state.kind === 'failed') return; const operation = ++this.#operation; + this.#cancelHealthTimer(); + this.#set({ kind: 'reconnecting', desktop, attempt: 1 }); + this.diagnostics.add('connection_lost', 'warning'); await this.#teardownConnection(); const token = await this.storage.token(desktop.desktopId); if (!token || !this.#current(operation)) { if (this.#current(operation)) await this.#fail('Connection to the PC was lost.', operation); return; } @@ -432,6 +463,7 @@ export class ConnectionManager { async #teardownConnection(): Promise { this.#cancelProfileRecovery(); + this.#cancelHealthTimer(); this.#disconnectStop?.(); this.#disconnectStop = null; const client = this.#client; this.#client = null; this.#token = null; @@ -439,6 +471,35 @@ export class ConnectionManager { await this.transport.disconnect().catch(() => undefined); } + #scheduleHealth(delay = 5_000): void { + this.#cancelHealthTimer(); + if (this.#state.kind !== 'connected' || this.#state.profileStatus === 'recovering') return; + const operation = this.#operation; + const desktop = this.#state.desktop; + this.#healthTimer = setTimeout(() => { + this.#healthTimer = null; + if (!this.#current(operation) || this.#state.kind !== 'connected') return; + if (this.#protocolOperations > 0 || this.#healthProbe) { this.#scheduleHealth(); return; } + const probe = this.#runHealthProbe(desktop, operation); + this.#healthProbe = probe; + void probe.finally(() => { if (this.#healthProbe === probe) this.#healthProbe = null; }); + }, delay); + (this.#healthTimer as unknown as { unref?: () => void }).unref?.(); + } + + async #runHealthProbe(desktop: DiscoveredDesktop, operation: number): Promise { + const healthy = await this.transport.verifyConnection(desktop.desktopId); + if (!this.#current(operation) || this.#state.kind !== 'connected') return; + if (healthy) { this.#scheduleHealth(); return; } + this.diagnostics.add('connection_health_failed', 'warning'); + void this.#unexpectedDisconnect(desktop, operation); + } + + #cancelHealthTimer(): void { + if (this.#healthTimer !== null) clearTimeout(this.#healthTimer); + this.#healthTimer = null; + } + async #orderedSaved(): Promise { const saved = (await this.storage.list()).filter((pc) => !this.#invalidSavedDesktopIds.has(pc.desktopId)); const defaultId = await this.storage.defaultDesktopId().catch(() => null); diff --git a/src/connection/ProtocolClient.test.ts b/src/connection/ProtocolClient.test.ts index cf468ff..15051d9 100644 --- a/src/connection/ProtocolClient.test.ts +++ b/src/connection/ProtocolClient.test.ts @@ -1,7 +1,7 @@ import { toByteArray } from 'base64-js'; import { createFrames, encodeFrame, FrameReassembler } from '@/domain/protocol/framing'; import type { BleTransport, Unsubscribe } from '@/transport/BleTransport'; -import { ProtocolClient } from './ProtocolClient'; +import { ProtocolClient, ProtocolResponseTimeoutError, ProtocolWriteError } from './ProtocolClient'; class FakeTransport implements BleTransport { frames: string[] = []; @@ -16,6 +16,7 @@ class FakeTransport implements BleTransport { resolveAndConnect = async () => { throw new Error('not used'); }; connect = async () => undefined; disconnect = async () => undefined; cancelPendingWrites = async () => undefined; + verifyConnection = async () => true; notificationsReady = async () => undefined; async writeFrame(frame: string) { if (this.fail) throw new Error('write failed'); this.inFlight += 1; this.maxInFlight = Math.max(this.maxInFlight, this.inFlight); await this.writeGate; this.frames.push(frame); this.inFlight -= 1; } subscribe(listener: (value: string) => void): Unsubscribe { this.listener = listener; return () => { this.listener = null; }; } @@ -68,9 +69,9 @@ describe('ProtocolClient', () => { const transport = new FakeTransport(); transport.fail = true; const client = new ProtocolClient(transport); - await expect(client.request('{"fixture":true}', 'write', 10)).rejects.toThrow('Could not write'); + await expect(client.request('{"fixture":true}', 'write', 10)).rejects.toBeInstanceOf(ProtocolWriteError); transport.fail = false; - await expect(client.request('{"fixture":true}', 'timeout', 1)).rejects.toThrow('timed out'); + await expect(client.request('{"fixture":true}', 'timeout', 1)).rejects.toBeInstanceOf(ProtocolResponseTimeoutError); }); it('bounds a native GATT write that never settles', async () => { diff --git a/src/connection/ProtocolClient.ts b/src/connection/ProtocolClient.ts index 99c805c..614b0b2 100644 --- a/src/connection/ProtocolClient.ts +++ b/src/connection/ProtocolClient.ts @@ -16,7 +16,7 @@ export class ProtocolClient { async start(onFailure: () => void): Promise { this.#unsubscribe?.(); - this.#unsubscribe = this.transport.subscribe((raw) => this.#accept(raw), () => { void this.close().finally(onFailure); }); + this.#unsubscribe = this.transport.subscribe((raw) => this.#accept(raw), () => { onFailure(); void this.close(); }); try { await this.transport.notificationsReady(); } catch (error) { @@ -32,9 +32,9 @@ export class ProtocolClient { }); void response.catch(() => undefined); try { await this.send(message); } - catch { this.#reject(requestId, new Error('Could not write to PC.')); } + catch { this.#reject(requestId, new ProtocolWriteError()); } const pending = this.#pending.get(requestId); - if (pending) pending.timer = setTimeout(() => { this.#pending.delete(requestId); pending.reject(new Error('PC response timed out.')); }, timeoutMs); + if (pending) pending.timer = setTimeout(() => { this.#pending.delete(requestId); pending.reject(new ProtocolResponseTimeoutError()); }, timeoutMs); return await response; } @@ -106,3 +106,11 @@ export class ProtocolClient { pending.reject(error); } } + +export class ProtocolWriteError extends Error { + constructor() { super('Could not write to PC.'); } +} + +export class ProtocolResponseTimeoutError extends Error { + constructor() { super('PC response timed out.'); } +} diff --git a/src/connection/connection.test.ts b/src/connection/connection.test.ts index a982f5e..c82ed1a 100644 --- a/src/connection/connection.test.ts +++ b/src/connection/connection.test.ts @@ -51,6 +51,7 @@ class FakeTransport implements BleTransport { return this.resolvedDesktop; }; cancelPendingWrites = async () => undefined; + verifyConnection = async () => true; notificationsReady = async () => { if (this.failReadiness) throw new Error('readiness failed'); }; subscribe(): Unsubscribe { return () => undefined; } subscribeDisconnect(): Unsubscribe { return () => undefined; } } diff --git a/src/connection/integration.test.ts b/src/connection/integration.test.ts index 91aa2bd..9c7ad77 100644 --- a/src/connection/integration.test.ts +++ b/src/connection/integration.test.ts @@ -24,6 +24,7 @@ class LoopbackTransport implements BleTransport { readonly outbound = new FrameReassembler(); onFrame: ((frame: string) => void) | null = null; onDisconnect: (() => void) | null = null; + onNotificationError: ((error: Error) => void) | null = null; failWrites = false; rejectPairing = false; rejectAuthentication = false; @@ -41,6 +42,10 @@ class LoopbackTransport implements BleTransport { dropResponseCounts = new Map(); invalidResponseCounts = new Map(); hangWrites = new Set(); + healthChecks = 0; + healthResult = true; + healthGate: Promise | null = null; + disconnectGate: Promise | null = null; scan(): Unsubscribe { return () => undefined; } resolveAndConnect = async () => { this.connectCount += 1; @@ -48,10 +53,11 @@ class LoopbackTransport implements BleTransport { return this.resolvedDesktop; }; connect = async () => undefined; - disconnect = async () => undefined; + disconnect = async () => { await this.disconnectGate; }; cancelPendingWrites = async () => undefined; + verifyConnection = async () => { this.healthChecks += 1; await this.healthGate; return this.healthResult; }; notificationsReady = async () => { await this.readinessGate; }; - subscribe(onFrame: (frameBase64: string) => void): Unsubscribe { this.onFrame = onFrame; return () => { this.onFrame = null; }; } + subscribe(onFrame: (frameBase64: string) => void, onError: (error: Error) => void): Unsubscribe { this.onFrame = onFrame; this.onNotificationError = onError; return () => { this.onFrame = null; this.onNotificationError = null; }; } subscribeDisconnect(onDisconnect: () => void): Unsubscribe { this.onDisconnect = onDisconnect; return () => { this.onDisconnect = null; }; } async writeFrame(raw: string): Promise { if (this.failWrites) throw new Error('fixture write failed'); @@ -496,6 +502,7 @@ describe('pairing and authenticated connection integration', () => { await delayed.connect(desktop); delayedTransport.onDisconnect?.(); await waitFor(() => delayed.snapshot().kind === 'reconnecting'); + await waitFor(() => typeof resume === 'function'); const disconnect = delayed.disconnect(); resume(); await disconnect; @@ -552,6 +559,194 @@ describe('pairing and authenticated connection integration', () => { await Promise.all([connecting, disconnecting]); expect(manager.snapshot().kind).toBe('idle'); }); + + it('shows reconnecting before native-disconnect cleanup finishes and deduplicates signals', async () => { + const transport = new LoopbackTransport(); + const manager = new ConnectionManager(transport, new MemoryStorage(), new DiagnosticLog(), async () => true, () => 1000, undefined, () => new Promise(() => undefined)); + await manager.connect(desktop); + const nativeDisconnect = transport.onDisconnect!; + let releaseCleanup!: () => void; + transport.disconnectGate = new Promise((resolve) => { releaseCleanup = resolve; }); + + nativeDisconnect(); + nativeDisconnect(); + + expect(manager.snapshot()).toMatchObject({ kind: 'reconnecting', desktop: { desktopId: 'pc-1' }, attempt: 1 }); + expect(manager.diagnostics.snapshot().filter(({ code }) => code === 'connection_lost')).toHaveLength(1); + releaseCleanup(); + transport.disconnectGate = null; + await manager.disconnect(); + }); + + it('shows reconnecting before notification-error cleanup finishes', async () => { + const transport = new LoopbackTransport(); + const manager = new ConnectionManager(transport, new MemoryStorage(), new DiagnosticLog(), async () => true, () => 1000, undefined, () => new Promise(() => undefined)); + await manager.connect(desktop); + let releaseCleanup!: () => void; + transport.disconnectGate = new Promise((resolve) => { releaseCleanup = resolve; }); + + transport.onNotificationError?.(new Error('notification failed')); + + expect(manager.snapshot()).toMatchObject({ kind: 'reconnecting', attempt: 1 }); + releaseCleanup(); + transport.disconnectGate = null; + await manager.disconnect(); + }); + + it('starts reconnecting immediately after a native command write fails', async () => { + const transport = new LoopbackTransport(); + const manager = new ConnectionManager(transport, new MemoryStorage(), new DiagnosticLog(), async () => true, () => 1000, undefined, () => new Promise(() => undefined)); + await manager.connect(desktop); + transport.failWrites = true; + + await expect(manager.send('mouse.click', { button: 'left' })).resolves.toBe(false); + + expect(manager.snapshot()).toMatchObject({ kind: 'reconnecting', attempt: 1 }); + expect(manager.diagnostics.snapshot().some(({ code }) => code === 'connection_lost')).toBe(true); + }); + + it('starts reconnecting when a native command write times out', async () => { + jest.useFakeTimers(); + try { + const transport = new LoopbackTransport(); + const manager = new ConnectionManager(transport, new MemoryStorage(), new DiagnosticLog(), async () => true, () => 1000, undefined, () => new Promise(() => undefined)); + await connectWithFakeTimers(manager); + transport.hangWrites.add('mouse.click'); + const command = manager.send('mouse.click', { button: 'left' }); + await waitForMicrotasks(() => transport.requests.filter((type) => type === 'mouse.click').length === 1); + + await jest.advanceTimersByTimeAsync(5_000); + await expect(command).resolves.toBe(false); + + expect(manager.snapshot()).toMatchObject({ kind: 'reconnecting', attempt: 1 }); + } finally { + jest.useRealTimers(); + } + }); + + it('checks an idle connection after five seconds and reconnects when the check fails', async () => { + jest.useFakeTimers(); + try { + const transport = new LoopbackTransport(); + transport.healthResult = false; + const manager = new ConnectionManager(transport, new MemoryStorage(), new DiagnosticLog(), async () => true, () => 1000, undefined, () => new Promise(() => undefined)); + await connectWithFakeTimers(manager); + + await jest.advanceTimersByTimeAsync(4_999); + expect(transport.healthChecks).toBe(0); + await jest.advanceTimersByTimeAsync(1); + + expect(transport.healthChecks).toBe(1); + expect(manager.snapshot()).toMatchObject({ kind: 'reconnecting', attempt: 1 }); + expect(manager.diagnostics.snapshot().some(({ code }) => code === 'connection_health_failed')).toBe(true); + } finally { + jest.useRealTimers(); + } + }); + + it('defers the idle check after successful activity and does not overlap a running probe', async () => { + jest.useFakeTimers(); + try { + const transport = new LoopbackTransport(); + const manager = new ConnectionManager(transport, new MemoryStorage(), new DiagnosticLog(), async () => true, () => 1000); + await connectWithFakeTimers(manager); + await jest.advanceTimersByTimeAsync(4_000); + const successfulCommand = manager.send('mouse.click', { button: 'left' }); + await waitForMicrotasks(() => transport.requests.filter((type) => type === 'mouse.click').length === 1); + await expect(successfulCommand).resolves.toBe(true); + await jest.advanceTimersByTimeAsync(4_999); + expect(transport.healthChecks).toBe(0); + + let releaseHealth!: () => void; + transport.healthGate = new Promise((resolve) => { releaseHealth = resolve; }); + await jest.advanceTimersByTimeAsync(1); + expect(transport.healthChecks).toBe(1); + const command = manager.send('mouse.click', { button: 'left' }); + await Promise.resolve(); + expect(transport.requests.filter((type) => type === 'mouse.click')).toHaveLength(1); + releaseHealth(); + await waitForMicrotasks(() => transport.requests.filter((type) => type === 'mouse.click').length === 2); + await expect(command).resolves.toBe(true); + expect(transport.requests.filter((type) => type === 'mouse.click')).toHaveLength(2); + await manager.disconnect(); + } finally { + jest.useRealTimers(); + } + }); + + it('uses a health check to distinguish a dropped response from a lost connection', async () => { + jest.useFakeTimers(); + try { + const transport = new LoopbackTransport(); + const manager = new ConnectionManager(transport, new MemoryStorage(), new DiagnosticLog(), async () => true, () => 1000); + await connectWithFakeTimers(manager); + transport.dropResponses.add('mouse.click'); + const command = manager.send('mouse.click', { button: 'left' }); + + await jest.advanceTimersByTimeAsync(5_000); + await expect(command).resolves.toBe(false); + await jest.advanceTimersByTimeAsync(1); + + expect(transport.healthChecks).toBe(1); + expect(manager.snapshot().kind).toBe('connected'); + await manager.disconnect(); + } finally { + jest.useRealTimers(); + } + }); + + it('ignores a stale failed health check after explicit disconnect', async () => { + jest.useFakeTimers(); + try { + let releaseHealth!: () => void; + const transport = new LoopbackTransport(); + transport.healthResult = false; + transport.healthGate = new Promise((resolve) => { releaseHealth = resolve; }); + const manager = new ConnectionManager(transport, new MemoryStorage(), new DiagnosticLog(), async () => true, () => 1000); + await connectWithFakeTimers(manager); + await jest.advanceTimersByTimeAsync(5_000); + expect(transport.healthChecks).toBe(1); + + await manager.disconnect(); + releaseHealth(); + await jest.advanceTimersByTimeAsync(0); + + expect(manager.snapshot().kind).toBe('idle'); + expect(manager.diagnostics.snapshot().some(({ code }) => code === 'connection_health_failed')).toBe(false); + } finally { + jest.useRealTimers(); + } + }); + + it('ignores a stale failed health check after a replacement connection', async () => { + jest.useFakeTimers(); + try { + let releaseHealth!: () => void; + const transport = new LoopbackTransport(); + transport.healthResult = false; + transport.healthGate = new Promise((resolve) => { releaseHealth = resolve; }); + const manager = new ConnectionManager(transport, new MemoryStorage(), new DiagnosticLog(), async () => true, () => 1000); + await connectWithFakeTimers(manager); + await jest.advanceTimersByTimeAsync(5_000); + expect(transport.healthChecks).toBe(1); + + const replacement = { ...desktop, desktopId: 'pc-2', displayName: 'Studio', peripheralId: 'ble-2' }; + transport.resolvedDesktop = replacement; + const connecting = manager.connect(replacement); + releaseHealth(); + await waitForMicrotasks(() => { + const state = manager.snapshot(); + return state.kind === 'connected' && state.desktop.desktopId === 'pc-2'; + }); + await connecting; + await jest.advanceTimersByTimeAsync(0); + + expect(manager.snapshot()).toMatchObject({ kind: 'connected', desktop: { desktopId: 'pc-2' } }); + await manager.disconnect(); + } finally { + jest.useRealTimers(); + } + }); }); async function waitFor(predicate: () => boolean): Promise { @@ -569,3 +764,9 @@ async function waitForMicrotasks(predicate: () => boolean): Promise { } throw new Error('condition was not reached'); } + +async function connectWithFakeTimers(manager: ConnectionManager): Promise { + const connecting = manager.connect(desktop); + await waitForMicrotasks(() => manager.snapshot().kind === 'connected'); + await connecting; +} diff --git a/src/diagnostics/DiagnosticLog.ts b/src/diagnostics/DiagnosticLog.ts index b1389c2..ce4e35d 100644 --- a/src/diagnostics/DiagnosticLog.ts +++ b/src/diagnostics/DiagnosticLog.ts @@ -6,6 +6,8 @@ const messages = { scan_failed: 'Bluetooth discovery could not start.', connecting: 'Connecting to a PC.', connected: 'Connected to a PC.', + connection_lost: 'The connection to the PC was lost.', + connection_health_failed: 'The PC did not respond to a Bluetooth connection check.', profile_recovery_started: 'Restoring remote controls.', profile_recovered: 'Remote controls were restored.', profile_recovery_exhausted: 'Remote controls could not be restored.', diff --git a/src/remote/DisconnectedRemote.test.ts b/src/remote/DisconnectedRemote.test.ts index 7ef8a35..1748290 100644 --- a/src/remote/DisconnectedRemote.test.ts +++ b/src/remote/DisconnectedRemote.test.ts @@ -21,4 +21,10 @@ describe('disconnected Remote flow', () => { title: 'Connecting', message: 'Connecting to Office.', primaryAction: null, chooseAction: null, busy: true, }); }); + + it('presents the first reconnect attempt without exposing controls or actions', () => { + expect(disconnectedRemotePresentation({ kind: 'reconnecting', desktop: { ...saved, rssi: null }, attempt: 1 })).toEqual({ + title: 'Connecting', message: 'Reconnecting to Office, attempt 1.', primaryAction: null, chooseAction: null, busy: true, + }); + }); }); diff --git a/src/remote/RemoteScreen.test.tsx b/src/remote/RemoteScreen.test.tsx index e021cca..63f1ef9 100644 --- a/src/remote/RemoteScreen.test.tsx +++ b/src/remote/RemoteScreen.test.tsx @@ -119,4 +119,13 @@ describe('RemoteScreen sticky surface selector', () => { expect(view.queryByTestId('screen-scroll-to-top-container')).toBeNull(); expect(view.getByRole('button', { name: 'Surface' })).toBeTruthy(); }); + + it('removes connected controls as soon as reconnection starts', async () => { + mockConnection = { kind: 'reconnecting', desktop, attempt: 1 }; + const view = await render(); + + expect(view.queryByText('Mouse controls')).toBeNull(); + expect(view.getByText('Reconnecting to Office PC, attempt 1.')).toBeTruthy(); + expect(view.queryByTestId('screen-scroll-to-top-container')).toBeNull(); + }); }); diff --git a/src/transport/BleTransport.ts b/src/transport/BleTransport.ts index 90997d4..e2dda85 100644 --- a/src/transport/BleTransport.ts +++ b/src/transport/BleTransport.ts @@ -13,6 +13,7 @@ export interface BleTransport { maxWriteValueBytes(): number; writeFrame(frameBase64: string): Promise; cancelPendingWrites(): Promise; + verifyConnection(desktopId: string): Promise; subscribe(onFrame: (frameBase64: string) => void, onError: (error: Error) => void): Unsubscribe; notificationsReady(): Promise; subscribeDisconnect(onDisconnect: () => void): Unsubscribe; diff --git a/src/transport/ReactNativeBleTransport.test.ts b/src/transport/ReactNativeBleTransport.test.ts index 30d945b..8a249cf 100644 --- a/src/transport/ReactNativeBleTransport.test.ts +++ b/src/transport/ReactNativeBleTransport.test.ts @@ -163,6 +163,73 @@ describe('ReactNativeBleTransport', () => { expect(connected.readDescriptorForService).not.toHaveBeenCalled(); }); + it('verifies the current PC without reconnecting or rediscovering services', async () => { + const connected = device(); + const transport = new ReactNativeBleTransport(manager({ connectToDevice: jest.fn(async () => connected) }), 'ios'); + await transport.connect('ble-1'); + (connected.discoverAllServicesAndCharacteristics as jest.Mock).mockClear(); + + await expect(transport.verifyConnection('pc-1')).resolves.toBe(true); + + expect(connected.isConnected).toHaveBeenCalledTimes(1); + expect(connected.readCharacteristicForService).toHaveBeenCalledTimes(1); + expect(connected.discoverAllServicesAndCharacteristics).not.toHaveBeenCalled(); + expect(connected.connect).not.toHaveBeenCalled(); + }); + + it.each([ + ['native disconnect', { isConnected: jest.fn(async () => false) }], + ['read rejection', { readCharacteristicForService: jest.fn(async () => { throw new Error('read failed'); }) }], + ['empty status', { readCharacteristicForService: jest.fn(async () => ({ value: null })) }], + ['malformed status', { readCharacteristicForService: jest.fn(async () => ({ value: fromByteArray(new TextEncoder().encode('not json')) })) }], + ['wrong desktop', { readCharacteristicForService: jest.fn(async () => ({ value: fromByteArray(new TextEncoder().encode('{"protocolVersion":1,"desktopId":"other","displayName":"Desk","platform":"windows"}')) })) }], + ] as const)('reports a failed health check for %s', async (_label, overrides) => { + const connected = device(overrides as Partial); + const transport = new ReactNativeBleTransport(manager({ connectToDevice: jest.fn(async () => connected) }), 'ios'); + await transport.connect('ble-1'); + + await expect(transport.verifyConnection('pc-1')).resolves.toBe(false); + }); + + it('bounds a connection health check to four seconds', async () => { + jest.useFakeTimers(); + try { + const connected = device({ readCharacteristicForService: jest.fn(() => new Promise(() => undefined)) }); + const transport = new ReactNativeBleTransport(manager({ connectToDevice: jest.fn(async () => connected) }), 'ios'); + await transport.connect('ble-1'); + let settled = false; + const result = transport.verifyConnection('pc-1').then((value) => { settled = true; return value; }); + await Promise.resolve(); + + await jest.advanceTimersByTimeAsync(3_999); + expect(settled).toBe(false); + await jest.advanceTimersByTimeAsync(1); + await expect(result).resolves.toBe(false); + } finally { + jest.useRealTimers(); + } + }); + + it('does not continue a timed-out health check into a status read', async () => { + jest.useFakeTimers(); + try { + let resolveConnected!: (connected: boolean) => void; + const connected = device({ isConnected: jest.fn(() => new Promise((resolve) => { resolveConnected = resolve; })) }); + const transport = new ReactNativeBleTransport(manager({ connectToDevice: jest.fn(async () => connected) }), 'ios'); + await transport.connect('ble-1'); + const result = transport.verifyConnection('pc-1'); + + await jest.advanceTimersByTimeAsync(4_000); + await expect(result).resolves.toBe(false); + resolveConnected(true); + await Promise.resolve(); + + expect(connected.readCharacteristicForService).not.toHaveBeenCalled(); + } finally { + jest.useRealTimers(); + } + }); + it('cancels a partial native connection when discovery fails', async () => { const connected = device({ discoverAllServicesAndCharacteristics: jest.fn(async () => { throw new Error('discovery failed'); }) }); const transport = new ReactNativeBleTransport(manager({ connectToDevice: jest.fn(async () => connected) }), 'ios'); diff --git a/src/transport/ReactNativeBleTransport.ts b/src/transport/ReactNativeBleTransport.ts index 76826c9..061cc59 100644 --- a/src/transport/ReactNativeBleTransport.ts +++ b/src/transport/ReactNativeBleTransport.ts @@ -266,6 +266,25 @@ export class ReactNativeBleTransport implements BleTransport { operations.forEach(([, cancel]) => cancel(error)); } + async verifyConnection(desktopId: string): Promise { + let active = true; + try { + const device = this.#requireDevice(); + return await this.#bounded((async () => { + if (!await device.isConnected()) return false; + if (!active) return false; + const characteristic = await device.readCharacteristicForService(BLE_UUIDS.service, BLE_UUIDS.status); + if (!active || !characteristic.value) return false; + const raw = new TextDecoder().decode(toByteArray(characteristic.value)); + return parseStatus(raw)?.desktopId === desktopId; + })(), Math.min(4_000, this.nativeTimeoutMs)); + } catch { + return false; + } finally { + active = false; + } + } + subscribe(onFrame: (frameBase64: string) => void, onError: (error: Error) => void): Unsubscribe { const subscription: Subscription = this.#requireDevice().monitorCharacteristicForService(BLE_UUIDS.service, BLE_UUIDS.transmit, (error, characteristic) => { if (error) onError(error); From 3734d0f839edb32d940a21fa82a258a108e611af Mon Sep 17 00:00:00 2001 From: Owen McGirr Date: Tue, 25 Aug 2026 13:30:37 +0100 Subject: [PATCH 2/3] fix: monitor profile recovery health --- src/connection/ConnectionManager.ts | 37 +++++++++++++++++++---------- src/connection/integration.test.ts | 32 +++++++++++++++++++++++++ 2 files changed, 57 insertions(+), 12 deletions(-) diff --git a/src/connection/ConnectionManager.ts b/src/connection/ConnectionManager.ts index 2591271..0f76c05 100644 --- a/src/connection/ConnectionManager.ts +++ b/src/connection/ConnectionManager.ts @@ -38,7 +38,7 @@ export class ConnectionManager { #invalidSavedDesktopIds = new Set(); #profileRecoveryTimers = new Map, (active: boolean) => void>(); #healthTimer: ReturnType | null = null; - #healthProbe: Promise | null = null; + #healthProbe: Promise | null = null; #protocolOperations = 0; constructor( @@ -356,7 +356,7 @@ export class ConnectionManager { } else { this.#set({ kind: 'connected', desktop, profile: null, profileStatus: 'recovering' }); this.diagnostics.add('profile_recovery_started'); - void this.#recoverPointerProfile(token, operation); + void this.#recoverPointerProfile(token, desktop, operation); } } @@ -369,9 +369,10 @@ export class ConnectionManager { return null; } - async #recoverPointerProfile(token: string, operation: number): Promise { + async #recoverPointerProfile(token: string, desktop: DiscoveredDesktop, operation: number): Promise { for (const delay of [1_000, 2_000, 4_000]) { if (!await this.#waitForProfileRecovery(delay, operation)) return; + if (!await this.#probeHealth(desktop, operation, false)) return; const profile = await this.#requestPointerProfileAttempt(token, operation); if (!this.#current(operation)) return; if (profile) { @@ -382,6 +383,10 @@ export class ConnectionManager { } return; } + // A missing profile response is not itself a disconnect signal. Probe + // immediately so a lost PC is still detected within the request's + // five-second timeout plus the four-second health-check bound. + if (!await this.#probeHealth(desktop, operation, false)) return; } if (this.#current(operation) && this.#state.kind === 'connected' && this.#state.profileStatus === 'recovering') { this.#set({ ...this.#state, profileStatus: 'unavailable' }); @@ -480,19 +485,27 @@ export class ConnectionManager { this.#healthTimer = null; if (!this.#current(operation) || this.#state.kind !== 'connected') return; if (this.#protocolOperations > 0 || this.#healthProbe) { this.#scheduleHealth(); return; } - const probe = this.#runHealthProbe(desktop, operation); - this.#healthProbe = probe; - void probe.finally(() => { if (this.#healthProbe === probe) this.#healthProbe = null; }); + void this.#probeHealth(desktop, operation, true); }, delay); (this.#healthTimer as unknown as { unref?: () => void }).unref?.(); } - async #runHealthProbe(desktop: DiscoveredDesktop, operation: number): Promise { - const healthy = await this.transport.verifyConnection(desktop.desktopId); - if (!this.#current(operation) || this.#state.kind !== 'connected') return; - if (healthy) { this.#scheduleHealth(); return; } - this.diagnostics.add('connection_health_failed', 'warning'); - void this.#unexpectedDisconnect(desktop, operation); + #probeHealth(desktop: DiscoveredDesktop, operation: number, scheduleOnSuccess: boolean): Promise { + if (this.#healthProbe) return this.#healthProbe; + const probe = (async () => { + const healthy = await this.transport.verifyConnection(desktop.desktopId).catch(() => false); + if (!this.#current(operation) || this.#state.kind !== 'connected') return false; + if (healthy) { + if (scheduleOnSuccess) this.#scheduleHealth(); + return true; + } + this.diagnostics.add('connection_health_failed', 'warning'); + void this.#unexpectedDisconnect(desktop, operation); + return false; + })(); + this.#healthProbe = probe; + void probe.finally(() => { if (this.#healthProbe === probe) this.#healthProbe = null; }); + return probe; } #cancelHealthTimer(): void { diff --git a/src/connection/integration.test.ts b/src/connection/integration.test.ts index 9c7ad77..293d4ce 100644 --- a/src/connection/integration.test.ts +++ b/src/connection/integration.test.ts @@ -274,6 +274,38 @@ describe('pairing and authenticated connection integration', () => { } }); + it('detects a lost PC while pointer profile recovery is waiting for a response', async () => { + jest.useFakeTimers(); + try { + const transport = new LoopbackTransport(); + transport.dropResponses.add('pointer.profile'); + const manager = new ConnectionManager(transport, new MemoryStorage(), new DiagnosticLog(), async () => true, () => 1000, undefined, () => new Promise(() => undefined)); + + const connecting = manager.connect(desktop); + await waitForMicrotasks(() => transport.requests.filter((type) => type === 'pointer.profile').length === 1); + await jest.advanceTimersByTimeAsync(5_000); + await waitForMicrotasks(() => transport.requests.filter((type) => type === 'pointer.profile').length === 2); + await jest.advanceTimersByTimeAsync(5_000); + await connecting; + + await jest.advanceTimersByTimeAsync(1_000); + await waitForMicrotasks(() => transport.requests.filter((type) => type === 'pointer.profile').length === 3); + expect(transport.healthChecks).toBe(1); + transport.healthResult = false; + + await jest.advanceTimersByTimeAsync(4_999); + expect(manager.snapshot()).toMatchObject({ kind: 'connected', profileStatus: 'recovering' }); + await jest.advanceTimersByTimeAsync(1); + await waitForMicrotasks(() => manager.snapshot().kind === 'reconnecting'); + + expect(transport.healthChecks).toBe(2); + expect(manager.snapshot()).toMatchObject({ kind: 'reconnecting', attempt: 1 }); + expect(manager.diagnostics.snapshot().some(({ code }) => code === 'connection_health_failed')).toBe(true); + } finally { + jest.useRealTimers(); + } + }); + it('exhausts exactly five authenticated profile requests after 1, 2, and 4 second delays', async () => { jest.useFakeTimers(); try { From b02640d99672c830532f1c1e2f08b1b8d46b2719 Mon Sep 17 00:00:00 2001 From: Owen McGirr Date: Tue, 25 Aug 2026 13:37:12 +0100 Subject: [PATCH 3/3] fix: bound profile recovery monitoring --- src/connection/ConnectionManager.ts | 76 +++++++++++++++++++---------- src/connection/integration.test.ts | 66 +++++++++++++++++++++++++ 2 files changed, 117 insertions(+), 25 deletions(-) diff --git a/src/connection/ConnectionManager.ts b/src/connection/ConnectionManager.ts index 0f76c05..679c9b3 100644 --- a/src/connection/ConnectionManager.ts +++ b/src/connection/ConnectionManager.ts @@ -37,6 +37,7 @@ export class ConnectionManager { #switchIntent = 0; #invalidSavedDesktopIds = new Set(); #profileRecoveryTimers = new Map, (active: boolean) => void>(); + #profileRecoveryDeadline: ReturnType | null = null; #healthTimer: ReturnType | null = null; #healthProbe: Promise | null = null; #protocolOperations = 0; @@ -343,7 +344,7 @@ export class ConnectionManager { } if (response.kind === 'error') this.diagnostics.add('remote_name_sync_failed', 'warning'); this.#token = token; - const profile = await this.#requestPointerProfile(token, operation); + const profile = await this.#requestPointerProfile(token, desktop, operation); if (!this.#current(operation)) return; const saved = { desktopId: desktop.desktopId, displayName: desktop.displayName, platform: desktop.platform, peripheralId: desktop.peripheralId, lastConnectedAt: this.now() }; await this.storage.save(saved, token); @@ -360,9 +361,9 @@ export class ConnectionManager { } } - async #requestPointerProfile(token: string, operation: number): Promise { + async #requestPointerProfile(token: string, desktop: DiscoveredDesktop, operation: number): Promise { for (let attempt = 0; attempt < 2; attempt += 1) { - const profile = await this.#requestPointerProfileAttempt(token, operation); + const profile = await this.#requestPointerProfileAttempt(token, desktop, operation); if (profile) return profile; if (!this.#current(operation)) return null; } @@ -370,40 +371,63 @@ export class ConnectionManager { } async #recoverPointerProfile(token: string, desktop: DiscoveredDesktop, operation: number): Promise { - for (const delay of [1_000, 2_000, 4_000]) { - if (!await this.#waitForProfileRecovery(delay, operation)) return; - if (!await this.#probeHealth(desktop, operation, false)) return; - const profile = await this.#requestPointerProfileAttempt(token, operation); - if (!this.#current(operation)) return; - if (profile) { - if (this.#state.kind === 'connected' && this.#state.profileStatus === 'recovering') { - this.#set({ ...this.#state, profile, profileStatus: 'ready' }); - this.diagnostics.add('profile_recovered'); - this.#scheduleHealth(); + const deadline = setTimeout(() => this.#markProfileUnavailable(operation), 22_000); + this.#profileRecoveryDeadline = deadline; + try { + for (const delay of [1_000, 2_000, 4_000]) { + if (!await this.#waitForProfileRecovery(delay, operation)) return; + if (!await this.#probeHealth(desktop, operation, false) || !this.#profileRecovering(operation)) return; + const profile = await this.#requestPointerProfileAttempt(token, desktop, operation); + if (!this.#current(operation)) return; + if (profile) { + if (this.#state.kind === 'connected' && this.#state.profileStatus === 'recovering') { + this.#set({ ...this.#state, profile, profileStatus: 'ready' }); + this.diagnostics.add('profile_recovered'); + this.#scheduleHealth(); + } + return; } - return; + // A missing profile response is not itself a disconnect signal. Probe + // immediately so a lost PC is still detected within the request's + // five-second timeout plus the four-second health-check bound. + if (!await this.#probeHealth(desktop, operation, false)) return; + if (!this.#profileRecovering(operation)) return; } - // A missing profile response is not itself a disconnect signal. Probe - // immediately so a lost PC is still detected within the request's - // five-second timeout plus the four-second health-check bound. - if (!await this.#probeHealth(desktop, operation, false)) return; - } - if (this.#current(operation) && this.#state.kind === 'connected' && this.#state.profileStatus === 'recovering') { - this.#set({ ...this.#state, profileStatus: 'unavailable' }); - this.diagnostics.add('profile_recovery_exhausted', 'warning'); - this.#scheduleHealth(); + this.#markProfileUnavailable(operation); + } finally { + if (this.#profileRecoveryDeadline === deadline) { + clearTimeout(deadline); + this.#profileRecoveryDeadline = null; + } + if (this.#current(operation) && this.#state.kind === 'connected' && this.#state.profileStatus === 'unavailable') this.#scheduleHealth(); } } - async #requestPointerProfileAttempt(token: string, operation: number): Promise { + async #requestPointerProfileAttempt(token: string, desktop: DiscoveredDesktop, operation: number): Promise { if (!this.#current(operation)) return null; const [type, payload] = commandPayloads.pointerProfile(); const id = this.id(); - const response = await this.#client!.request(authenticatedCommand({ id, deviceId: this.#deviceId!, token, timestamp: this.now(), type, payload }), id, 5_000).catch(() => null); + let response: ProtocolResponse | null = null; + try { + response = await this.#client!.request(authenticatedCommand({ id, deviceId: this.#deviceId!, token, timestamp: this.now(), type, payload }), id, 5_000); + } catch (error) { + if (error instanceof ProtocolWriteError && this.#current(operation)) void this.#unexpectedDisconnect(desktop, operation); + } if (!this.#current(operation)) return null; return response?.kind === 'pointerProfile' ? response.profile : null; } + #profileRecovering(operation: number): boolean { + return this.#current(operation) && this.#state.kind === 'connected' && this.#state.profileStatus === 'recovering'; + } + + #markProfileUnavailable(operation: number): void { + if (!this.#current(operation) || this.#state.kind !== 'connected' || this.#state.profileStatus !== 'recovering') return; + const state = this.#state; + this.#set({ ...state, profileStatus: 'unavailable' }); + this.diagnostics.add('profile_recovery_exhausted', 'warning'); + } + #waitForProfileRecovery(milliseconds: number, operation: number): Promise { return new Promise((resolve) => { const timer = setTimeout(() => { @@ -415,6 +439,8 @@ export class ConnectionManager { } #cancelProfileRecovery(): void { + if (this.#profileRecoveryDeadline !== null) clearTimeout(this.#profileRecoveryDeadline); + this.#profileRecoveryDeadline = null; for (const [timer, resolve] of this.#profileRecoveryTimers) { clearTimeout(timer); resolve(false); diff --git a/src/connection/integration.test.ts b/src/connection/integration.test.ts index 293d4ce..6e0dcba 100644 --- a/src/connection/integration.test.ts +++ b/src/connection/integration.test.ts @@ -26,6 +26,7 @@ class LoopbackTransport implements BleTransport { onDisconnect: (() => void) | null = null; onNotificationError: ((error: Error) => void) | null = null; failWrites = false; + failWriteTypes = new Set(); rejectPairing = false; rejectAuthentication = false; rejectPingCount = 0; @@ -70,6 +71,7 @@ class LoopbackTransport implements BleTransport { this.requests.push(request.type); this.requestPayloads.push({ type: request.type, payload: request.payload }); this.requestIds.push({ id: request.id, type: request.type, authenticated: typeof request.auth === 'string' && request.auth.length > 0 }); + if (this.failWriteTypes.has(request.type)) throw new Error('fixture request write failed'); if (this.hangWrites.has(request.type)) await new Promise(() => undefined); await this.responseGateQueues.get(request.type)?.shift(); await this.responseGates.get(request.type); @@ -274,6 +276,17 @@ describe('pairing and authenticated connection integration', () => { } }); + it('starts reconnecting immediately when an initial profile request write fails', async () => { + const transport = new LoopbackTransport(); + transport.failWriteTypes.add('pointer.profile'); + const manager = new ConnectionManager(transport, new MemoryStorage(), new DiagnosticLog(), async () => true, () => 1000, undefined, () => new Promise(() => undefined)); + + await manager.connect(desktop); + + expect(manager.snapshot()).toMatchObject({ kind: 'reconnecting', attempt: 1 }); + expect(transport.requests.filter((type) => type === 'pointer.profile')).toHaveLength(1); + }); + it('detects a lost PC while pointer profile recovery is waiting for a response', async () => { jest.useFakeTimers(); try { @@ -306,6 +319,59 @@ describe('pairing and authenticated connection integration', () => { } }); + it('starts reconnecting immediately when a recovery profile write fails', async () => { + jest.useFakeTimers(); + try { + const transport = new LoopbackTransport(); + transport.dropResponseCounts.set('pointer.profile', 2); + const manager = new ConnectionManager(transport, new MemoryStorage(), new DiagnosticLog(), async () => true, () => 1000, undefined, () => new Promise(() => undefined)); + + const connecting = manager.connect(desktop); + await waitForMicrotasks(() => transport.requests.filter((type) => type === 'pointer.profile').length === 1); + await jest.advanceTimersByTimeAsync(5_000); + await waitForMicrotasks(() => transport.requests.filter((type) => type === 'pointer.profile').length === 2); + await jest.advanceTimersByTimeAsync(5_000); + await connecting; + transport.failWriteTypes.add('pointer.profile'); + + await jest.advanceTimersByTimeAsync(1_000); + await waitForMicrotasks(() => manager.snapshot().kind === 'reconnecting'); + + expect(manager.snapshot()).toMatchObject({ kind: 'reconnecting', attempt: 1 }); + expect(transport.requests.filter((type) => type === 'pointer.profile')).toHaveLength(3); + } finally { + jest.useRealTimers(); + } + }); + + it('keeps the recovering presentation bounded when a healthy probe is slow', async () => { + jest.useFakeTimers(); + try { + let releaseHealth!: () => void; + const transport = new LoopbackTransport(); + transport.dropResponses.add('pointer.profile'); + const manager = new ConnectionManager(transport, new MemoryStorage(), new DiagnosticLog(), async () => true); + const connecting = manager.connect(desktop); + await waitForMicrotasks(() => transport.requests.filter((type) => type === 'pointer.profile').length === 1); + await jest.advanceTimersByTimeAsync(5_000); + await waitForMicrotasks(() => transport.requests.filter((type) => type === 'pointer.profile').length === 2); + await jest.advanceTimersByTimeAsync(5_000); + await connecting; + transport.healthGate = new Promise((resolve) => { releaseHealth = resolve; }); + + await jest.advanceTimersByTimeAsync(21_999); + expect(manager.snapshot()).toMatchObject({ kind: 'connected', profileStatus: 'recovering' }); + await jest.advanceTimersByTimeAsync(1); + expect(manager.snapshot()).toMatchObject({ kind: 'connected', profileStatus: 'unavailable' }); + + releaseHealth(); + await jest.advanceTimersByTimeAsync(0); + await manager.disconnect(); + } finally { + jest.useRealTimers(); + } + }); + it('exhausts exactly five authenticated profile requests after 1, 2, and 4 second delays', async () => { jest.useFakeTimers(); try {