From c696f5c6fb2ed3e6c260c668dd84b6580c115c7c Mon Sep 17 00:00:00 2001 From: enaboapps <60785457+enaboapps@users.noreply.github.com> Date: Sat, 12 Sep 2026 11:26:21 +0100 Subject: [PATCH] fix: correlate connection attempts and isolate preferred cancellation --- docs/physical-smoke-test.md | 20 ++++ src/connection/ConnectionManager.ts | 96 ++++++++++++------- src/connection/connection.test.ts | 84 +++++++++++++++- src/diagnostics/DiagnosticLog.test.ts | 28 ++++++ src/diagnostics/DiagnosticLog.ts | 54 +++++++++-- src/transport/BleTransport.ts | 5 +- src/transport/ReactNativeBleTransport.test.ts | 21 +++- src/transport/ReactNativeBleTransport.ts | 17 ++-- 8 files changed, 269 insertions(+), 56 deletions(-) diff --git a/docs/physical-smoke-test.md b/docs/physical-smoke-test.md index 6019ddf..9225c35 100644 --- a/docs/physical-smoke-test.md +++ b/docs/physical-smoke-test.md @@ -51,6 +51,26 @@ Run the matrix on a physical Android phone and iPhone against current Switchify 9. While repeat, drag, modifiers, and a text stream are active, background and terminate the app, disconnect Bluetooth, and quit Switchify PC. Confirm the desktop returns to neutral input state each time. 10. Export diagnostics and verify that no typed content, token, authentication proof, nonce, or verification code appears. +### Correlating connection attempts + +Connection events and BLE stages include local labels such as `[attempt-3 PC-2 switch]`. +Origins distinguish nearby selection, saved selection, preferred auto-connect, explicit switching, +and reconnect retries. `ble_peer_observed` records an anonymous candidate and its advertised reply +mode (`read-v1` or `notifications`); compare this with the selected PC label and `selected_match`. +Read-based readiness has its own `response_read_ready` stage. + +Teardown events record the observed trigger: explicit disconnect, scan, switch, focus cancellation, +native disconnect, response failure, write failure, health failure, or failed connection setup. +These are triggers, not inferred radio/OS root causes. A superseded attempt and a new attempt have +different labels, even when they target the same PC. Confirm that foregrounding does not replace +an active manual connection with the preferred PC, and that quick switches cannot revive an old attempt. + +Labels are not names, addresses, credentials, or persistent desktop identifiers. The log keeps +200 entries and at most 128 identity-to-label mappings in memory; eviction can assign a later label +to an old PC. Clear removes the mappings and invalidates old correlation contexts; restart also +starts a new label namespace. Do not compare these labels across exports separated by clear/restart. +No new telemetry or persistence is added. Physical reconnect success must still be recorded separately. + The development-preview PR remains draft until all four platform pairings are recorded successfully. ### Movement width regression diff --git a/src/connection/ConnectionManager.ts b/src/connection/ConnectionManager.ts index 679c9b3..b9e9a0f 100644 --- a/src/connection/ConnectionManager.ts +++ b/src/connection/ConnectionManager.ts @@ -2,7 +2,7 @@ import * as Crypto from 'expo-crypto'; import { authenticatedCommand, commandPayloads, pairingRequest } from '@/domain/protocol/commands'; import type { JsonObject, PointerProfile, ProtocolResponse } from '@/domain/protocol/types'; -import { DiagnosticLog } from '@/diagnostics/DiagnosticLog'; +import { DiagnosticLog, type DiagnosticAttempt, type AttemptSource } from '@/diagnostics/DiagnosticLog'; import type { PairingStorage, SavedPc } from '@/storage/PairingStore'; import type { BleAvailability, BleTransport, DiscoveredDesktop, Unsubscribe } from '@/transport/BleTransport'; import { ProtocolClient, ProtocolWriteError } from './ProtocolClient'; @@ -33,6 +33,7 @@ export class ConnectionManager { #cleanups = new Set(); #operation = 0; #preferredConnect: Promise | null = null; + #preferredOperation: number | null = null; #disconnecting: Promise | null = null; #switchIntent = 0; #invalidSavedDesktopIds = new Set(); @@ -41,6 +42,14 @@ export class ConnectionManager { #healthTimer: ReturnType | null = null; #healthProbe: Promise | null = null; #protocolOperations = 0; + #attempt: DiagnosticAttempt | undefined; + #record(code: Parameters[0], level: Parameters[1] = 'info'): void { + this.diagnostics.add(code, level, this.#attempt); + } + #startAttempt(desktopId: string, source: AttemptSource): void { + if (this.#attempt) this.#record('attempt_superseded'); + this.#attempt = this.diagnostics.beginAttempt(desktopId, source); + } constructor( private readonly transport: BleTransport, @@ -64,7 +73,9 @@ export class ConnectionManager { } async scan(): Promise { + this.#record('teardown_scan'); await this.disconnect(false); + this.#attempt = undefined; const operation = ++this.#operation; const saved = await this.#orderedSaved(); if (!this.#current(operation)) return; @@ -73,7 +84,7 @@ export class ConnectionManager { const availability = await this.transport.availability(); if (!this.#current(operation)) return; if (availability !== 'ready') { this.#set(this.#availabilityState(availability, saved)); return; } - this.diagnostics.add('scan_started'); + this.#record('scan_started'); const discovered = new Map(); this.#set({ kind: 'scanning', saved, discovered: [] }); this.#scanStop = this.transport.scan((desktop) => { @@ -85,13 +96,14 @@ export class ConnectionManager { async connect(desktop: DiscoveredDesktop): Promise { const operation = ++this.#operation; + this.#startAttempt(desktop.desktopId, 'nearby'); this.#scanStop?.(); this.#scanStop = null; await this.#teardownConnection(); if (!this.#current(operation)) return; this.#set({ kind: 'connecting', desktop }); - this.diagnostics.add('connecting'); + this.#record('connecting'); try { - const resolved = await this.transport.resolveAndConnect(desktop.desktopId); + const resolved = await this.transport.resolveAndConnect(desktop.desktopId, this.#attempt); if (!this.#current(operation)) return; await this.#connectDesktop(resolved, operation, true, true); } catch { @@ -99,8 +111,10 @@ export class ConnectionManager { } } - async connectSaved(pc: SavedPc): Promise { + async connectSaved(pc: SavedPc, source: AttemptSource = 'saved'): Promise { const operation = ++this.#operation; + if (source === 'preferred') this.#preferredOperation = operation; + this.#startAttempt(pc.desktopId, source); this.#scanStop?.(); this.#scanStop = null; await this.#teardownConnection(); if (!this.#current(operation)) return; @@ -135,9 +149,9 @@ export class ConnectionManager { const savedDesktop: DiscoveredDesktop = { ...pc, rssi: null }; this.#set({ kind: 'connecting', desktop: savedDesktop }); - this.diagnostics.add('connecting'); + this.#record('connecting'); try { - const resolved = await this.transport.resolveAndConnect(pc.desktopId); + const resolved = await this.transport.resolveAndConnect(pc.desktopId, this.#attempt); if (!this.#current(operation)) return; await this.#connectDesktop(resolved, operation, true, true, token); } catch { @@ -148,13 +162,15 @@ export class ConnectionManager { async switchSaved(pc: SavedPc): Promise { const intent = ++this.#switchIntent; if ('desktop' in this.#state && this.#state.desktop.desktopId === pc.desktopId && !this.#disconnecting) return; + this.#record('teardown_switch'); await this.#beginDisconnect(false); if (intent !== this.#switchIntent) return; - await this.connectSaved(pc); + await this.connectSaved(pc, 'switch'); } async connectPreferred(): Promise { if (this.#preferredConnect) return this.#preferredConnect; + this.#preferredOperation = this.#operation; const attempt = (async () => { await this.#disconnecting; if (this.#state.kind === 'connected' || this.#state.kind === 'connecting' || this.#state.kind === 'pairing' || this.#state.kind === 'reconnecting' || this.#state.kind === 'scanning') return; @@ -165,27 +181,28 @@ export class ConnectionManager { this.#set({ kind: 'idle', saved: [] }); return; } - await this.connectSaved(saved[0]!); + await this.connectSaved(saved[0]!, 'preferred'); })(); this.#preferredConnect = attempt; try { await attempt; } - finally { if (this.#preferredConnect === attempt) this.#preferredConnect = null; } + finally { if (this.#preferredConnect === attempt) { this.#preferredConnect = null; this.#preferredOperation = null; } } } async cancelPreferredConnection(): Promise { - if (!this.#preferredConnect || this.#state.kind === 'connected') return; + if (!this.#preferredConnect || this.#preferredOperation !== this.#operation || this.#state.kind === 'connected') return; + this.#record('teardown_focus'); await this.disconnect(false); } async #connectDesktop(desktop: DiscoveredDesktop, operation: number, announced = false, transportConnected = false, savedToken: string | null = null): Promise { this.#set({ kind: 'connecting', desktop }); - if (!announced) this.diagnostics.add('connecting'); + if (!announced) this.#record('connecting'); try { - if (!transportConnected) await this.transport.connect(desktop.peripheralId); + if (!transportConnected) await this.transport.connect(desktop.peripheralId, this.#attempt); if (!this.#current(operation)) return; - this.#disconnectStop = this.transport.subscribeDisconnect(() => void this.#unexpectedDisconnect(desktop, operation)); + this.#disconnectStop = this.transport.subscribeDisconnect(() => void this.#unexpectedDisconnect(desktop, operation, 'teardown_native')); const client = new ProtocolClient(this.transport, this.id); - await client.start(() => void this.#unexpectedDisconnect(desktop, operation)); + await client.start(() => void this.#unexpectedDisconnect(desktop, operation, 'teardown_response')); if (!this.#current(operation)) { await client.close(); return; } this.#client = client; this.#deviceId = await this.storage.getDeviceId(); @@ -209,7 +226,7 @@ export class ConnectionManager { await this.load(); return true; } catch { - this.diagnostics.add('unpair_failed', 'warning'); + this.#record('unpair_failed', 'warning'); this.#set({ kind: 'failed', message: 'Could not remove this saved PC.', saved: await this.#orderedSaved() }); return false; } @@ -223,6 +240,7 @@ export class ConnectionManager { } async disconnect(record = true): Promise { + if (record) this.#record('teardown_requested'); this.#switchIntent += 1; await this.#beginDisconnect(record); } @@ -230,6 +248,7 @@ export class ConnectionManager { async #beginDisconnect(record: boolean): Promise { if (this.#disconnecting) return this.#disconnecting; this.#preferredConnect = null; + this.#preferredOperation = null; const attempt = this.#disconnect(record); this.#disconnecting = attempt; try { await attempt; } @@ -245,7 +264,7 @@ export class ConnectionManager { for (const cleanup of [...this.#cleanups]) await Promise.resolve(cleanup()).catch(() => undefined); await this.#teardownConnection(); if (!this.#current(operation)) return; - if (record) { this.diagnostics.add('cleanup_complete'); this.diagnostics.add('disconnected'); } + if (record) { this.#record('cleanup_complete'); this.#record('disconnected'); } const saved = await this.#orderedSaved(); if (this.#current(operation)) this.#set({ kind: 'idle', saved }); } @@ -282,12 +301,12 @@ export class ConnectionManager { if (response.kind === 'switchProfileCatalog') return response; if (response.kind === 'error' && response.code === 'invalid_auth') await this.#fail('Saved access is no longer valid.', this.#operation, true); if (response.kind === 'error' && response.code === 'name_update_failed') { - this.diagnostics.add('remote_name_sync_failed', 'warning'); + this.#record('remote_name_sync_failed', 'warning'); return response; } } catch (error) { if (error instanceof ProtocolWriteError || responseMode === 'none') { - void this.#unexpectedDisconnect(desktop, sourceOperation); + void this.#unexpectedDisconnect(desktop, sourceOperation, 'teardown_write'); } else { shouldProbe = true; } @@ -297,7 +316,7 @@ export class ConnectionManager { this.#scheduleHealth(healthyActivity ? 5_000 : shouldProbe ? 0 : 5_000); } } - this.diagnostics.add('command_failed', 'warning'); + if (this.#current(sourceOperation)) this.#record('command_failed', 'warning'); return null; } @@ -312,13 +331,13 @@ export class ConnectionManager { const requestId = this.id(); const nonce = Crypto.randomUUID(); this.#set({ kind: 'pairing', desktop, verificationCode: pairingVerificationCode(desktop.desktopId, this.#deviceId!, nonce) }); - this.diagnostics.add('pairing_requested'); + this.#record('pairing_requested'); const deviceName = await this.getRemoteName(); if (!this.#current(operation)) return; const response = await this.#client!.request(pairingRequest({ id: requestId, deviceId: this.#deviceId!, deviceName, desktopId: desktop.desktopId, requestNonce: nonce }), requestId, 60_000); if (!this.#current(operation)) return; if (response.kind !== 'pairingComplete' || response.desktopId !== desktop.desktopId || response.deviceId !== this.#deviceId) { - if (response.kind === 'error') this.diagnostics.add('pairing_rejected', 'warning'); + if (response.kind === 'error') this.#record('pairing_rejected', 'warning'); throw new Error('Pairing was not completed.'); } await this.storage.save({ desktopId: desktop.desktopId, displayName: desktop.displayName, platform: desktop.platform, peripheralId: desktop.peripheralId, lastConnectedAt: this.now() }, response.token); @@ -342,7 +361,7 @@ export class ConnectionManager { } throw new Error('Authentication failed.'); } - if (response.kind === 'error') this.diagnostics.add('remote_name_sync_failed', 'warning'); + if (response.kind === 'error') this.#record('remote_name_sync_failed', 'warning'); this.#token = token; const profile = await this.#requestPointerProfile(token, desktop, operation); if (!this.#current(operation)) return; @@ -350,13 +369,13 @@ export class ConnectionManager { await this.storage.save(saved, token); this.#invalidSavedDesktopIds.delete(desktop.desktopId); if (!this.#current(operation)) return; - this.diagnostics.add('connected'); + this.#record('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'); + this.#record('profile_recovery_started'); void this.#recoverPointerProfile(token, desktop, operation); } } @@ -382,7 +401,7 @@ export class ConnectionManager { if (profile) { if (this.#state.kind === 'connected' && this.#state.profileStatus === 'recovering') { this.#set({ ...this.#state, profile, profileStatus: 'ready' }); - this.diagnostics.add('profile_recovered'); + this.#record('profile_recovered'); this.#scheduleHealth(); } return; @@ -411,7 +430,7 @@ export class ConnectionManager { 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 (error instanceof ProtocolWriteError && this.#current(operation)) void this.#unexpectedDisconnect(desktop, operation, 'teardown_write'); } if (!this.#current(operation)) return null; return response?.kind === 'pointerProfile' ? response.profile : null; @@ -425,7 +444,7 @@ export class ConnectionManager { 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'); + this.#record('profile_recovery_exhausted', 'warning'); } #waitForProfileRecovery(milliseconds: number, operation: number): Promise { @@ -448,12 +467,13 @@ export class ConnectionManager { this.#profileRecoveryTimers.clear(); } - async #unexpectedDisconnect(desktop: DiscoveredDesktop, sourceOperation: number): Promise { + async #unexpectedDisconnect(desktop: DiscoveredDesktop, sourceOperation: number, reason: 'teardown_native' | 'teardown_response' | 'teardown_write' | 'teardown_health'): Promise { if (!this.#current(sourceOperation) || this.#state.kind === 'idle' || this.#state.kind === 'reconnecting' || this.#state.kind === 'failed') return; + this.#record(reason, 'warning'); const operation = ++this.#operation; this.#cancelHealthTimer(); this.#set({ kind: 'reconnecting', desktop, attempt: 1 }); - this.diagnostics.add('connection_lost', 'warning'); + this.#record('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; } @@ -463,11 +483,12 @@ export class ConnectionManager { await this.reconnectDelay(attempt * 500); if (!this.#current(operation)) return; try { - const resolved = await this.transport.resolveAndConnect(desktop.desktopId); + this.#startAttempt(desktop.desktopId, 'reconnect'); + const resolved = await this.transport.resolveAndConnect(desktop.desktopId, this.#attempt); if (!this.#current(operation)) return; - this.#disconnectStop = this.transport.subscribeDisconnect(() => void this.#unexpectedDisconnect(resolved, operation)); + this.#disconnectStop = this.transport.subscribeDisconnect(() => void this.#unexpectedDisconnect(resolved, operation, 'teardown_native')); const client = new ProtocolClient(this.transport, this.id); - await client.start(() => void this.#unexpectedDisconnect(resolved, operation)); + await client.start(() => void this.#unexpectedDisconnect(resolved, operation, 'teardown_response')); if (!this.#current(operation)) { await client.close(); return; } this.#client = client; this.#deviceId = await this.storage.getDeviceId(); @@ -484,11 +505,12 @@ export class ConnectionManager { async #fail(message: string, operation: number, auth = false): Promise { if (!this.#current(operation)) return; + this.#record('teardown_failure', 'warning'); const saved = await this.#orderedSaved(); if (!this.#current(operation)) return; await this.#teardownConnection(); if (!this.#current(operation)) return; - if (auth) this.diagnostics.add('authentication_failed', 'error'); + if (auth) this.#record('authentication_failed', 'error'); this.#set({ kind: 'failed', message, saved }); } @@ -525,8 +547,8 @@ export class ConnectionManager { if (scheduleOnSuccess) this.#scheduleHealth(); return true; } - this.diagnostics.add('connection_health_failed', 'warning'); - void this.#unexpectedDisconnect(desktop, operation); + this.#record('connection_health_failed', 'warning'); + void this.#unexpectedDisconnect(desktop, operation, 'teardown_health'); return false; })(); this.#healthProbe = probe; @@ -549,7 +571,7 @@ export class ConnectionManager { if (!this.#current(operation)) return; const availability = await this.transport.availability().catch(() => 'poweredOff' as BleAvailability); if (!this.#current(operation)) return; - this.diagnostics.add('scan_failed', 'error'); + this.#record('scan_failed', 'error'); this.#set(availability === 'ready' ? { kind: 'failed', message: 'Bluetooth discovery could not start.', saved } : this.#availabilityState(availability, saved)); } diff --git a/src/connection/connection.test.ts b/src/connection/connection.test.ts index c82ed1a..6c5ac58 100644 --- a/src/connection/connection.test.ts +++ b/src/connection/connection.test.ts @@ -1,4 +1,4 @@ -import { DiagnosticLog } from '@/diagnostics/DiagnosticLog'; +import { DiagnosticLog, type DiagnosticAttempt } from '@/diagnostics/DiagnosticLog'; import type { PairingStorage, SavedPc } from '@/storage/PairingStore'; import type { BleAvailability, BleTransport, DiscoveredDesktop, Unsubscribe } from '@/transport/BleTransport'; import { ConnectionManager } from './ConnectionManager'; @@ -29,6 +29,7 @@ class FakeTransport implements BleTransport { resolveGate: Promise | null = null; resolveGates = new Map>(); resolveDesktopIds: string[] = []; + attempts: DiagnosticAttempt[] = []; resolveStarted: ((desktopId: string) => void) | null = null; failConnect = false; failReadiness = false; @@ -42,7 +43,8 @@ class FakeTransport implements BleTransport { await this.connectGate; if (this.failConnect) throw new Error('connect failed'); }; disconnect = async () => undefined; writeFrame = async () => undefined; - resolveAndConnect = async (desktopId: string) => { + resolveAndConnect = async (desktopId: string, attempt?: DiagnosticAttempt) => { + if (attempt) this.attempts.push(attempt); this.resolveDesktopIds.push(desktopId); this.resolveStarted?.(desktopId); await (this.resolveGates.get(desktopId) ?? this.resolveGate); @@ -64,6 +66,84 @@ const waitFor = async (condition: () => boolean): Promise => { }; describe('connection lifecycle', () => { + it('keeps overlapping manual attempts separate and ignores late completion', async () => { + const storage = new FakeStorage(); + storage.saved = [pc('private-first'), pc('private-second')]; + storage.saved.forEach((p) => storage.tokens.set(p.desktopId, 'private-token')); + const transport = new FakeTransport(); + let release!: () => void; + transport.resolveGates.set('private-first', new Promise((resolve) => { release = resolve; })); + transport.resolveError = new Error('private native error'); + const log = new DiagnosticLog(); + const manager = new ConnectionManager(transport, storage, log, async () => true); + const first = manager.connectSaved(storage.saved[0]!); + await waitFor(() => transport.resolveDesktopIds.length === 1); + await manager.connectSaved(storage.saved[1]!); + const completed = log.export(); + release(); + await first; + expect(log.export()).toBe(completed); + expect(transport.attempts).toMatchObject([{ attempt: 1, pc: 1, source: 'saved' }, { attempt: 2, pc: 2, source: 'saved' }]); + expect(completed).toContain('[attempt-1 PC-1 saved] A new connection request'); + expect(completed).toContain('[attempt-2 PC-2 saved] Connection attempt failed'); + expect(completed).not.toContain('private'); + }); + + it('does not let preferred auto-connect replace a pending manual target', async () => { + const storage = new FakeStorage(); + storage.saved = [pc('preferred-private'), pc('manual-private')]; + storage.saved.forEach((p) => storage.tokens.set(p.desktopId, 'private-token')); + const transport = new FakeTransport(); + let release!: () => void; + transport.resolveGate = new Promise((resolve) => { release = resolve; }); + transport.resolveError = new Error('private error'); + const manager = new ConnectionManager(transport, storage, new DiagnosticLog(), async () => true); + const manual = manager.connectSaved(storage.saved[1]!); + await waitFor(() => transport.resolveDesktopIds.length === 1); + await manager.connectPreferred(); + expect(transport.resolveDesktopIds).toEqual(['manual-private']); + release(); + await manual; + }); + + it('distinguishes automatic preferred requests from explicit switches', async () => { + const storage = new FakeStorage(); + storage.saved = [pc('preferred-private'), pc('switched-private')]; + storage.saved.forEach((p) => storage.tokens.set(p.desktopId, 'private-token')); + const transport = new FakeTransport(); + transport.resolveError = new Error('private error'); + const log = new DiagnosticLog(); + const manager = new ConnectionManager(transport, storage, log, async () => true); + await manager.connectPreferred(); + await manager.switchSaved(storage.saved[1]!); + expect(transport.attempts).toMatchObject([{ source: 'preferred', pc: 1 }, { source: 'switch', pc: 2 }]); + expect(log.export()).toContain('teardown_switch'); + expect(log.export()).not.toContain('private'); + }); + + it('does not cancel a manual replacement when an older preferred attempt loses focus', async () => { + const storage = new FakeStorage(); + storage.saved = [pc('preferred-private'), pc('manual-private')]; + storage.saved.forEach((p) => storage.tokens.set(p.desktopId, 'private-token')); + const transport = new FakeTransport(); + let releasePreferred!: () => void; + let releaseManual!: () => void; + transport.resolveGates.set('preferred-private', new Promise((resolve) => { releasePreferred = resolve; })); + transport.resolveGates.set('manual-private', new Promise((resolve) => { releaseManual = resolve; })); + transport.resolveError = new Error('private error'); + const log = new DiagnosticLog(); + const manager = new ConnectionManager(transport, storage, log, async () => true); + const preferred = manager.connectPreferred(); + await waitFor(() => transport.resolveDesktopIds.length === 1); + const manual = manager.connectSaved(storage.saved[1]!); + await waitFor(() => transport.resolveDesktopIds.length === 2); + await manager.cancelPreferredConnection(); + const state = manager.snapshot(); + releasePreferred(); releaseManual(); + await Promise.all([preferred, manual]); + expect(state).toMatchObject({ kind: 'connecting', desktop: { desktopId: 'manual-private' } }); + expect(log.snapshot().some((entry) => entry.code === 'teardown_focus')).toBe(false); + }); it('matches the Android pairing verification algorithm', () => { expect(pairingVerificationCode('desktop-1', 'device-1', 'nonce-1')).toBe('215918'); expect(pairingVerificationCode('0:0:1280:720:1.5', 'android-device-id', 'random-request-nonce')).toBe('735258'); diff --git a/src/diagnostics/DiagnosticLog.test.ts b/src/diagnostics/DiagnosticLog.test.ts index 7b3163b..9341169 100644 --- a/src/diagnostics/DiagnosticLog.test.ts +++ b/src/diagnostics/DiagnosticLog.test.ts @@ -1,6 +1,34 @@ import { DiagnosticLog } from './DiagnosticLog'; describe('connection-stage diagnostics', () => { + it('correlates attempts with anonymous PCs without exporting identifying input', () => { + const log = new DiagnosticLog(); + const first = log.beginAttempt('private-desktop-id', 'saved'); + const second = log.beginAttempt('another-private-id', 'preferred'); + const third = log.beginAttempt('private-desktop-id', 'switch'); + expect(first.pc).toBe(third.pc); + expect(second.pc).not.toBe(first.pc); + log.addConnectionStage('resolution', 'started', first); + log.peerObserved('another-private-id', true, first); + log.add('teardown_native', 'warning', third); + expect(log.export()).toContain('[attempt-1 PC-1 saved]'); + expect(log.export()).toContain('Observed PC-2; replies=read-v1'); + expect(log.export()).toContain('[attempt-3 PC-1 switch]'); + expect(log.export()).not.toMatch(/private|desktop-id/); + }); + + it('bounds aliases, clears correlation state, and isolates throwing observers', () => { + const log = new DiagnosticLog(); + log.subscribe(() => { throw new Error('private observer error'); }); + const first = log.beginAttempt('first-private-id', 'saved'); + for (let n = 0; n < 130; n++) log.beginAttempt(`private-${n}`, 'nearby'); + expect(log.beginAttempt('first-private-id', 'saved').pc).not.toBe(first.pc); + log.clear(); + expect(log.export()).toBe(''); + log.add('connected', 'info', first); + expect(log.export()).not.toContain('attempt-1'); + expect(log.export()).not.toContain('private'); + }); it('uses the existing bounded, observable and clearable local log', () => { const log = new DiagnosticLog(); const listener = jest.fn(); diff --git a/src/diagnostics/DiagnosticLog.ts b/src/diagnostics/DiagnosticLog.ts index b1c7490..85d119d 100644 --- a/src/diagnostics/DiagnosticLog.ts +++ b/src/diagnostics/DiagnosticLog.ts @@ -1,5 +1,7 @@ export type DiagnosticLevel = 'info' | 'warning' | 'error'; export type DiagnosticEntry = { id: number; timestamp: number; level: DiagnosticLevel; code: string; message: string }; +export type AttemptSource = 'nearby' | 'saved' | 'preferred' | 'switch' | 'reconnect'; +export type DiagnosticAttempt = Readonly<{ attempt: number; pc: number; source: AttemptSource }>; const connectionStages = { connect: 'Connect to the selected PC', @@ -14,11 +16,23 @@ const connectionStages = { resolution: 'Resolve and prepare the selected PC connection', notifications: 'Register notification listener', notification_ready: 'Verify Android notification descriptor', + response_read_ready: 'Prepare read-based responses', } as const; export type ConnectionStage = keyof typeof connectionStages; export type ConnectionStageOutcome = 'started' | 'succeeded' | 'failed' | 'not_matched' | 'timed_out'; const messages = { + attempt_started: 'Connection attempt started.', + attempt_superseded: 'A new connection request superseded this attempt.', + teardown_requested: 'Explicit disconnect requested.', + teardown_switch: 'Switch to another saved PC requested.', + teardown_scan: 'Disconnect to start discovery.', + teardown_focus: 'Preferred connection cancelled when leaving the screen.', + teardown_failure: 'Connection attempt failed; cleaning up.', + teardown_native: 'Native Bluetooth disconnect observed.', + teardown_response: 'Response channel failed.', + teardown_write: 'Bluetooth write failed.', + teardown_health: 'Connection health check failed.', scan_started: 'Looking for nearby PCs.', scan_failed: 'Bluetooth discovery could not start.', connecting: 'Connecting to a PC.', @@ -42,19 +56,45 @@ export class DiagnosticLog { #entries: DiagnosticEntry[] = []; #listeners = new Set<() => void>(); #nextId = 1; + #nextAttempt = 1; + #nextPc = 1; + #pcs = new Map(); + #attempts = new WeakSet(); + beginAttempt(desktopId: string, source: AttemptSource): DiagnosticAttempt { + const safeSource: AttemptSource = ['nearby', 'saved', 'preferred', 'switch', 'reconnect'].includes(source) ? source : 'saved'; + const attempt = Object.freeze({ attempt: this.#nextAttempt++, pc: this.#pc(desktopId), source: safeSource }); + this.#attempts.add(attempt); + this.add('attempt_started', 'info', attempt); + return attempt; + } + #pc(desktopId: string): number { + const existing = this.#pcs.get(desktopId); + if (existing !== undefined) return existing; + const pc = this.#nextPc++; + if (desktopId.length <= 128) { + if (this.#pcs.size >= 128) this.#pcs.delete(this.#pcs.keys().next().value!); + this.#pcs.set(desktopId, pc); + } + return pc; + } + peerObserved(desktopId: string, readReplies: boolean, attempt?: DiagnosticAttempt, matchesSelected = false): void { + if (!attempt || !this.#attempts.has(attempt)) return; + this.#append('ble_peer_observed', `Observed PC-${matchesSelected ? attempt.pc : this.#pc(desktopId)}; replies=${readReplies ? 'read-v1' : 'notifications'}.`, 'info', attempt); + } subscribe = (listener: () => void) => { this.#listeners.add(listener); return () => this.#listeners.delete(listener); }; snapshot = () => this.#entries; - add(code: keyof typeof messages, level: DiagnosticLevel = 'info'): void { - this.#append(code, messages[code], level); + add(code: keyof typeof messages, level: DiagnosticLevel = 'info', attempt?: DiagnosticAttempt): void { + this.#append(code, messages[code], level, attempt); } - addConnectionStage(stage: ConnectionStage, outcome: ConnectionStageOutcome): void { + addConnectionStage(stage: ConnectionStage, outcome: ConnectionStageOutcome, attempt?: DiagnosticAttempt): void { // Only fixed vocabulary crosses this boundary: no native error, address or payload. - this.#append(`ble_${stage}_${outcome}`, `${connectionStages[stage]}: ${outcome}.`, outcome === 'failed' || outcome === 'timed_out' ? 'warning' : 'info'); + this.#append(`ble_${stage}_${outcome}`, `${connectionStages[stage]}: ${outcome}.`, outcome === 'failed' || outcome === 'timed_out' ? 'warning' : 'info', attempt); } - #append(code: string, message: string, level: DiagnosticLevel): void { + #append(code: string, message: string, level: DiagnosticLevel, attempt?: DiagnosticAttempt): void { + if (attempt && this.#attempts.has(attempt)) message = `[attempt-${attempt.attempt} PC-${attempt.pc} ${attempt.source}] ${message}`; this.#entries = [{ id: this.#nextId++, timestamp: Date.now(), level, code, message }, ...this.#entries].slice(0, 200); - this.#listeners.forEach((listener) => listener()); + this.#listeners.forEach((listener) => { try { listener(); } catch { /* Diagnostics must not affect connections. */ } }); } - clear(): void { this.#entries = []; this.#listeners.forEach((listener) => listener()); } + clear(): void { this.#entries = []; this.#pcs.clear(); this.#attempts = new WeakSet(); this.#listeners.forEach((listener) => { try { listener(); } catch { /* Observer isolation. */ } }); } export(): string { return this.#entries.map((entry) => `${new Date(entry.timestamp).toISOString()} [${entry.level}] ${entry.code}: ${entry.message}`).join('\n'); } } diff --git a/src/transport/BleTransport.ts b/src/transport/BleTransport.ts index e2dda85..99ca74b 100644 --- a/src/transport/BleTransport.ts +++ b/src/transport/BleTransport.ts @@ -1,4 +1,5 @@ import type { PcStatus } from '@/domain/protocol/types'; +import type { DiagnosticAttempt } from '@/diagnostics/DiagnosticLog'; export type DiscoveredDesktop = PcStatus & { peripheralId: string; rssi: number | null }; export type Unsubscribe = () => void; @@ -7,8 +8,8 @@ export type BleAvailability = 'ready' | 'unauthorized' | 'poweredOff' | 'unsuppo export interface BleTransport { availability(): Promise; scan(onDesktop: (desktop: DiscoveredDesktop) => void, onError: (error: Error) => void): Unsubscribe; - connect(peripheralId: string): Promise; - resolveAndConnect(desktopId: string): Promise; + connect(peripheralId: string, attempt?: DiagnosticAttempt): Promise; + resolveAndConnect(desktopId: string, attempt?: DiagnosticAttempt): Promise; disconnect(): Promise; maxWriteValueBytes(): number; writeFrame(frameBase64: string): Promise; diff --git a/src/transport/ReactNativeBleTransport.test.ts b/src/transport/ReactNativeBleTransport.test.ts index faf26ae..2851f3b 100644 --- a/src/transport/ReactNativeBleTransport.test.ts +++ b/src/transport/ReactNativeBleTransport.test.ts @@ -30,6 +30,21 @@ function manager(overrides: Record = {}): BleManager { } describe('ReactNativeBleTransport', () => { + it('links observed peers and BLE stages to the selected attempt without identifiers', async () => { + const log = new DiagnosticLog(); + let callback!: (error: Error | null, value: Device | null) => void; + const transport = new ReactNativeBleTransport(manager({ startDeviceScan: jest.fn((_u, _o, cb) => { callback = cb; }) }), 'ios', 1000, undefined, log); + const attempt = log.beginAttempt('pc-1', 'saved'); + const result = transport.resolveAndConnect('pc-1', attempt); + await waitFor(() => !!callback); + callback(null, device({ id: 'private-address', name: 'private-name' })); + await result; + expect(log.export()).toContain('ble_peer_observed: [attempt-1 PC-1 saved] Observed PC-1; replies=notifications.'); + expect(log.export()).toContain('ble_resolution_succeeded: [attempt-1 PC-1 saved]'); + expect(log.export()).not.toMatch(/private|pc-1/); + await transport.disconnect(); + }); + it('uses peer-specific reads for negotiated Linux replies, never notifications', async () => { const connected = device({ readCharacteristicForService: jest.fn(async (_service, characteristic) => ({ value: characteristic.endsWith('7eb-1d6d-4d92-9ef0-1f89d3db21f4') @@ -37,10 +52,12 @@ describe('ReactNativeBleTransport', () => { : '', } as Characteristic)) }); const native = manager({ connectToDevice: jest.fn(async () => connected) }); - const transport = new ReactNativeBleTransport(native, 'ios'); - await transport.connect('ble-1'); + const log = new DiagnosticLog(); + const transport = new ReactNativeBleTransport(native, 'ios', 10_000, undefined, log); + await transport.connect('ble-1', log.beginAttempt('pc-1', 'saved')); const stop = transport.subscribe(jest.fn(), jest.fn()); await transport.notificationsReady(); + expect(log.export()).toContain('ble_response_read_ready_succeeded: [attempt-1 PC-1 saved]'); expect(connected.monitorCharacteristicForService).not.toHaveBeenCalled(); expect(connected.readDescriptorForService).not.toHaveBeenCalled(); expect(connected.readCharacteristicForService).toHaveBeenCalledWith(expect.any(String), '7a78f7ec-1d6d-4d92-9ef0-1f89d3db21f4', expect.any(String)); diff --git a/src/transport/ReactNativeBleTransport.ts b/src/transport/ReactNativeBleTransport.ts index fd5f29b..30d85f9 100644 --- a/src/transport/ReactNativeBleTransport.ts +++ b/src/transport/ReactNativeBleTransport.ts @@ -4,7 +4,7 @@ import { toByteArray } from 'base64-js'; import { BLE_DESCRIPTORS, BLE_UUIDS } from '@/domain/protocol/constants'; import { parseStatus } from '@/domain/protocol/responses'; -import type { ConnectionStage, ConnectionStageOutcome, DiagnosticLog } from '@/diagnostics/DiagnosticLog'; +import type { ConnectionStage, ConnectionStageOutcome, DiagnosticLog, DiagnosticAttempt } from '@/diagnostics/DiagnosticLog'; import type { BleAvailability, BleTransport, DiscoveredDesktop, Unsubscribe } from './BleTransport'; import { desktopDisplayName } from './desktopDisplayName'; import { ReadResponsePoller } from './ReadResponsePoller'; @@ -25,13 +25,14 @@ export class ReactNativeBleTransport implements BleTransport { #writePoisoned = false; #readReplies = false; #responsePoller: ReadResponsePoller | null = null; + #diagnosticAttempt: DiagnosticAttempt | undefined; constructor( manager: BleManager | null = null, private readonly platform = Platform.OS, private readonly nativeTimeoutMs = 10_000, private readonly managerFactory = () => new BleManager(), - private readonly diagnostics?: Pick, + private readonly diagnostics?: Pick & Partial>, ) { this.#manager = manager; } async availability(): Promise { @@ -44,6 +45,7 @@ export class ReactNativeBleTransport implements BleTransport { } scan(onDesktop: (desktop: DiscoveredDesktop) => void, onError: (error: Error) => void): Unsubscribe { + this.#diagnosticAttempt = undefined; const operation = ++this.#operation; let active = true; const waiting = new Map(); @@ -96,16 +98,18 @@ export class ReactNativeBleTransport implements BleTransport { }; } - async connect(peripheralId: string): Promise { + async connect(peripheralId: string, attempt?: DiagnosticAttempt): Promise { await this.disconnect(); + this.#diagnosticAttempt = attempt; const operation = ++this.#operation; const result = this.#connectQueue.catch(() => undefined).then(() => this.#connect(peripheralId, operation)); this.#connectQueue = result.then(() => undefined, () => undefined); await result; } - async resolveAndConnect(desktopId: string): Promise { + async resolveAndConnect(desktopId: string, attempt?: DiagnosticAttempt): Promise { await this.disconnect(); + this.#diagnosticAttempt = attempt; const operation = ++this.#operation; this.#recordStage('resolution', 'started', operation); return new Promise((resolve, reject) => { @@ -162,6 +166,7 @@ export class ReactNativeBleTransport implements BleTransport { this.#scanKeys.add(this.#scanKey(device)); const task = this.#readStatus(device, (desktop) => { if (!active || operation !== this.#operation) return false; + try { this.diagnostics?.peerObserved?.(desktop.desktopId, desktop.responseTransport === 'read-v1', attempt, desktop.desktopId === desktopId); } catch { /* Diagnostic isolation. */ } this.#recordStage('selected_match', desktop.desktopId === desktopId ? 'succeeded' : 'not_matched', operation); if (desktop.desktopId !== desktopId || claimedDeviceId !== null) return false; claimedDeviceId = device.id; @@ -369,7 +374,7 @@ export class ReactNativeBleTransport implements BleTransport { async notificationsReady(): Promise { if (this.#readReplies) { if (!this.#responsePoller) throw new Error('Bluetooth response reader has not started.'); - await this.#responsePoller.ready; + await this.#stage('response_read_ready', () => this.#responsePoller!.ready); return; } if (this.platform !== 'android') return; @@ -446,7 +451,7 @@ export class ReactNativeBleTransport implements BleTransport { #recordStage(stage: ConnectionStage, outcome: ConnectionStageOutcome, operation: number): void { if (operation !== this.#operation) return; // Diagnostics must never change Bluetooth control flow, even if a UI listener throws. - try { this.diagnostics?.addConnectionStage(stage, outcome); } catch { /* best effort */ } + try { this.diagnostics?.addConnectionStage(stage, outcome, this.#diagnosticAttempt); } catch { /* best effort */ } } async #stage(stage: ConnectionStage, action: () => Promise, operation = this.#operation): Promise {