This commit is contained in:
parent
c4fade90e7
commit
618b5d3177
|
|
@ -64,7 +64,7 @@
|
|||
{
|
||||
"files": ["src/transport/rpc-client.ts"],
|
||||
"rules": {
|
||||
"max-lines": ["error", { "max": 1070, "skipBlankLines": true, "skipComments": true }]
|
||||
"max-lines": ["error", { "max": 1074, "skipBlankLines": true, "skipComments": true }]
|
||||
}
|
||||
},
|
||||
{
|
||||
|
|
|
|||
|
|
@ -52,6 +52,7 @@ import { ActionSheetContent } from '../../../src/components/ActionSheetModal'
|
|||
import { ConfirmModal } from '../../../src/components/ConfirmModal'
|
||||
import { BottomDrawer } from '../../../src/components/BottomDrawer'
|
||||
import { ProtocolBlockScreen } from '../../../src/components/ProtocolBlockScreen'
|
||||
import { AuthFailedBanner } from '../../../src/components/AuthFailedBanner'
|
||||
import { getCachedWorktrees } from '../../../src/cache/worktree-cache'
|
||||
import { colors, radii, spacing, typography } from '../../../src/theme/mobile-theme'
|
||||
import { useResponsiveLayout } from '../../../src/layout/responsive-layout'
|
||||
|
|
@ -998,19 +999,12 @@ export default function HostScreen() {
|
|||
|
||||
{/* Auth failed banner */}
|
||||
{connState === 'auth-failed' && (
|
||||
<View style={styles.authBanner}>
|
||||
<Text style={styles.authBannerText}>
|
||||
Pairing rejected — re-pair from desktop or remove this host.
|
||||
</Text>
|
||||
<View style={styles.authActions}>
|
||||
<Pressable style={styles.authAction} onPress={() => router.push('/pair-scan')}>
|
||||
<Text style={styles.authActionText}>Re-pair</Text>
|
||||
</Pressable>
|
||||
<Pressable style={styles.authAction} onPress={() => setConfirmRemoveHost(true)}>
|
||||
<Text style={[styles.authActionText, { color: colors.statusRed }]}>Remove</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
</View>
|
||||
<AuthFailedBanner
|
||||
canRetry={!!hostId}
|
||||
onRetry={() => hostId && void forceReconnectHost(hostId)}
|
||||
onRepair={() => router.push('/pair-scan')}
|
||||
onRemove={() => setConfirmRemoveHost(true)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Search bar */}
|
||||
|
|
@ -1424,30 +1418,6 @@ const styles = StyleSheet.create({
|
|||
fontSize: typography.metaSize,
|
||||
fontWeight: '600'
|
||||
},
|
||||
authBanner: {
|
||||
backgroundColor: colors.bgPanel,
|
||||
paddingVertical: spacing.sm,
|
||||
paddingHorizontal: spacing.lg,
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: colors.borderSubtle
|
||||
},
|
||||
authBannerText: {
|
||||
color: colors.statusRed,
|
||||
fontSize: 13,
|
||||
marginBottom: spacing.sm
|
||||
},
|
||||
authActions: {
|
||||
flexDirection: 'row',
|
||||
gap: spacing.lg
|
||||
},
|
||||
authAction: {
|
||||
paddingVertical: spacing.xs
|
||||
},
|
||||
authActionText: {
|
||||
color: colors.accentBlue,
|
||||
fontSize: 13,
|
||||
fontWeight: '600'
|
||||
},
|
||||
toolbar: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
|
|
|
|||
|
|
@ -0,0 +1,66 @@
|
|||
import { View, Text, Pressable, StyleSheet } from 'react-native'
|
||||
import { colors, spacing } from '../theme/mobile-theme'
|
||||
|
||||
// Why: auth-failed is no longer necessarily terminal (issue #5200) — a
|
||||
// transient rejection can latch it even though the desktop still lists this
|
||||
// device. Offer Retry (fresh client + handshake) ahead of the disruptive
|
||||
// re-pair flow so the common transient case recovers without re-pairing.
|
||||
export function AuthFailedBanner({
|
||||
canRetry,
|
||||
onRetry,
|
||||
onRepair,
|
||||
onRemove
|
||||
}: {
|
||||
canRetry: boolean
|
||||
onRetry: () => void
|
||||
onRepair: () => void
|
||||
onRemove: () => void
|
||||
}) {
|
||||
return (
|
||||
<View style={styles.banner}>
|
||||
<Text style={styles.text}>
|
||||
Authentication failed — try reconnecting first; if it keeps failing, re-pair from desktop.
|
||||
</Text>
|
||||
<View style={styles.actions}>
|
||||
{canRetry && (
|
||||
<Pressable style={styles.action} onPress={onRetry}>
|
||||
<Text style={styles.actionText}>Retry</Text>
|
||||
</Pressable>
|
||||
)}
|
||||
<Pressable style={styles.action} onPress={onRepair}>
|
||||
<Text style={styles.actionText}>Re-pair</Text>
|
||||
</Pressable>
|
||||
<Pressable style={styles.action} onPress={onRemove}>
|
||||
<Text style={[styles.actionText, { color: colors.statusRed }]}>Remove</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
banner: {
|
||||
backgroundColor: colors.bgPanel,
|
||||
paddingVertical: spacing.sm,
|
||||
paddingHorizontal: spacing.lg,
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: colors.borderSubtle
|
||||
},
|
||||
text: {
|
||||
color: colors.statusRed,
|
||||
fontSize: 13,
|
||||
marginBottom: spacing.sm
|
||||
},
|
||||
actions: {
|
||||
flexDirection: 'row',
|
||||
gap: spacing.lg
|
||||
},
|
||||
action: {
|
||||
paddingVertical: spacing.xs
|
||||
},
|
||||
actionText: {
|
||||
color: colors.accentBlue,
|
||||
fontSize: 13,
|
||||
fontWeight: '600'
|
||||
}
|
||||
})
|
||||
|
|
@ -712,6 +712,88 @@ describe('mobile rpc-client connection timeout', () => {
|
|||
})
|
||||
})
|
||||
|
||||
// Issue #5200: a single auth rejection used to latch 'auth-failed'
|
||||
// permanently, forcing a needless re-pair even when the desktop still
|
||||
// listed the device with a valid token. The client now retries the
|
||||
// handshake a bounded number of times before declaring auth dead.
|
||||
describe('auth rejection retry (issue #5200)', () => {
|
||||
function authenticate(socket: MockWebSocket) {
|
||||
socket.open()
|
||||
socket.receive(JSON.stringify({ type: 'e2ee_ready' }))
|
||||
socket.receive('encrypted:{"type":"e2ee_authenticated"}')
|
||||
}
|
||||
|
||||
it('retries the handshake on a transient e2ee_error instead of latching auth-failed', async () => {
|
||||
const client = connect('ws://desktop.invalid', 'token', 'server-key')
|
||||
const first = mockSockets[0]!
|
||||
first.open()
|
||||
first.receive(JSON.stringify({ type: 'e2ee_ready' }))
|
||||
|
||||
// Transient rejection during handshake — must NOT latch auth-failed.
|
||||
first.receive('encrypted:{"type":"e2ee_error","error":{"code":"unauthorized"}}')
|
||||
expect(client.getState()).toBe('reconnecting')
|
||||
|
||||
// A fresh socket gets a fresh handshake; this time it authenticates.
|
||||
await vi.advanceTimersByTimeAsync(500)
|
||||
authenticate(mockSockets[mockSockets.length - 1]!)
|
||||
expect(client.getState()).toBe('connected')
|
||||
|
||||
client.close()
|
||||
})
|
||||
|
||||
it('latches auth-failed once the retry budget is exhausted', async () => {
|
||||
const client = connect('ws://desktop.invalid', 'token', 'server-key')
|
||||
|
||||
// Three consecutive handshake rejections (AUTH_RETRY_BUDGET = 3).
|
||||
for (let i = 0; i < 3; i++) {
|
||||
if (i > 0) {
|
||||
await vi.advanceTimersByTimeAsync(500)
|
||||
}
|
||||
const socket = mockSockets[mockSockets.length - 1]!
|
||||
socket.open()
|
||||
socket.receive(JSON.stringify({ type: 'e2ee_ready' }))
|
||||
socket.receive('encrypted:{"type":"e2ee_error","error":{"code":"unauthorized"}}')
|
||||
}
|
||||
|
||||
expect(client.getState()).toBe('auth-failed')
|
||||
|
||||
client.close()
|
||||
})
|
||||
|
||||
it('resets the budget after a successful connect between rejections', async () => {
|
||||
const client = connect('ws://desktop.invalid', 'token', 'server-key')
|
||||
|
||||
// Two rejections, then a clean connect resets the budget...
|
||||
for (let i = 0; i < 2; i++) {
|
||||
if (i > 0) {
|
||||
await vi.advanceTimersByTimeAsync(500)
|
||||
}
|
||||
const socket = mockSockets[mockSockets.length - 1]!
|
||||
socket.open()
|
||||
socket.receive(JSON.stringify({ type: 'e2ee_ready' }))
|
||||
socket.receive('encrypted:{"type":"e2ee_error","error":{"code":"unauthorized"}}')
|
||||
}
|
||||
await vi.advanceTimersByTimeAsync(500)
|
||||
authenticate(mockSockets[mockSockets.length - 1]!)
|
||||
expect(client.getState()).toBe('connected')
|
||||
|
||||
// ...so a later mid-session rejection gets the full budget again
|
||||
// rather than immediately latching auth-failed.
|
||||
const live = mockSockets[mockSockets.length - 1]!
|
||||
const request = client.sendRequest('status.get').catch(() => undefined)
|
||||
// sendRequest awaits waitForConnected before sending — let it flush.
|
||||
await Promise.resolve()
|
||||
const id = sentRequest(live, 'status.get').id
|
||||
live.receive(
|
||||
`encrypted:${JSON.stringify({ id, ok: false, error: { code: 'unauthorized' } })}`
|
||||
)
|
||||
await request
|
||||
expect(client.getState()).toBe('reconnecting')
|
||||
|
||||
client.close()
|
||||
})
|
||||
})
|
||||
|
||||
it('rejects requests waiting for reconnect after the retry cap', async () => {
|
||||
const client = connect('ws://desktop.invalid', 'token', 'server-key')
|
||||
const socket = mockSockets[0]!
|
||||
|
|
|
|||
|
|
@ -119,6 +119,15 @@ const RECONNECT_DELAYS = [500, 1000, 2000, 4000, 8000, 15_000, 30_000, 60_000]
|
|||
// drift the user sees "Reconnecting…" while the loop is silently
|
||||
// parked.
|
||||
const GIVE_UP_AFTER_ATTEMPTS = 12
|
||||
// Why: a single `unauthorized`/`e2ee_error` is not proof the pairing is dead.
|
||||
// Issue #5200: a tablet showed "Auth failed" and forced a needless re-pair
|
||||
// while the desktop still listed it as paired with a valid token — a transient
|
||||
// rejection (mid-session resume race, a stale frame after background) latched
|
||||
// the terminal auth-failed state permanently. Retry the full handshake this
|
||||
// many times with a clean reconnect before declaring auth dead. A genuinely
|
||||
// revoked token is rejected on every attempt and converges to auth-failed in
|
||||
// seconds; a one-off glitch self-heals without the user re-pairing.
|
||||
const AUTH_RETRY_BUDGET = 3
|
||||
const REQUEST_TIMEOUT_MS = 30_000
|
||||
const CONNECT_TIMEOUT_MS = 12_000
|
||||
const HANDSHAKE_TIMEOUT_MS = 5_000
|
||||
|
|
@ -182,6 +191,11 @@ export function connect(
|
|||
let handshakeTimer: ReturnType<typeof setTimeout> | null = null
|
||||
let activityProbeTimer: ReturnType<typeof setInterval> | null = null
|
||||
let intentionallyClosed = false
|
||||
// Why: consecutive auth rejections since the last successful connect. We
|
||||
// tolerate up to AUTH_RETRY_BUDGET (issue #5200) before latching auth-failed
|
||||
// so a transient rejection doesn't force a needless re-pair. Reset to 0 on
|
||||
// every 'connected'.
|
||||
let authRejectionCount = 0
|
||||
let lastConnectedAt: number | null = null
|
||||
// Why: diagnostic — when the rpc-client gets stuck in a state where every
|
||||
// openConnection fails with code 1006 and only a force-quit recovers, we
|
||||
|
|
@ -247,6 +261,9 @@ export function connect(
|
|||
})
|
||||
if (next === 'connected') {
|
||||
lastConnectedAt = Date.now()
|
||||
// Why: a clean handshake proves the token is valid — clear the auth
|
||||
// retry budget so a future isolated rejection gets the full budget again.
|
||||
authRejectionCount = 0
|
||||
for (const waiter of connectWaiters.splice(0)) {
|
||||
if (waiter.timeout) {
|
||||
clearTimeout(waiter.timeout)
|
||||
|
|
@ -496,18 +513,11 @@ export function connect(
|
|||
}
|
||||
} else if (msg.type === 'e2ee_error' || (!msg.ok && msg.error?.code === 'unauthorized')) {
|
||||
console.log('[net] e2ee auth FAILED', { msgType: msg.type, error: msg.error })
|
||||
emitLog(
|
||||
'error',
|
||||
'Authentication rejected',
|
||||
typeof msg.error?.message === 'string' ? msg.error.message : 'Unauthorized'
|
||||
)
|
||||
intentionallyClosed = true
|
||||
ws?.close()
|
||||
ws = null
|
||||
activeBrowserScreencastRequestId = null
|
||||
pendingBrowserScreencastRequestId = null
|
||||
setState('auth-failed')
|
||||
rejectAllPending('Unauthorized — pairing may be revoked')
|
||||
if (handshakeTimer) {
|
||||
clearTimeout(handshakeTimer)
|
||||
handshakeTimer = null
|
||||
}
|
||||
handleAuthRejection('Unauthorized — pairing may be revoked')
|
||||
}
|
||||
} catch {
|
||||
// Not JSON — ignore during handshake.
|
||||
|
|
@ -549,16 +559,12 @@ export function connect(
|
|||
return
|
||||
}
|
||||
|
||||
// Why: auth failure is distinct from transient disconnect — retrying
|
||||
// with a rejected token causes infinite reconnect churn.
|
||||
// Why: a mid-session unauthorized may be a transient glitch, not a dead
|
||||
// pairing (issue #5200). handleAuthRejection retries the handshake a few
|
||||
// times before latching auth-failed, while still bounding churn via the
|
||||
// budget so a genuinely revoked token doesn't reconnect forever.
|
||||
if (!response.ok && response.error.code === 'unauthorized') {
|
||||
intentionallyClosed = true
|
||||
ws?.close()
|
||||
ws = null
|
||||
activeBrowserScreencastRequestId = null
|
||||
pendingBrowserScreencastRequestId = null
|
||||
setState('auth-failed')
|
||||
rejectAllPending('Unauthorized — pairing may be revoked')
|
||||
handleAuthRejection('Unauthorized — pairing may be revoked')
|
||||
return
|
||||
}
|
||||
|
||||
|
|
@ -736,6 +742,51 @@ export function connect(
|
|||
scheduleReconnect()
|
||||
}
|
||||
|
||||
// Why: a token rejection (handshake e2ee_error/unauthorized or a mid-session
|
||||
// unauthorized RPC) may be transient — issue #5200. Retry the full handshake
|
||||
// up to AUTH_RETRY_BUDGET times before declaring auth dead, so a one-off
|
||||
// glitch self-heals instead of forcing the user to re-pair. A genuinely
|
||||
// revoked token fails every retry and latches auth-failed within seconds.
|
||||
function handleAuthRejection(reason: string): void {
|
||||
activeBrowserScreencastRequestId = null
|
||||
pendingBrowserScreencastRequestId = null
|
||||
authRejectionCount++
|
||||
if (authRejectionCount < AUTH_RETRY_BUDGET) {
|
||||
console.log('[net] auth rejected — retrying handshake', {
|
||||
attempt: authRejectionCount,
|
||||
budget: AUTH_RETRY_BUDGET,
|
||||
endpoint: redactedEndpoint(endpoint)
|
||||
})
|
||||
emitLog(
|
||||
'warn',
|
||||
'Authentication rejected',
|
||||
`Retrying (${authRejectionCount}/${AUTH_RETRY_BUDGET})`
|
||||
)
|
||||
// Why: close the current socket but DON'T set intentionallyClosed —
|
||||
// we want handleSocketClosed to route into the reconnect path so the
|
||||
// token gets a fresh handshake. rejectAllPending unblocks in-flight RPCs.
|
||||
const closing = ws
|
||||
ws = null
|
||||
sharedKey = null
|
||||
rejectAllPending(reason)
|
||||
if (closing) {
|
||||
closing.close()
|
||||
}
|
||||
setState('reconnecting')
|
||||
scheduleReconnect()
|
||||
return
|
||||
}
|
||||
console.log('[net] auth rejected — budget exhausted, latching auth-failed', {
|
||||
attempt: authRejectionCount,
|
||||
endpoint: redactedEndpoint(endpoint)
|
||||
})
|
||||
intentionallyClosed = true
|
||||
ws?.close()
|
||||
ws = null
|
||||
setState('auth-failed')
|
||||
rejectAllPending(reason)
|
||||
}
|
||||
|
||||
function scheduleReconnect() {
|
||||
// Why: spinning reconnect forever drains battery and floods logs
|
||||
// when the host is genuinely unreachable (wrong IP, port closed,
|
||||
|
|
|
|||
Loading…
Reference in New Issue