feat(mobile): show detailed pairing log + cap connecting timeout (#1520)

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 <help@stably.ai>
This commit is contained in:
Neil
2026-05-06 19:20:53 -07:00
committed by GitHub
co-authored by Orca
parent 0948f4f205
commit db12df2ae5
5 changed files with 308 additions and 9 deletions
+48 -4
View File
@@ -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<PairingOffer | null>(null)
const [status, setStatus] = useState<Status>('awaiting-confirm')
const [errorMessage, setErrorMessage] = useState('')
const [logs, setLogs] = useState<ConnectionLogEntry[]>([])
// 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<ConnectionLogEntry[]>([])
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<typeof connect> | 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() {
<>
<ActivityIndicator size="large" color={colors.textSecondary} />
<Text style={styles.connectingText}>Connecting</Text>
<View style={styles.logSlot}>
<ConnectionLog entries={logs} title="Pairing log" />
</View>
</>
)}
{status === 'error' && (
<>
<Text style={styles.errorText}>{errorMessage}</Text>
{logs.length > 0 && (
<View style={styles.logSlot}>
<ConnectionLog entries={logs} title="Pairing log" />
</View>
)}
<Pressable style={styles.primaryButton} onPress={cancel}>
<Text style={styles.primaryButtonText}>Back to home</Text>
</Pressable>
@@ -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,
+44 -3
View File
@@ -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<ConnectionLogEntry[]>([])
const logsRef = useRef<ConnectionLogEntry[]>([])
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<typeof connect> | 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() {
<View style={styles.centered}>
<ActivityIndicator size="large" color={colors.textSecondary} />
<Text style={styles.connectingText}>Connecting</Text>
<View style={styles.logSlot}>
<ConnectionLog entries={logs} title="Pairing log" />
</View>
</View>
)}
{status === 'error' && (
<View style={styles.centered}>
<Text style={styles.errorText}>{errorMessage}</Text>
{logs.length > 0 && (
<View style={styles.logSlot}>
<ConnectionLog entries={logs} title="Pairing log" />
</View>
)}
<View style={styles.errorActions}>
<Pressable style={styles.primaryButton} onPress={retry}>
<Text style={styles.primaryButtonText}>Try Again</Text>
@@ -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,
+141
View File
@@ -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<ConnectionLogEntry['level'], string> = {
info: colors.textSecondary,
success: colors.statusGreen,
warn: colors.statusAmber,
error: colors.statusRed
}
const LEVEL_GLYPH: Record<ConnectionLogEntry['level'], string> = {
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<ScrollView | null>(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 (
<View style={styles.container}>
{title && <Text style={styles.title}>{title}</Text>}
<ScrollView
ref={scrollRef}
style={styles.scroll}
contentContainerStyle={styles.scrollContent}
showsVerticalScrollIndicator={false}
>
{entries.map((entry) => (
<View key={entry.id} style={styles.row}>
<Text style={styles.timestamp}>{formatTime(entry.ts, baseTs)}</Text>
<Text style={[styles.glyph, { color: LEVEL_COLOR[entry.level] }]}>
{LEVEL_GLYPH[entry.level]}
</Text>
<View style={styles.rowText}>
<Text style={[styles.message, { color: LEVEL_COLOR[entry.level] }]}>
{entry.message}
</Text>
{entry.detail && (
<Text style={styles.detail} numberOfLines={2}>
{entry.detail}
</Text>
)}
</View>
</View>
))}
</ScrollView>
</View>
)
}
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
}
})
+61 -2
View File
@@ -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()
+14
View File
@@ -35,6 +35,20 @@ export const PairingOfferSchema = z.object({
export type PairingOffer = z.infer<typeof PairingOfferSchema>
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'