mirror of
https://github.com/stablyai/orca.git
synced 2026-09-22 00:02:31 +00:00
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:
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
}
|
||||
})
|
||||
@@ -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()
|
||||
|
||||
@@ -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'
|
||||
|
||||
Reference in New Issue
Block a user