From db12df2ae52ee807a904f6a6ec5a33e6edf44b6f Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Wed, 6 May 2026 19:20:53 -0700 Subject: [PATCH] feat(mobile): show detailed pairing log + cap connecting timeout (#1520) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When pairing fails (e.g. broken Tailscale route, firewall blocking the WS, expired token, etc.) the mobile app previously got stuck on 'Connecting…' forever with no signal about *where* it stalled. Adds a structured connection-log stream from the rpc client and renders it under the 'Connecting…' spinner on both pair-scan and pair-confirm. Each phase emits a timestamped, color-coded entry: Opening WebSocket, WebSocket open, Sent e2ee_hello, Received e2ee_ready, Authenticated, WebSocket closed / Reconnect scheduled / Connect timeout / Handshake timeout, etc. The log auto-scrolls and stays visible after a failure so the user can see the last successful step. Also fixes the silent infinite spinner: pairing now has a 25s overall timeout — rpc-client retries forever by design (right behaviour for live sessions), but for the *initial* pair we want a hard ceiling that surfaces an actionable error with the log visible instead of spinning. - Adds ConnectionLogEntry / ConnectionLogSink to transport/types.ts - connect() now accepts { onStateChange, onLog } (legacy fn form kept) - New ConnectionLog component (mono, level-coloured, +Xs elapsed) - Pair flows track logs in a ref and render them in connecting/error - 25s pairing-overall timeout that closes the client and surfaces a 'see log below for where it stalled' error Co-authored-by: Orca --- mobile/app/pair-confirm.tsx | 52 ++++++++- mobile/app/pair-scan.tsx | 47 +++++++- mobile/src/components/ConnectionLog.tsx | 141 ++++++++++++++++++++++++ mobile/src/transport/rpc-client.ts | 63 ++++++++++- mobile/src/transport/types.ts | 14 +++ 5 files changed, 308 insertions(+), 9 deletions(-) create mode 100644 mobile/src/components/ConnectionLog.tsx diff --git a/mobile/app/pair-confirm.tsx b/mobile/app/pair-confirm.tsx index fefa84ee053..2a63018aafe 100644 --- a/mobile/app/pair-confirm.tsx +++ b/mobile/app/pair-confirm.tsx @@ -1,4 +1,4 @@ -import { useEffect, useState } from 'react' +import { useEffect, useRef, useState } from 'react' import { View, Text, StyleSheet, Pressable, ActivityIndicator } from 'react-native' import { useSafeAreaInsets } from 'react-native-safe-area-context' import { useLocalSearchParams, useRouter } from 'expo-router' @@ -6,11 +6,19 @@ import { ChevronLeft } from 'lucide-react-native' import { parsePairingCode } from '../src/transport/pairing' import { connect } from '../src/transport/rpc-client' import { saveHost, getNextHostName } from '../src/transport/host-store' -import type { PairingOffer, RpcResponse } from '../src/transport/types' +import type { ConnectionLogEntry, PairingOffer, RpcResponse } from '../src/transport/types' import { colors, spacing, radii, typography } from '../src/theme/mobile-theme' +import { ConnectionLog } from '../src/components/ConnectionLog' type Status = 'awaiting-confirm' | 'connecting' | 'error' +// Why: cap how long the user stares at "Connecting…" during pairing. +// rpc-client retries forever by design (good for live sessions), but for +// the *initial* pair we want a hard ceiling so a half-broken Tailscale +// route surfaces an actionable error with the log visible, instead of +// spinning silently. ~25s allows for one full connect-timeout + a retry. +const PAIRING_OVERALL_TIMEOUT_MS = 25_000 + export default function PairConfirmScreen() { const router = useRouter() const insets = useSafeAreaInsets() @@ -18,6 +26,11 @@ export default function PairConfirmScreen() { const [offer, setOffer] = useState(null) const [status, setStatus] = useState('awaiting-confirm') const [errorMessage, setErrorMessage] = useState('') + const [logs, setLogs] = useState([]) + // Why: collect logs in a ref so the rpc-client callback (which closures + // over the initial state setter) always sees the freshest list and we + // batch fewer setState calls when entries arrive in bursts. + const logsRef = useRef([]) useEffect(() => { if (!params.code) { @@ -37,21 +50,39 @@ export default function PairConfirmScreen() { async function confirm() { if (!offer) return setStatus('connecting') + logsRef.current = [] + setLogs([]) let client: ReturnType | null = null // Why: split the try/catch around the network call vs the local save // so a Keychain or AsyncStorage failure doesn't masquerade as a // "Cannot connect" error. let response: RpcResponse + let timedOut = false + const overallTimer = setTimeout(() => { + timedOut = true + client?.close() + }, PAIRING_OVERALL_TIMEOUT_MS) try { - client = connect(offer.endpoint, offer.deviceToken, offer.publicKeyB64) + client = connect(offer.endpoint, offer.deviceToken, offer.publicKeyB64, { + onLog: (entry) => { + logsRef.current = [...logsRef.current, entry] + setLogs(logsRef.current) + } + }) response = await client.sendRequest('status.get') + clearTimeout(overallTimer) client.close() client = null } catch (err) { + clearTimeout(overallTimer) console.warn('[pair-confirm] connect failed', err) setStatus('error') - setErrorMessage('Cannot connect — check that your computer is on the same network') + setErrorMessage( + timedOut + ? `Couldn't connect within ${PAIRING_OVERALL_TIMEOUT_MS / 1000}s — see log below for where it stalled` + : 'Cannot connect — check that your computer is on the same network' + ) client?.close() return } @@ -119,12 +150,20 @@ export default function PairConfirmScreen() { <> Connecting… + + + )} {status === 'error' && ( <> {errorMessage} + {logs.length > 0 && ( + + + + )} Back to home @@ -202,6 +241,11 @@ const styles = StyleSheet.create({ marginTop: spacing.lg, textAlign: 'center' }, + logSlot: { + width: '100%', + marginTop: spacing.lg, + marginBottom: spacing.md + }, errorText: { color: colors.statusRed, fontSize: typography.bodySize, diff --git a/mobile/app/pair-scan.tsx b/mobile/app/pair-scan.tsx index 734787400f2..ca8a8673e7a 100644 --- a/mobile/app/pair-scan.tsx +++ b/mobile/app/pair-scan.tsx @@ -7,9 +7,15 @@ import { ChevronLeft, Clipboard as ClipboardIcon, QrCode } from 'lucide-react-na import { decodePairingUrl, parsePairingCode } from '../src/transport/pairing' import { connect } from '../src/transport/rpc-client' import { saveHost, getNextHostName } from '../src/transport/host-store' -import type { PairingOffer, RpcResponse } from '../src/transport/types' +import type { ConnectionLogEntry, PairingOffer, RpcResponse } from '../src/transport/types' import { colors, spacing, radii, typography } from '../src/theme/mobile-theme' import { TextInputModal } from '../src/components/TextInputModal' +import { ConnectionLog } from '../src/components/ConnectionLog' + +// Why: see pair-confirm.tsx — cap initial-pair "Connecting…" so a broken +// route surfaces as a real error with the log visible instead of a +// silent infinite spinner. +const PAIRING_OVERALL_TIMEOUT_MS = 25_000 function Step({ number, text }: { number: number; text: string }) { return ( @@ -29,6 +35,8 @@ export default function PairScanScreen() { const [status, setStatus] = useState<'scanning' | 'connecting' | 'error'>('scanning') const [errorMessage, setErrorMessage] = useState('') const [pasteVisible, setPasteVisible] = useState(false) + const [logs, setLogs] = useState([]) + const logsRef = useRef([]) const processingRef = useRef(false) const handleBarCodeScanned = useCallback( @@ -67,6 +75,8 @@ export default function PairScanScreen() { async function testAndSave(offer: PairingOffer) { setStatus('connecting') + logsRef.current = [] + setLogs([]) let client: ReturnType | null = null // Why: split the try/catch around the network call vs the local save @@ -74,15 +84,31 @@ export default function PairScanScreen() { // "Cannot connect — same network?" error. Pairing reached the // desktop fine; the failure is local persistence. let response: RpcResponse + let timedOut = false + const overallTimer = setTimeout(() => { + timedOut = true + client?.close() + }, PAIRING_OVERALL_TIMEOUT_MS) try { - client = connect(offer.endpoint, offer.deviceToken, offer.publicKeyB64) + client = connect(offer.endpoint, offer.deviceToken, offer.publicKeyB64, { + onLog: (entry) => { + logsRef.current = [...logsRef.current, entry] + setLogs(logsRef.current) + } + }) response = await client.sendRequest('status.get') + clearTimeout(overallTimer) client.close() client = null } catch (err) { + clearTimeout(overallTimer) console.warn('[pair] connect failed', err) setStatus('error') - setErrorMessage('Cannot connect — check that your computer is on the same network') + setErrorMessage( + timedOut + ? `Couldn't connect within ${PAIRING_OVERALL_TIMEOUT_MS / 1000}s — see log below for where it stalled` + : 'Cannot connect — check that your computer is on the same network' + ) processingRef.current = false client?.close() return @@ -126,6 +152,8 @@ export default function PairScanScreen() { function retry() { setStatus('scanning') setErrorMessage('') + logsRef.current = [] + setLogs([]) processingRef.current = false } @@ -240,12 +268,20 @@ export default function PairScanScreen() { Connecting… + + + )} {status === 'error' && ( {errorMessage} + {logs.length > 0 && ( + + + + )} Try Again @@ -398,6 +434,11 @@ const styles = StyleSheet.create({ fontSize: typography.bodySize, marginTop: spacing.lg }, + logSlot: { + width: '100%', + marginTop: spacing.lg, + paddingHorizontal: spacing.sm + }, errorText: { color: colors.statusRed, fontSize: typography.bodySize, diff --git a/mobile/src/components/ConnectionLog.tsx b/mobile/src/components/ConnectionLog.tsx new file mode 100644 index 00000000000..3722ba0db25 --- /dev/null +++ b/mobile/src/components/ConnectionLog.tsx @@ -0,0 +1,141 @@ +import { useEffect, useRef } from 'react' +import { ScrollView, StyleSheet, Text, View } from 'react-native' +import type { ConnectionLogEntry } from '../transport/types' +import { colors, radii, spacing, typography } from '../theme/mobile-theme' + +type Props = { + entries: ConnectionLogEntry[] + // Tag printed before the first entry so it's clear what's being logged + // (e.g. 'Pairing' vs 'Reconnect'). + title?: string +} + +const LEVEL_COLOR: Record = { + info: colors.textSecondary, + success: colors.statusGreen, + warn: colors.statusAmber, + error: colors.statusRed +} + +const LEVEL_GLYPH: Record = { + info: '•', + success: '✓', + warn: '!', + error: '✕' +} + +function formatTime(ts: number, baseTs: number): string { + // Why: show elapsed seconds since the first entry — absolute wall-clock + // time isn't actionable when debugging "why is connecting stuck". + const elapsed = Math.max(0, ts - baseTs) / 1000 + if (elapsed < 10) return `+${elapsed.toFixed(2)}s` + if (elapsed < 100) return `+${elapsed.toFixed(1)}s` + return `+${Math.round(elapsed)}s` +} + +export function ConnectionLog({ entries, title }: Props) { + const scrollRef = useRef(null) + + // Why: keep the latest entry visible while logs grow during a slow + // connect. Skip on empty so the title row doesn't jump. + useEffect(() => { + if (entries.length === 0) return + const id = setTimeout(() => scrollRef.current?.scrollToEnd({ animated: true }), 16) + return () => clearTimeout(id) + }, [entries.length]) + + if (entries.length === 0) return null + const baseTs = entries[0]!.ts + + return ( + + {title && {title}} + + {entries.map((entry) => ( + + {formatTime(entry.ts, baseTs)} + + {LEVEL_GLYPH[entry.level]} + + + + {entry.message} + + {entry.detail && ( + + {entry.detail} + + )} + + + ))} + + + ) +} + +const styles = StyleSheet.create({ + container: { + width: '100%', + maxHeight: 240, + backgroundColor: colors.bgPanel, + borderRadius: radii.card, + borderWidth: StyleSheet.hairlineWidth, + borderColor: colors.borderSubtle, + paddingVertical: spacing.sm, + paddingHorizontal: spacing.md + }, + title: { + fontSize: typography.metaSize, + fontFamily: typography.monoFamily, + color: colors.textMuted, + textTransform: 'uppercase', + letterSpacing: 1, + marginBottom: spacing.xs + }, + scroll: { + maxHeight: 200 + }, + scrollContent: { + gap: 6 + }, + row: { + flexDirection: 'row', + alignItems: 'flex-start', + gap: spacing.sm + }, + timestamp: { + fontFamily: typography.monoFamily, + fontSize: typography.metaSize, + color: colors.textMuted, + width: 52, + paddingTop: 1 + }, + glyph: { + fontFamily: typography.monoFamily, + fontSize: typography.metaSize, + width: 12, + textAlign: 'center', + paddingTop: 1 + }, + rowText: { + flex: 1 + }, + message: { + fontFamily: typography.monoFamily, + fontSize: typography.metaSize, + lineHeight: 16 + }, + detail: { + fontFamily: typography.monoFamily, + fontSize: 11, + color: colors.textMuted, + lineHeight: 14, + marginTop: 1 + } +}) diff --git a/mobile/src/transport/rpc-client.ts b/mobile/src/transport/rpc-client.ts index d1269000382..770494cfe3d 100644 --- a/mobile/src/transport/rpc-client.ts +++ b/mobile/src/transport/rpc-client.ts @@ -1,4 +1,10 @@ -import type { RpcResponse, RpcSuccess, ConnectionState } from './types' +import type { + RpcResponse, + RpcSuccess, + ConnectionState, + ConnectionLogLevel, + ConnectionLogSink +} from './types' import { generateKeyPair, deriveSharedKey, @@ -60,12 +66,38 @@ const WEBSOCKET_CONNECTING_STATE = 0 // window and well below iOS's typical background-disconnect window. const ACTIVITY_PROBE_INTERVAL_MS = 20_000 +export type ConnectOptions = { + onStateChange?: (state: ConnectionState) => void + // Fires for every observable lifecycle event so the UI can render a + // detailed connection log. Useful when 'Connecting…' hangs forever + // (e.g. broken Tailscale route) and you need to see *where* it's stuck. + onLog?: ConnectionLogSink +} + export function connect( endpoint: string, deviceToken: string, serverPublicKeyB64: string, - onStateChange?: (state: ConnectionState) => void + optionsOrLegacy?: ConnectOptions | ((state: ConnectionState) => void) ): RpcClient { + // Why: keep backward-compat with callers that pass a bare onStateChange fn. + const options: ConnectOptions = + typeof optionsOrLegacy === 'function' + ? { onStateChange: optionsOrLegacy } + : (optionsOrLegacy ?? {}) + const onStateChange = options.onStateChange + const onLog = options.onLog + let logCounter = 0 + function emitLog(level: ConnectionLogLevel, message: string, detail?: string) { + if (!onLog) return + onLog({ + id: `log-${++logCounter}-${Date.now()}`, + ts: Date.now(), + level, + message, + detail + }) + } let ws: WebSocket | null = null let state: ConnectionState = 'disconnected' let requestCounter = 0 @@ -123,6 +155,12 @@ export function connect( setState('connecting') sharedKey = null + emitLog( + 'info', + reconnectAttempt > 0 ? `Reconnecting (attempt ${reconnectAttempt + 1})` : 'Opening WebSocket', + endpoint + ) + ws = new WebSocket(endpoint) const openingWs = ws @@ -132,6 +170,11 @@ export function connect( connectTimer = setTimeout(() => { connectTimer = null if (ws === openingWs && openingWs.readyState === WEBSOCKET_CONNECTING_STATE) { + emitLog( + 'error', + 'WebSocket connect timeout', + `No TCP/WS handshake within ${CONNECT_TIMEOUT_MS / 1000}s — endpoint unreachable?` + ) openingWs.close() if (ws === openingWs) { handleSocketClosed(openingWs) @@ -143,6 +186,7 @@ export function connect( clearConnectTimer() reconnectAttempt = 0 setState('handshaking') + emitLog('success', 'WebSocket open', 'Starting E2EE handshake') // Why: generate a fresh ephemeral keypair for each connection. // This provides forward secrecy — compromising one session's key @@ -153,11 +197,17 @@ export function connect( publicKeyB64: publicKeyToBase64(ephemeral.publicKey) }) ws?.send(hello) + emitLog('info', 'Sent e2ee_hello', 'Awaiting server e2ee_ready') sharedKey = deriveSharedKey(ephemeral.secretKey, serverPublicKey) handshakeTimer = setTimeout(() => { handshakeTimer = null + emitLog( + 'error', + 'Handshake timeout', + `No e2ee_ready/e2ee_authenticated within ${HANDSHAKE_TIMEOUT_MS / 1000}s` + ) ws?.close() }, HANDSHAKE_TIMEOUT_MS) } @@ -171,6 +221,7 @@ export function connect( try { const msg = JSON.parse(raw) if (msg.type === 'e2ee_ready') { + emitLog('success', 'Received e2ee_ready', 'Sending device token') sendEncrypted({ type: 'e2ee_auth', deviceToken }) return } @@ -195,11 +246,17 @@ export function connect( handshakeTimer = null } setState('connected') + emitLog('success', 'Authenticated', 'Channel ready for RPC') startActivityProbe() for (const [id, stream] of streamListeners) { sendEncrypted({ id, deviceToken, method: stream.method, params: stream.params }) } } else if (msg.type === 'e2ee_error' || (!msg.ok && msg.error?.code === 'unauthorized')) { + emitLog( + 'error', + 'Authentication rejected', + typeof msg.error?.message === 'string' ? msg.error.message : 'Unauthorized' + ) intentionallyClosed = true ws?.close() ws = null @@ -303,6 +360,7 @@ export function connect( rejectAllPending('Connection closed') return } + emitLog('warn', 'WebSocket closed', 'Will attempt to reconnect') rejectAllPending('Connection interrupted') setState('reconnecting') scheduleReconnect() @@ -311,6 +369,7 @@ export function connect( function scheduleReconnect() { const delay = RECONNECT_DELAYS[Math.min(reconnectAttempt, RECONNECT_DELAYS.length - 1)]! reconnectAttempt++ + emitLog('info', `Reconnect scheduled in ${delay}ms`, `Attempt ${reconnectAttempt}`) reconnectTimer = setTimeout(() => { reconnectTimer = null openConnection() diff --git a/mobile/src/transport/types.ts b/mobile/src/transport/types.ts index 2e9e8fcf7ff..6750dc69eca 100644 --- a/mobile/src/transport/types.ts +++ b/mobile/src/transport/types.ts @@ -35,6 +35,20 @@ export const PairingOfferSchema = z.object({ export type PairingOffer = z.infer +export type ConnectionLogLevel = 'info' | 'success' | 'warn' | 'error' + +export type ConnectionLogEntry = { + id: string + ts: number + level: ConnectionLogLevel + // Short human-readable phase label, e.g. 'Opening WebSocket'. + message: string + // Optional second line for endpoint/error/elapsed detail. + detail?: string +} + +export type ConnectionLogSink = (entry: ConnectionLogEntry) => void + export type ConnectionState = | 'connecting' | 'handshaking'