Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 20 additions & 0 deletions mobile/src/diagnostics/connection-diagnostics-analysis.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -195,6 +195,26 @@ describe('diagnoseConnection', () => {
})
})

it('does not blame the host when a dial timer fires after suspension', () => {
expect(
diagnoseConnection({
endpoint: 'ws://100.88.90.25:6768',
state: 'connecting',
activePath: 'tailscale',
entries: [
{
...event(
'WebSocket connect interrupted',
'App suspended for 2h44m; connection state unknown, re-dialing'
),
level: 'warn',
code: 'suspended-dial'
}
]
}).likelyCause
).toBe('No single failure cause can be determined from the recorded events.')
})

it('keys reportability to the current structured incident', () => {
const args = {
endpoint: 'ws://192.168.1.2:6768',
Expand Down
93 changes: 45 additions & 48 deletions mobile/src/transport/rpc-client-socket-session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,13 @@ import {
import { isRpcResponse } from './rpc-response-shape'
import { isStaleRpcSocketEvent, logRpcSocketClose } from './rpc-socket-close-evidence'
import { describeSocketEvent, redactSocketEndpoint } from './socket-event-debug'
import {
releaseSocketTimer,
rememberSocketDeadlineClock,
startSocketDeadlineClock,
takeSocketDeadline
} from './socket-deadline-clock'
import { reportSocketDeadline, type SocketDeadlineKind } from './socket-deadline-suspension'
import type { ConnectionLogEmitter, ConnectionState, RpcResponse } from './types'
import { websocketPayloadToUint8 } from './websocket-payload-bytes'

Expand Down Expand Up @@ -87,14 +94,8 @@ export class RpcClientSocketSession {
}

clearTimers(): void {
if (this.connectTimer) {
clearTimeout(this.connectTimer)
this.connectTimer = null
}
if (this.handshakeTimer) {
clearTimeout(this.handshakeTimer)
this.handshakeTimer = null
}
this.connectTimer = releaseSocketTimer(this.connectTimer)
this.handshakeTimer = releaseSocketTimer(this.handshakeTimer)
}

clearKey(): void {
Expand Down Expand Up @@ -248,58 +249,54 @@ export class RpcClientSocketSession {
}

private armConnectTimeout(): void {
this.connectTimer = setTimeout(() => {
this.connectTimer = null
if (
this.options.getCurrentSocket() === this.socket &&
this.socket.readyState === WEBSOCKET_CONNECTING_STATE
) {
console.log('[net] connect-timeout fired (onopen never arrived)', {
attempt: this.options.getReconnectAttempt(),
timeoutMs: CONNECT_TIMEOUT_MS
})
this.options.emitLog(
'error',
'WebSocket connect timeout',
`No TCP/WS handshake within ${CONNECT_TIMEOUT_MS / 1000}s — endpoint unreachable?`,
{ code: 'connect-timeout' }
)
this.options.onForcedClose(this)
}
}, CONNECT_TIMEOUT_MS)
this.armSocketDeadline('connect', CONNECT_TIMEOUT_MS)
}

private armHandshakeTimeout(): void {
this.handshakeTimer = setTimeout(() => {
this.handshakeTimer = null
if (this.options.getCurrentSocket() !== this.socket || this.authenticated) {
this.armSocketDeadline('handshake', HANDSHAKE_TIMEOUT_MS)
}

private armSocketDeadline(kind: SocketDeadlineKind, timeoutMs: number): void {
const timer = setTimeout(() => {
const sample = takeSocketDeadline(timer)
if (kind === 'connect') {
this.connectTimer = null
} else {
this.handshakeTimer = null
}
const current = this.options.getCurrentSocket() === this.socket
const pending =
kind === 'connect'
? current && this.socket.readyState === WEBSOCKET_CONNECTING_STATE
: current && !this.authenticated
if (!pending) {
return
}
console.log('[net] handshake-timeout fired (e2ee_authenticated never arrived)', {
timeoutMs: HANDSHAKE_TIMEOUT_MS
})
this.options.emitLog(
'error',
'Handshake timeout',
`No e2ee_ready/e2ee_authenticated within ${HANDSHAKE_TIMEOUT_MS / 1000}s`,
{ code: 'handshake-timeout' }
reportSocketDeadline(
{
kind,
...sample,
timeoutMs,
...(kind === 'connect' ? { attempt: this.options.getReconnectAttempt() } : {})
},
this.options.emitLog,
() => this.options.onForcedClose(this)
)
this.options.onForcedClose(this)
}, HANDSHAKE_TIMEOUT_MS)
}, timeoutMs)
rememberSocketDeadlineClock(timer, startSocketDeadlineClock())
if (kind === 'connect') {
this.connectTimer = timer
} else {
this.handshakeTimer = timer
}
}

private clearConnectTimer(): void {
if (this.connectTimer) {
clearTimeout(this.connectTimer)
this.connectTimer = null
}
this.connectTimer = releaseSocketTimer(this.connectTimer)
}

private clearHandshakeTimer(): void {
if (this.handshakeTimer) {
clearTimeout(this.handshakeTimer)
this.handshakeTimer = null
}
this.handshakeTimer = releaseSocketTimer(this.handshakeTimer)
}

private isStale(eventName: string): boolean {
Expand Down
135 changes: 135 additions & 0 deletions mobile/src/transport/socket-deadline-clock.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,135 @@
import { describe, expect, it, vi } from 'vitest'
import {
releaseSocketTimer,
rememberSocketDeadlineClock,
startSocketDeadlineClock,
takeSocketDeadline
} from './socket-deadline-clock'
import { describeSocketDeadline } from './socket-deadline-suspension'

const appState = vi.hoisted(() => ({
currentState: 'active',
listeners: new Set<(state: string) => void>()
}))

vi.mock('react-native', () => ({
AppState: {
get currentState() {
return appState.currentState
},
addEventListener: (_event: string, listener: (state: string) => void) => {
appState.listeners.add(listener)
return {
remove: () => {
appState.listeners.delete(listener)
}
}
}
}
}))

describe('startSocketDeadlineClock', () => {
it('reports a wall-clock jump while the app stays active as a host timeout', () => {
appState.currentState = 'active'
appState.listeners.clear()
let wallMs = 1_000
let monotonicMs = 50
const clock = startSocketDeadlineClock(
() => wallMs,
() => monotonicMs
)
wallMs += 3 * 60 * 60 * 1000
monotonicMs += 12_000
const report = describeSocketDeadline({
kind: 'connect',
timeoutMs: 12_000,
...clock.finish()
})

expect(report.code).toBe('connect-timeout')
expect(appState.listeners.size).toBe(0)
})

it('keeps a host timeout when a short foreground loss does not delay the clocks', () => {
appState.currentState = 'active'
appState.listeners.clear()
let wallMs = 50_000
let monotonicMs = 10
const clock = startSocketDeadlineClock(
() => wallMs,
() => monotonicMs
)
for (const listener of appState.listeners) {
listener('background')
}
wallMs -= 5_000
monotonicMs += 12_000
const report = describeSocketDeadline({
kind: 'connect',
timeoutMs: 12_000,
...clock.finish()
})

expect(report.code).toBe('connect-timeout')
expect(report.detail).toContain('endpoint unreachable')
})

it('keeps an on-budget timeout when a transient inactive state has ended', () => {
appState.currentState = 'active'
appState.listeners.clear()
let wallMs = 1_000
let monotonicMs = 50
const clock = startSocketDeadlineClock(
() => wallMs,
() => monotonicMs
)
for (const listener of appState.listeners) {
listener('inactive')
listener('active')
}
wallMs += 12_000
monotonicMs += 12_000

const report = describeSocketDeadline({
kind: 'connect',
timeoutMs: 12_000,
...clock.finish()
})

expect(report.code).toBe('connect-timeout')
})

it('detects suspension when the watcher starts after the app is already backgrounded', () => {
appState.currentState = 'background'
appState.listeners.clear()
let wallMs = 1_000
let monotonicMs = 50
const clock = startSocketDeadlineClock(
() => wallMs,
() => monotonicMs
)
wallMs += 3 * 60 * 60 * 1000
monotonicMs += 12_000

const report = describeSocketDeadline({
kind: 'handshake',
timeoutMs: 5_000,
...clock.finish()
})

expect(report.code).toBe('suspended-dial')
expect(appState.listeners.size).toBe(0)
})

it('drops the foreground watch when the timer is cleared', () => {
appState.currentState = 'active'
appState.listeners.clear()
const timer = setTimeout(() => undefined, 60_000)
rememberSocketDeadlineClock(timer, startSocketDeadlineClock())
expect(appState.listeners.size).toBe(1)

expect(releaseSocketTimer(timer)).toBeNull()
expect(appState.listeners.size).toBe(0)
expect(takeSocketDeadline(timer).leftForeground).toBe(false)
})
})
95 changes: 95 additions & 0 deletions mobile/src/transport/socket-deadline-clock.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
import { AppState } from 'react-native'

export type SocketDeadlineSample = {
armedAtMs: number
firedAtMs: number
monotonicElapsedMs: number
leftForeground: boolean
}

type DeadlineTimer = ReturnType<typeof setTimeout>

type SocketDeadlineClock = {
finish: () => SocketDeadlineSample
}

const boundClocks = new Map<DeadlineTimer, SocketDeadlineClock>()

function defaultMonotonicNow(): number {
if (typeof performance !== 'undefined' && typeof performance.now === 'function') {
return performance.now()
}
return Date.now()
}

function watchForegroundLoss(onLeave: () => void): () => void {
const subscription = AppState.addEventListener('change', (state) => {
Comment thread
coderabbitai[bot] marked this conversation as resolved.
if (state === 'background' || state === 'inactive') {
onLeave()
}
})
if (AppState.currentState === 'background' || AppState.currentState === 'inactive') {
onLeave()
}
return () => {
subscription.remove()
}
}

export function startSocketDeadlineClock(
now: () => number = Date.now,
monotonicNow: () => number = defaultMonotonicNow,
watchLeave: (onLeave: () => void) => () => void = watchForegroundLoss
): SocketDeadlineClock {
const armedAtMs = now()
const armedMonotonicMs = monotonicNow()
let leftForeground = false
let stopped = false
const stopWatch = watchLeave(() => {
leftForeground = true
})
return {
finish() {
if (!stopped) {
stopped = true
stopWatch()
}
return {
armedAtMs,
firedAtMs: now(),
monotonicElapsedMs: monotonicNow() - armedMonotonicMs,
leftForeground
}
}
}
}

export function rememberSocketDeadlineClock(
timer: DeadlineTimer,
clock: SocketDeadlineClock
): void {
boundClocks.set(timer, clock)
}

export function takeSocketDeadline(timer: DeadlineTimer): SocketDeadlineSample {
const clock = boundClocks.get(timer)
boundClocks.delete(timer)
if (!clock) {
const firedAtMs = Date.now()
return {
armedAtMs: firedAtMs,
firedAtMs,
monotonicElapsedMs: 0,
leftForeground: false
}
}
return clock.finish()
}

export function releaseSocketTimer(timer: DeadlineTimer | null): null {
if (timer) {
clearTimeout(timer)
takeSocketDeadline(timer)
}
return null
}
Loading