diff --git a/mobile/app/h/[hostId]/files/[worktreeId].tsx b/mobile/app/h/[hostId]/files/[worktreeId].tsx index 1f3d620d0..8180baafb 100644 --- a/mobile/app/h/[hostId]/files/[worktreeId].tsx +++ b/mobile/app/h/[hostId]/files/[worktreeId].tsx @@ -11,7 +11,8 @@ import { import { SafeAreaView } from 'react-native-safe-area-context' import { useLocalSearchParams, useRouter } from 'expo-router' import { ChevronDown, ChevronLeft, ChevronRight, File, FileText, Folder } from 'lucide-react-native' -import { useHostClient } from '../../../../src/transport/client-context' +import { useHostClient, useForceReconnect } from '../../../../src/transport/client-context' +import { getWorktreeLabel } from '../../../../src/session/worktree-label' import type { RpcSuccess } from '../../../../src/transport/types' import { triggerError, triggerSelection } from '../../../../src/platform/haptics' import { colors, radii, spacing, typography } from '../../../../src/theme/mobile-theme' @@ -104,17 +105,6 @@ function isMarkdownPath(relativePath: string): boolean { return /\.(md|mdx|markdown)$/i.test(relativePath) } -function getWorktreeLabel(name: string | undefined, worktreeId: string): string { - if (name?.trim()) { - return name.trim() - } - const pathPart = worktreeId.includes('::') - ? worktreeId.slice(worktreeId.indexOf('::') + 2) - : worktreeId - const normalized = pathPart.replace(/\\/g, '/').replace(/\/+$/, '') - return normalized.slice(normalized.lastIndexOf('/') + 1) || 'Worktree' -} - export default function MobileFileExplorerScreen() { const { hostId, worktreeId, name } = useLocalSearchParams<{ hostId: string @@ -123,6 +113,7 @@ export default function MobileFileExplorerScreen() { }>() const router = useRouter() const { client, state: connState } = useHostClient(hostId) + const forceReconnect = useForceReconnect() const [files, setFiles] = useState([]) const [expanded, setExpanded] = useState>(() => new Set()) const [loading, setLoading] = useState(true) @@ -287,7 +278,15 @@ export default function MobileFileExplorerScreen() { ) : error ? ( {error} - void loadFiles()}> + {/* Why: while disconnected, re-sending the request is useless — revive + the parked transport instead (issue #5049); loadFiles re-runs via + its effect once the new client connects. */} + + connState !== 'connected' && hostId ? void forceReconnect(hostId) : void loadFiles() + } + > Retry diff --git a/mobile/app/h/[hostId]/session/[worktreeId].tsx b/mobile/app/h/[hostId]/session/[worktreeId].tsx index a9cc8eb70..c5d8fb552 100644 --- a/mobile/app/h/[hostId]/session/[worktreeId].tsx +++ b/mobile/app/h/[hostId]/session/[worktreeId].tsx @@ -46,7 +46,13 @@ import { } from 'lucide-react-native' import type { RpcClient } from '../../../../src/transport/rpc-client' import { loadHosts } from '../../../../src/transport/host-store' -import { useHostClient } from '../../../../src/transport/client-context' +import { + useHostClient, + useForceReconnect, + useReconnectAttempt, + useLastConnectedAt +} from '../../../../src/transport/client-context' +import { classifyConnection } from '../../../../src/transport/connection-health' import type { ConnectionState, RpcFailure, RpcSuccess } from '../../../../src/transport/types' import { useMobileDictation } from '../../../../src/hooks/use-mobile-dictation' import { @@ -924,6 +930,9 @@ export default function SessionScreen() { // Why: shared client per host owned by RpcClientProvider. See // docs/mobile-shared-client-per-host.md. const { client, state: connState } = useHostClient(hostId) + const reconnectAttempts = useReconnectAttempt(hostId) + const lastConnectedAt = useLastConnectedAt(hostId) + const forceReconnectHost = useForceReconnect() const initialCreateWarning = typeof createdWarning === 'string' ? createdWarning.trim() : '' const [terminals, setTerminals] = useState([]) const terminalsRef = useRef([]) @@ -3640,6 +3649,17 @@ export default function SessionScreen() { void handleCreateTerminal() }, [client, creating, creatingBrowser, creatingMarkdown, showEmptyState, worktreeId]) + // Why: the reconnect loop parks at its give-up cap; without an in-session + // affordance the only recovery is leaving the screen or restarting the + // app (issue #5049). Surface tap-to-retry once the verdict escalates. + const connectionVerdict = classifyConnection({ + state: connState, + reconnectAttempts, + lastConnectedAt + }) + const showConnectionRetry = + connectionVerdict.kind === 'warning' || connectionVerdict.kind === 'unreachable' + const terminalSummary = connState === 'connected' ? showLoadingState @@ -3647,7 +3667,9 @@ export default function SessionScreen() { : visibleTabs.length === 1 ? '1 tab' : `${visibleTabs.length} tabs` - : STATUS_LABELS[connState] + : showConnectionRetry + ? `${connectionVerdict.label} — tap to retry` + : STATUS_LABELS[connState] // Why: keep safe-area padding in layout at all times, then visually translate // the controls over the terminal when the keyboard appears. iOS keyboard @@ -3790,12 +3812,22 @@ export default function SessionScreen() { {worktreeName || 'Terminal'} - + { + if (hostId) { + void forceReconnectHost(hostId) + } + }} + accessibilityRole={showConnectionRetry ? 'button' : undefined} + accessibilityLabel={showConnectionRetry ? 'Reconnect to desktop' : undefined} + > {terminalSummary} - + [styles.filesButton, pressed && styles.filesButtonPressed]} diff --git a/mobile/app/h/[hostId]/source-control/[worktreeId].tsx b/mobile/app/h/[hostId]/source-control/[worktreeId].tsx index dad4d4cfe..59ea98736 100644 --- a/mobile/app/h/[hostId]/source-control/[worktreeId].tsx +++ b/mobile/app/h/[hostId]/source-control/[worktreeId].tsx @@ -30,7 +30,8 @@ import { Trash2, X } from 'lucide-react-native' -import { useHostClient } from '../../../../src/transport/client-context' +import { useHostClient, useForceReconnect } from '../../../../src/transport/client-context' +import { getWorktreeLabel } from '../../../../src/session/worktree-label' import type { RpcClient } from '../../../../src/transport/rpc-client' import type { RpcSuccess } from '../../../../src/transport/types' import { @@ -200,17 +201,6 @@ async function resolveMobileBranchCompareBaseRef( return result.defaultBaseRef?.trim() || null } -function getWorktreeLabel(name: string | undefined, worktreeId: string): string { - if (name?.trim()) { - return name.trim() - } - const pathPart = worktreeId.includes('::') - ? worktreeId.slice(worktreeId.indexOf('::') + 2) - : worktreeId - const normalized = pathPart.replace(/\\/g, '/').replace(/\/+$/, '') - return normalized.slice(normalized.lastIndexOf('/') + 1) || 'Worktree' -} - function formatBranchLabel(branch: string | undefined, head: string | undefined): string { if (branch?.startsWith('refs/heads/')) { return branch.slice('refs/heads/'.length) @@ -249,6 +239,7 @@ export default function MobileSourceControlScreen() { const router = useRouter() const insets = useSafeAreaInsets() const { client, state: connState } = useHostClient(hostId) + const forceReconnect = useForceReconnect() const [screenState, setScreenState] = useState({ kind: 'loading' }) const [branchCompareState, setBranchCompareState] = useState({ kind: 'idle' @@ -1431,7 +1422,20 @@ export default function MobileSourceControlScreen() { {screenState.message} {screenState.kind === 'error' ? ( - void loadStatus()}> + { + // Why: retrying the request is useless while the transport's + // reconnect loop is parked at its give-up cap — revive the + // connection instead (issue #5049). loadStatus re-runs via + // its connState effect once the new client connects. + if (connState !== 'connected' && hostId) { + void forceReconnect(hostId) + return + } + void loadStatus() + }} + > Retry ) : null} diff --git a/mobile/issue-5049-unresponsive-session-findings.md b/mobile/issue-5049-unresponsive-session-findings.md new file mode 100644 index 000000000..6440e1c99 --- /dev/null +++ b/mobile/issue-5049-unresponsive-session-findings.md @@ -0,0 +1,92 @@ +# Issue #5049: Android Remote Session Unresponsiveness — Findings + +Date: 2026-06-09 +Issue: https://github.com/stablyai/orca/issues/5049 + +## Reported symptoms + +Android + Tailscale remote session intermittently becomes unresponsive: tab/worktree +taps do nothing, pasted text doesn't execute, the connection "appears stuck instead +of clearly disconnected", and closing/reopening the app restores the session. + +## Root causes found (mobile-side) + +All three independently produce the exact reported symptom — a session that looks +alive but ignores input, recoverable only by an app restart: + +1. **Parked reconnect loop with no recovery path (primary).** `rpc-client.ts` + stops retrying permanently after `GIVE_UP_AFTER_ATTEMPTS` (12 attempts ≈ 6.5 min + of backoff). Android backgrounding + Doze + a Tailscale tunnel drop routinely + burns through all 12 attempts while the user is away. Nothing ever restarted the + loop: there was **no AppState listener anywhere in the transport layer**, so + returning to the foreground did not nudge the client. The state stays + `'reconnecting'` forever ("appears stuck instead of clearly disconnected"). + Reopening the app creates a fresh client with a fresh attempt budget — which is + exactly why "closing and reopening usually restores the session". + +2. **Half-open socket detection waits up to ~28s, and never starts earlier on + resume.** Android can kill the TCP path while backgrounded without delivering + `onclose`; `readyState` still reads OPEN, so every `terminal.send` (e.g. paste) + silently blackholes. The activity probe (20s interval + 8s timeout) eventually + reaps the link, but the first ~28s after resume look like "pasted text does not + run immediately" / "switching is very slow". + +3. **Stale client after `forceReconnect` (pre-existing `useHostClient` bug).** + `forceReconnect` swaps in a fresh `RpcClient`, but `useHostClient` only re-read + the client when its ref was still `null`. Any mounted screen kept driving the + old, **closed** client forever: the status header (fed by provider-level state + listeners) shows "Connected" while every RPC instantly fails with "Client + closed" — a session that looks alive but ignores all input. + + Additionally, the session screen (where users actually live) had no recovery + affordance at all: just a status label, while the Retry buttons exist only on + the home/host/tasks screens. + +## Fixes + +- `src/transport/rpc-client.ts` — new `notifyForeground()`: + - state `connected` → restart the probe interval and run one probe immediately + (half-open link reaped in ≤8s instead of ≤28s); + - state `reconnecting` → clear any pending backoff timer, reset the attempt + budget, reconnect immediately (un-parks the give-up cap). + - (Also extracted the duplicated close/error event serialization into + `socket-event-debug.ts` to stay under the file's line cap.) +- `src/transport/client-context.tsx`: + - `RpcClientProvider` now listens to AppState and calls `notifyForeground()` on + every live client when the app becomes active. + - `useHostClient` re-reads the underlying client on every state change, so + screens pick up the fresh client after `forceReconnect` instead of driving a + closed one. +- `app/h/[hostId]/session/[worktreeId].tsx` — the status row in the session header + becomes tappable once `classifyConnection` escalates to warning/unreachable, + showing "