diff --git a/config/reliability-gates.jsonc b/config/reliability-gates.jsonc index ce712d1c01b9..402402114721 100644 --- a/config/reliability-gates.jsonc +++ b/config/reliability-gates.jsonc @@ -5217,6 +5217,99 @@ ], "demotionRule": "Keep experimental or demote if retries can stop before recovery or exhaustion, continue after exhaustion, run in parallel, mutate a superseded pane, hide the Reconnect control, leak a subscription or timer, or fail to paint a new frame after manual reconnect." }, + { + "id": "mobile-browser.frame-continuity", + "title": "Mobile browser frames resume after authenticated transport changes", + "maturity": "experimental", + "protection": "partial", + "owner": "mobile-ui", + "layer": "react-native-image-decoder-and-mobile-transport-contract", + "surfaces": [ + "mobile browser screencast pane", + "React Native double-buffered frame decoder", + "direct mobile RPC reconnect", + "relay and direct logical migration" + ], + "platforms": ["ios", "android", "macos", "linux", "windows"], + "providers": ["lan", "tailscale", "cloud-relay"], + "coveredPlatforms": ["macos"], + "coveredProviders": ["lan", "cloud-relay"], + "coverageNotes": "A deterministic React Native renderer harness wedges one hidden Image decoder, proves one pending-layer remount at the authenticated boundary, rejects its stale callback, preserves the visible frame, and promotes the newest frame without subscription churn. Physical-socket and stable logical client controls prove one browser-stream replacement attach and one connected notification for direct reconnect plus LAN-to-Relay and Relay-to-LAN migration. Native iOS transition evidence is blocked because the unsigned local development runtime exposes neither Relay nor a safely scoped physical-drop control; Android and live Tailscale evidence are also uncollected.", + "motivatingLinks": [ + "https://github.com/stablyai/orca/issues/14274", + "https://linear.app/stably/issue/STA-4128" + ], + "invariant": "After a physical RPC reconnect or authenticated Relay/direct migration, a mounted mobile browser pane must keep its last decoded frame visible, discard only an unresolved hidden decoder, reject callbacks from that discarded decoder, and promote the newest replacement frame without reopening the tab or duplicating the logical browser.screencast subscription. A connected notification with no pending decode must perform no React render or native mutation.", + "oracle": "Render one pane with native wrapper and Image identity instrumentation, paint layer zero, advance the 100 ms throttle with a second frame while withholding the hidden layer onLoad, then publish connected on the same logical client. Require the visible decoder and URI to survive, exactly the hidden decoder to remount with its pending URI, a stale callback to do nothing, a newer frame to reach that replacement, and its current callback to promote it while one logical subscription remains. Separately require a healthy connected boundary to produce zero commits and native mutations, a physical socket replacement to replay browser.screencast once and publish connected once, and each logical migration direction to attach it once and publish connected once.", + "commands": [ + "pnpm vitest run --config mobile/vitest.config.ts mobile/src/browser/mobile-browser-frameless-stream.test.tsx mobile/src/transport/rpc-client-terminal-reconnect.test.ts mobile/src/transport/stable-logical-rpc-client.test.ts" + ], + "testFiles": [ + "mobile/src/browser/mobile-browser-frameless-stream.test.tsx", + "mobile/src/transport/rpc-client-terminal-reconnect.test.ts", + "mobile/src/transport/stable-logical-rpc-client.test.ts" + ], + "assertionRefs": [ + { + "file": "mobile/src/browser/mobile-browser-frameless-stream.test.tsx", + "assertions": [ + "replaces only a pending decoder after reconnect and promotes the newest frame", + "does no rendering or native work on a healthy connected boundary" + ] + }, + { + "file": "mobile/src/transport/rpc-client-terminal-reconnect.test.ts", + "assertions": [ + "replays one browser screencast and publishes one connected state after reconnect" + ] + }, + { + "file": "mobile/src/transport/stable-logical-rpc-client.test.ts", + "assertions": [ + "attaches one browser screencast and publishes connected for LAN to Relay migration", + "attaches one browser screencast and publishes connected for Relay to LAN migration" + ] + } + ], + "evidenceRuns": [ + { + "date": "2026-08-23", + "runner": "local", + "platform": "macos", + "command": "pnpm vitest run --config mobile/vitest.config.ts mobile/src/browser/mobile-browser-frameless-stream.test.tsx mobile/src/transport/rpc-client-terminal-reconnect.test.ts mobile/src/transport/stable-logical-rpc-client.test.ts", + "result": "passed", + "durationSeconds": 0.23, + "summary": "Three focused files passed 25 deterministic pane-continuity, physical reconnect, and logical migration tests." + } + ], + "runtimeBudget": { + "p95Seconds": 5, + "scope": "focused React Native renderer and mobile transport contracts" + }, + "flakeHistory": { + "status": "unknown", + "evidence": "The deterministic focused gate passes locally; CI and soak history are not yet available." + }, + "redGreenEvidence": { + "status": "complete", + "evidence": "The byte-identical pending-decoder oracle fails on current main because connected does not replace the hidden native Image, passes with the decoder-epoch repair, and fails at the same native-identity assertion when only that repair is disabled. The healthy-boundary and transport controls remain green throughout." + }, + "performanceBudget": { + "required": true, + "evidence": "Each mounted pane adds one client state listener with exact cleanup and O(1) work per notification. Healthy connected notifications perform zero React commits, native mutations, timers, RPCs, or subscription operations; a wedged transition remounts exactly one hidden wrapper and Image while preserving the visible layer." + }, + "promotionCriteria": [ + "Collect 100 consecutive focused CI passes or 14 days without an unexplained flake.", + "Run a mounted browser pane through physical socket recovery and Relay/direct migration on native iOS and Android builds.", + "Collect live LAN, Tailscale, and cloud-relay frame-identity evidence without subscription duplication." + ], + "knownGaps": [ + "The unsigned local iOS development runtime cannot expose Relay or a safely scoped physical socket drop, so native transition and screenshot evidence are blocked.", + "Android native decoder behavior and lifecycle transitions are untested.", + "Tailscale, carrier, sleep/wake, and real network-partition transitions remain deterministic transport or structural coverage rather than live evidence." + ], + "demotionRule": "Keep experimental or demote if reconnect can replace the visible decoder, retain a wedged hidden decoder, accept a stale decoder callback, duplicate browser.screencast, render or mutate native state on a healthy boundary, or fail to promote the next frame." + }, { "id": "editor.live-log-append-stability", "title": "Long live session logs retain their Monaco viewport while appending", diff --git a/mobile/src/browser/MobileBrowserPane.tsx b/mobile/src/browser/MobileBrowserPane.tsx index f823f3096308..aef7bb1b8f41 100644 --- a/mobile/src/browser/MobileBrowserPane.tsx +++ b/mobile/src/browser/MobileBrowserPane.tsx @@ -43,6 +43,7 @@ import { computeBrowserTouchClickRadiusCss, mapScreenToBrowserPoint, readLocalTouchPoint, + type BrowserFrameGeometry, type BrowserPoint, type BrowserTouchLayout, type BrowserZoomState @@ -96,6 +97,13 @@ type MobileBrowserPaneProps = { onToast: (message: string, durationMs?: number) => void } +type FrameLayerPair = [T, T] + +type PendingFrameTransition = { + layer: FrameLayer + decoderEpoch: number +} + type PanGesture = { x: number y: number @@ -167,10 +175,16 @@ export function MobileBrowserPane({ ) const frameUriRef = useRef(cachedInitialFrame?.uri ?? null) const frameMountedRef = useRef(cachedInitialFrame !== null) - const browserImageRefs = useRef<[Image | null, Image | null]>([null, null]) - const browserLayerRefs = useRef<[View | null, View | null]>([null, null]) - const pendingFrameLayerRef = useRef(null) + const frameLayerUriRefs = useRef>([ + cachedInitialFrame?.uri ?? null, + null + ]) + const browserImageRefs = useRef>([null, null]) + const browserLayerRefs = useRef>([null, null]) + const pendingFrameTransitionRef = useRef(null) const visibleFrameLayerRef = useRef(0) + const frameDecoderEpochsRef = useRef>([0, 0]) + const [frameDecoderEpochs, setFrameDecoderEpochs] = useState>([0, 0]) const busyRef = useRef(false) const lastAppliedFrameAtRef = useRef(0) const pendingThrottledFrameRef = useRef<{ @@ -284,22 +298,29 @@ export function MobileBrowserPane({ cacheBrowserFrame(frameCacheKey, { uri: nextFrameUri, metadata: frame.metadata }) if (!frameMountedRef.current) { frameUriRef.current = nextFrameUri + frameLayerUriRefs.current[0] = nextFrameUri frameMountedRef.current = true setFrameUri(nextFrameUri) updateBrowserImageSource(browserImageRefs.current[0], nextFrameUri) - } else if (pendingFrameLayerRef.current === null) { + } else if (pendingFrameTransitionRef.current === null) { // Why: decode the next frame offscreen and keep the previous layer visible // until onLoad; replacing the visible Image directly flashes black. const nextLayer: FrameLayer = visibleFrameLayerRef.current === 0 ? 1 : 0 frameUriRef.current = nextFrameUri - pendingFrameLayerRef.current = nextLayer + frameLayerUriRefs.current[nextLayer] = nextFrameUri + pendingFrameTransitionRef.current = { + layer: nextLayer, + decoderEpoch: frameDecoderEpochsRef.current[nextLayer] + } updateBrowserImageSource(browserImageRefs.current[nextLayer], nextFrameUri) } else { // Why: popovers/menus can settle in one final frame while the previous // offscreen frame is still decoding. Keep the hidden layer pointed at // the newest frame instead of dropping the final static state. frameUriRef.current = nextFrameUri - updateBrowserImageSource(browserImageRefs.current[pendingFrameLayerRef.current], nextFrameUri) + const pendingLayer = pendingFrameTransitionRef.current.layer + frameLayerUriRefs.current[pendingLayer] = nextFrameUri + updateBrowserImageSource(browserImageRefs.current[pendingLayer], nextFrameUri) } if (busyRef.current) { busyRef.current = false @@ -388,12 +409,14 @@ export function MobileBrowserPane({ const cachedFrame = getCachedBrowserFrame(cacheKey) if (cachedFrame) { frameUriRef.current = cachedFrame.uri + frameLayerUriRefs.current = [cachedFrame.uri, null] frameMountedRef.current = true frameMetadataRef.current = cachedFrame.metadata setFrameUri(cachedFrame.uri) setFrameMetadata(cachedFrame.metadata) } else { frameUriRef.current = null + frameLayerUriRefs.current = [null, null] frameMountedRef.current = false setFrameUri(null) setFrameMetadata(null) @@ -402,7 +425,7 @@ export function MobileBrowserPane({ } else { frameMountedRef.current = true } - pendingFrameLayerRef.current = null + pendingFrameTransitionRef.current = null if (!sameStream || !frameUriRef.current) { visibleFrameLayerRef.current = 0 } @@ -532,6 +555,29 @@ export function MobileBrowserPane({ worktreeId ]) + useEffect(() => { + if (!client) { + return + } + return client.onStateChange((state) => { + if (state !== 'connected') { + return + } + const pending = pendingFrameTransitionRef.current + if (!pending) { + return + } + const nextEpochs: FrameLayerPair = [...frameDecoderEpochsRef.current] + nextEpochs[pending.layer] += 1 + frameDecoderEpochsRef.current = nextEpochs + pendingFrameTransitionRef.current = { + layer: pending.layer, + decoderEpoch: nextEpochs[pending.layer] + } + setFrameDecoderEpochs(nextEpochs) + }) + }, [client]) + const sendBrowserRequest = useCallback( async ( method: string, @@ -941,9 +987,9 @@ export function MobileBrowserPane({ const setBrowserImageRef = useCallback((layer: FrameLayer, image: Image | null) => { browserImageRefs.current[layer] = image - const currentFrameUri = frameUriRef.current - if (image && currentFrameUri) { - updateBrowserImageSource(image, currentFrameUri) + const layerFrameUri = frameLayerUriRefs.current[layer] + if (image && layerFrameUri) { + updateBrowserImageSource(image, layerFrameUri) } }, []) const setBrowserLayerRef = useCallback((layer: FrameLayer, view: View | null) => { @@ -967,35 +1013,21 @@ export function MobileBrowserPane({ [setBrowserImageRef] ) - const handleBrowserImageLoad = useCallback((layer: FrameLayer) => { - if (pendingFrameLayerRef.current !== layer) { + const handleBrowserImageLoad = useCallback((layer: FrameLayer, decoderEpoch: number) => { + const pending = pendingFrameTransitionRef.current + if (!pending || pending.layer !== layer || pending.decoderEpoch !== decoderEpoch) { return } - pendingFrameLayerRef.current = null + pendingFrameTransitionRef.current = null visibleFrameLayerRef.current = layer updateBrowserLayerVisibility(browserLayerRefs.current, layer) }, []) - const handleBrowserImageLayer0Load = useCallback( - () => handleBrowserImageLoad(0), - [handleBrowserImageLoad] - ) - const handleBrowserImageLayer1Load = useCallback( - () => handleBrowserImageLoad(1), - [handleBrowserImageLoad] - ) - const handleBrowserImageError = useCallback((layer: FrameLayer) => { - if (pendingFrameLayerRef.current === layer) { - pendingFrameLayerRef.current = null + const handleBrowserImageError = useCallback((layer: FrameLayer, decoderEpoch: number) => { + const pending = pendingFrameTransitionRef.current + if (pending?.layer === layer && pending.decoderEpoch === decoderEpoch) { + pendingFrameTransitionRef.current = null } }, []) - const handleBrowserImageLayer0Error = useCallback( - () => handleBrowserImageError(0), - [handleBrowserImageError] - ) - const handleBrowserImageLayer1Error = useCallback( - () => handleBrowserImageError(1), - [handleBrowserImageError] - ) const controlsDisabled = !client || !tab.browserPageId || screencastSupported !== true const goBack = useCallback(() => { @@ -1028,8 +1060,7 @@ export function MobileBrowserPane({ }, [browserViewMode, resetBrowserZoomState, tab.browserPageId, worktreeId] ) - const renderedFrameSource = - frameUriRef.current || frameUri ? { uri: frameUriRef.current ?? frameUri! } : null + const hasRenderedFrame = Boolean(frameUriRef.current || frameUri) const frameLayerStyle = useCallback((layer: FrameLayer) => { return [ styles.browserImageLayer, @@ -1044,15 +1075,32 @@ export function MobileBrowserPane({ (layer: FrameLayer) => (layer === 0 ? setBrowserImageLayer0Ref : setBrowserImageLayer1Ref), [setBrowserImageLayer0Ref, setBrowserImageLayer1Ref] ) - const frameLayerLoadHandler = useCallback( - (layer: FrameLayer) => - layer === 0 ? handleBrowserImageLayer0Load : handleBrowserImageLayer1Load, - [handleBrowserImageLayer0Load, handleBrowserImageLayer1Load] - ) - const frameLayerErrorHandler = useCallback( - (layer: FrameLayer) => - layer === 0 ? handleBrowserImageLayer0Error : handleBrowserImageLayer1Error, - [handleBrowserImageLayer0Error, handleBrowserImageLayer1Error] + const renderBrowserFrameLayer = (layer: FrameLayer, geometry: BrowserFrameGeometry | null) => ( + + handleBrowserImageLoad(layer, frameDecoderEpochs[layer])} + onError={() => handleBrowserImageError(layer, frameDecoderEpochs[layer])} + style={ + geometry + ? [ + styles.browserImage, + { width: geometry.renderedWidth, height: geometry.renderedHeight } + ] + : styles.browserImageFill + } + /> + ) return ( @@ -1111,7 +1159,7 @@ export function MobileBrowserPane({ }} {...panResponder.panHandlers} > - {renderedFrameSource ? ( + {hasRenderedFrame ? ( {frameGeometry ? ( - {([0, 1] as const).map((layer) => ( - - - - ))} + {([0, 1] as const).map((layer) => renderBrowserFrameLayer(layer, frameGeometry))} ) : ( - ([0, 1] as const).map((layer) => ( - - - - )) + ([0, 1] as const).map((layer) => renderBrowserFrameLayer(layer, null)) )} ) : null} - {!renderedFrameSource || busy || error ? ( + {!hasRenderedFrame || busy || error ? ( {/* Why: a stream can report ready and then deliver no frames, so key the indicator off actually having pixels or it clears into a blank pane. */} - {busy || (!renderedFrameSource && !error) ? ( + {busy || (!hasRenderedFrame && !error) ? ( ) : null} {error ? {error} : null} diff --git a/mobile/src/browser/mobile-browser-frameless-stream.test.tsx b/mobile/src/browser/mobile-browser-frameless-stream.test.tsx index bf486d3c5c00..bc0050024c1e 100644 --- a/mobile/src/browser/mobile-browser-frameless-stream.test.tsx +++ b/mobile/src/browser/mobile-browser-frameless-stream.test.tsx @@ -1,12 +1,13 @@ import { Buffer } from 'buffer' -import { createElement } from 'react' -import { act, create, type ReactTestRenderer } from 'react-test-renderer' -import { describe, expect, it, vi } from 'vitest' +import { createElement, Profiler } from 'react' +import { act, create, type ReactTestInstance, type ReactTestRenderer } from 'react-test-renderer' +import { afterEach, describe, expect, it, vi } from 'vitest' import { BrowserScreencastOpcode, type BrowserScreencastFrame } from '../transport/browser-screencast-protocol' import type { RpcClient } from '../transport/rpc-client' +import type { ConnectionState } from '../transport/types' import { MobileBrowserPane, type MobileBrowserTab } from './MobileBrowserPane' vi.mock('react-native', () => ({ @@ -40,17 +41,32 @@ vi.mock('lucide-react-native', () => ({ type Subscription = { listener: (payload: unknown) => void onBinaryFrame?: (frame: BrowserScreencastFrame) => void + active: boolean +} + +type NativeMock = { + id: number + type: string + frameLayer: boolean + setNativeProps: ReturnType +} + +type FrameLayer = { + image: ReactTestInstance + imageNative: NativeMock + wrapperNative: NativeMock } let pageCounter = 0 +let nativeCounter = 0 -function makeFrame(): BrowserScreencastFrame { +function makeFrame(contents = 'frame', seq = 1): BrowserScreencastFrame { return { opcode: BrowserScreencastOpcode.Frame, - seq: 1, + seq, format: 'jpeg', metadata: { deviceWidth: 360, deviceHeight: 640, pageScaleFactor: 1 }, - image: new TextEncoder().encode('frame') + image: new TextEncoder().encode(contents) } } @@ -58,9 +74,57 @@ function spinnerCount(renderer: ReactTestRenderer): number { return renderer.root.findAllByType('ActivityIndicator').length } -async function renderPane(): Promise<{ renderer: ReactTestRenderer; stream: Subscription }> { +function frameUri(contents: string): string { + return `data:image/jpeg;base64,${Buffer.from(contents).toString('base64')}` +} + +function getFrameLayers( + renderer: ReactTestRenderer, + nativeMocks: NativeMock[], + useReplacementLayer = false +): [FrameLayer, FrameLayer] { + const images = renderer.root.findAllByType('Image') + const imageMocks = nativeMocks.filter(({ type }) => type === 'Image') + const wrapperMocks = nativeMocks.filter(({ type, frameLayer }) => type === 'View' && frameLayer) + const secondIndex = useReplacementLayer ? imageMocks.length - 1 : 1 + const secondWrapperIndex = useReplacementLayer ? wrapperMocks.length - 1 : 1 + if (images.length !== 2 || !imageMocks[0] || !imageMocks[secondIndex]) { + throw new Error('Expected two native frame Image layers') + } + if (!wrapperMocks[0] || !wrapperMocks[secondWrapperIndex]) { + throw new Error('Expected two native frame wrapper layers') + } + return [ + { image: images[0], imageNative: imageMocks[0], wrapperNative: wrapperMocks[0] }, + { + image: images[1], + imageNative: imageMocks[secondIndex], + wrapperNative: wrapperMocks[secondWrapperIndex] + } + ] +} + +function latestNativeImageUri(image: NativeMock): string | undefined { + const lastCall = image.setNativeProps.mock.calls.at(-1)?.[0] as + | { source?: { uri?: string }[] } + | undefined + return lastCall?.source?.[0]?.uri +} + +async function renderPane(): Promise<{ + renderer: ReactTestRenderer + stream: Subscription + emitState: (state: ConnectionState) => void + activeSubscriptionCount: () => number + stateListenerCount: () => number + renderCount: () => number + nativeMocks: NativeMock[] +}> { pageCounter += 1 const subscriptions: Subscription[] = [] + const stateListeners = new Set<(state: ConnectionState) => void>() + const nativeMocks: NativeMock[] = [] + let commits = 0 const client = { subscribe: ( _method: string, @@ -68,8 +132,15 @@ async function renderPane(): Promise<{ renderer: ReactTestRenderer; stream: Subs listener: (payload: unknown) => void, options?: { onBinaryFrame?: (frame: BrowserScreencastFrame) => void } ) => { - subscriptions.push({ listener, onBinaryFrame: options?.onBinaryFrame }) - return () => {} + const subscription = { listener, onBinaryFrame: options?.onBinaryFrame, active: true } + subscriptions.push(subscription) + return () => { + subscription.active = false + } + }, + onStateChange: (listener: (state: ConnectionState) => void) => { + stateListeners.add(listener) + return () => stateListeners.delete(listener) }, request: vi.fn() } as unknown as RpcClient @@ -90,17 +161,32 @@ async function renderPane(): Promise<{ renderer: ReactTestRenderer; stream: Subs let renderer: ReactTestRenderer await act(async () => { renderer = create( - createElement(MobileBrowserPane, { - client, - // Why: unique worktree id keeps each test on a cold module-level frame cache. - worktreeId: `wt-${pageCounter}`, - tab, - screencastSupported: true, - keyboardLift: 0, - bottomInset: 0, - onToast: () => {} - }), - { createNodeMock: () => ({ setNativeProps: () => {} }) } + createElement( + Profiler, + { id: 'mobile-browser-pane', onRender: () => (commits += 1) }, + createElement(MobileBrowserPane, { + client, + // Why: unique worktree id keeps each test on a cold module-level frame cache. + worktreeId: `wt-${pageCounter}`, + tab, + screencastSupported: true, + keyboardLift: 0, + bottomInset: 0, + onToast: () => {} + }) + ), + { + createNodeMock: (element) => { + const nativeMock = { + id: ++nativeCounter, + type: String(element.type), + frameLayer: element.type === 'View' && element.props.pointerEvents === 'none', + setNativeProps: vi.fn() + } + nativeMocks.push(nativeMock) + return nativeMock + } + } ) await Promise.resolve() }) @@ -118,9 +204,25 @@ async function renderPane(): Promise<{ renderer: ReactTestRenderer; stream: Subs if (!stream) { throw new Error('browser.screencast subscription not created') } - return { renderer: mounted, stream } + return { + renderer: mounted, + stream, + emitState: (state) => { + for (const listener of stateListeners) { + listener(state) + } + }, + activeSubscriptionCount: () => subscriptions.filter(({ active }) => active).length, + stateListenerCount: () => stateListeners.size, + renderCount: () => commits, + nativeMocks + } } +afterEach(() => { + vi.useRealTimers() +}) + describe('MobileBrowserPane with a stream that reports ready but sends no frames', () => { // Why: a host that stops painting still reports `ready`, so the pane used to clear its // indicator and leave an unexplained black rectangle. @@ -134,6 +236,157 @@ describe('MobileBrowserPane with a stream that reports ready but sends no frames expect(spinnerCount(renderer)).toBeGreaterThan(0) }) + it('replaces only a pending decoder after reconnect and promotes the newest frame', async () => { + vi.useFakeTimers() + vi.setSystemTime(1_000) + const { renderer, stream, emitState, activeSubscriptionCount, nativeMocks } = await renderPane() + + act(() => { + stream.onBinaryFrame?.(makeFrame('visible', 1)) + }) + const initialLayers = getFrameLayers(renderer, nativeMocks) + + act(() => { + stream.onBinaryFrame?.(makeFrame('pending', 2)) + vi.advanceTimersByTime(100) + }) + expect(latestNativeImageUri(initialLayers[1].imageNative)).toBe(frameUri('pending')) + const staleLoad = initialLayers[1].image.props.onLoad as () => void + const staleError = initialLayers[1].image.props.onError as () => void + + act(() => emitState('connected')) + + const reconnectedLayers = getFrameLayers(renderer, nativeMocks, true) + expect(reconnectedLayers[0].imageNative).toBe(initialLayers[0].imageNative) + expect(reconnectedLayers[0].wrapperNative).toBe(initialLayers[0].wrapperNative) + expect(reconnectedLayers[1].imageNative).not.toBe(initialLayers[1].imageNative) + expect(reconnectedLayers[1].wrapperNative).not.toBe(initialLayers[1].wrapperNative) + expect(reconnectedLayers[0].image.props.source).toEqual({ uri: frameUri('visible') }) + expect(reconnectedLayers[1].image.props.source).toEqual({ uri: frameUri('pending') }) + expect(nativeMocks.filter(({ type }) => type === 'Image')).toHaveLength(3) + expect( + nativeMocks.filter(({ type, frameLayer }) => type === 'View' && frameLayer) + ).toHaveLength(3) + expect(activeSubscriptionCount()).toBe(1) + + for (const layer of reconnectedLayers) { + layer.wrapperNative.setNativeProps.mockClear() + } + act(() => { + staleLoad() + staleError() + }) + expect(reconnectedLayers[0].wrapperNative.setNativeProps).not.toHaveBeenCalled() + expect(reconnectedLayers[1].wrapperNative.setNativeProps).not.toHaveBeenCalled() + + act(() => { + stream.onBinaryFrame?.(makeFrame('newest', 3)) + vi.advanceTimersByTime(100) + }) + expect(latestNativeImageUri(reconnectedLayers[1].imageNative)).toBe(frameUri('newest')) + act(() => reconnectedLayers[1].image.props.onLoad()) + expect(reconnectedLayers[0].wrapperNative.setNativeProps).toHaveBeenLastCalledWith({ + style: { opacity: 0 } + }) + expect(reconnectedLayers[1].wrapperNative.setNativeProps).toHaveBeenLastCalledWith({ + style: { opacity: 1 } + }) + expect(activeSubscriptionCount()).toBe(1) + }) + + // Why: the load handler keys on (layer, decoderEpoch) and a B->C replacement reuses both, + // so a late B onLoad does promote. Pin what that promotion actually lands on. + it('promotes the newest frame when a superseded frame reports its load late', async () => { + vi.useFakeTimers() + vi.setSystemTime(1_000) + const { renderer, stream, nativeMocks } = await renderPane() + + act(() => { + stream.onBinaryFrame?.(makeFrame('visible', 1)) + }) + const layers = getFrameLayers(renderer, nativeMocks) + + act(() => { + stream.onBinaryFrame?.(makeFrame('superseded', 2)) + vi.advanceTimersByTime(100) + }) + expect(latestNativeImageUri(layers[1].imageNative)).toBe(frameUri('superseded')) + + // The final frame replaces the hidden layer's source without a new transition. + act(() => { + stream.onBinaryFrame?.(makeFrame('final', 3)) + vi.advanceTimersByTime(100) + }) + expect(latestNativeImageUri(layers[1].imageNative)).toBe(frameUri('final')) + + for (const layer of layers) { + layer.wrapperNative.setNativeProps.mockClear() + } + + // The superseded frame's load arrives after its source was replaced. + act(() => layers[1].image.props.onLoad()) + + // The promoted layer is already pointed at the newest frame, not the superseded one, + // and the layer it replaces held a strictly older frame. + expect(latestNativeImageUri(layers[1].imageNative)).toBe(frameUri('final')) + expect(layers[0].wrapperNative.setNativeProps).toHaveBeenLastCalledWith({ + style: { opacity: 0 } + }) + expect(layers[1].wrapperNative.setNativeProps).toHaveBeenLastCalledWith({ + style: { opacity: 1 } + }) + + // The newest frame's own load is then a no-op: the transition is already settled. + for (const layer of layers) { + layer.wrapperNative.setNativeProps.mockClear() + } + act(() => layers[1].image.props.onLoad()) + expect(layers[0].wrapperNative.setNativeProps).not.toHaveBeenCalled() + expect(layers[1].wrapperNative.setNativeProps).not.toHaveBeenCalled() + }) + + it('does no rendering or native work on a healthy connected boundary', async () => { + const { + renderer, + stream, + emitState, + activeSubscriptionCount, + stateListenerCount, + renderCount, + nativeMocks + } = await renderPane() + act(() => { + stream.onBinaryFrame?.(makeFrame('visible')) + }) + const layers = getFrameLayers(renderer, nativeMocks) + const commitsBeforeReconnect = renderCount() + for (const layer of layers) { + layer.imageNative.setNativeProps.mockClear() + layer.wrapperNative.setNativeProps.mockClear() + } + + act(() => emitState('connected')) + + const layersAfterReconnect = getFrameLayers(renderer, nativeMocks) + expect(renderCount()).toBe(commitsBeforeReconnect) + expect(layersAfterReconnect.map(({ imageNative }) => imageNative)).toEqual( + layers.map(({ imageNative }) => imageNative) + ) + expect(layersAfterReconnect.map(({ wrapperNative }) => wrapperNative)).toEqual( + layers.map(({ wrapperNative }) => wrapperNative) + ) + expect(layers.flatMap(({ imageNative }) => imageNative.setNativeProps.mock.calls)).toEqual([]) + expect(layers.flatMap(({ wrapperNative }) => wrapperNative.setNativeProps.mock.calls)).toEqual( + [] + ) + expect(activeSubscriptionCount()).toBe(1) + expect(stateListenerCount()).toBe(1) + + act(() => renderer.unmount()) + expect(activeSubscriptionCount()).toBe(0) + expect(stateListenerCount()).toBe(0) + }) + it('clears the indicator once real pixels arrive', async () => { const { renderer, stream } = await renderPane() diff --git a/mobile/src/transport/rpc-client-terminal-reconnect.test.ts b/mobile/src/transport/rpc-client-terminal-reconnect.test.ts index f382068b7356..3e59847751bc 100644 --- a/mobile/src/transport/rpc-client-terminal-reconnect.test.ts +++ b/mobile/src/transport/rpc-client-terminal-reconnect.test.ts @@ -74,10 +74,15 @@ function sentRequest(socket: MockWebSocket, method: string): { id: string } { throw new Error(`Request not sent: ${method}`) } -function sentRequests(socket: MockWebSocket, method: string): Array<{ id: string }> { +function sentRequests(socket: MockWebSocket, method: string): { id: string; params?: unknown }[] { return socket.sent .map( - (payload) => JSON.parse(payload.replace(/^encrypted:/, '')) as { id: string; method: string } + (payload) => + JSON.parse(payload.replace(/^encrypted:/, '')) as { + id: string + method: string + params?: unknown + } ) .filter((request) => request.method === method) } @@ -108,7 +113,7 @@ function encodeTerminalOutput(streamId: number, chunk: string): Uint8Array { }) } -describe('rpc-client terminal reconnect streams', () => { +describe('rpc-client reconnect streams', () => { beforeEach(() => { vi.useFakeTimers() mockSockets.length = 0 @@ -160,4 +165,29 @@ describe('rpc-client terminal reconnect streams', () => { }) client.close() }) + + it('replays one browser screencast and publishes one connected state after reconnect', () => { + const client = connect('ws://desktop.invalid', 'token', 'server-key') + const first = mockSockets[0]! + authenticate(first) + client.subscribe('browser.screencast', { worktree: 'id:wt-1', page: 'page-1' }, () => {}, { + onBinaryFrame: () => {} + }) + expect(sentRequests(first, 'browser.screencast')).toHaveLength(1) + const states: string[] = [] + client.onStateChange((state) => states.push(state)) + + first.close() + vi.advanceTimersByTime(500) + expect(mockSockets).toHaveLength(2) + const replacement = mockSockets[1]! + authenticate(replacement) + + expect(sentRequests(replacement, 'browser.screencast')).toEqual([ + expect.objectContaining({ params: { worktree: 'id:wt-1', page: 'page-1' } }) + ]) + expect(states.filter((state) => state === 'connected')).toHaveLength(1) + + client.close() + }) }) diff --git a/mobile/src/transport/stable-logical-rpc-client.test.ts b/mobile/src/transport/stable-logical-rpc-client.test.ts index 0ba7a1f2ea76..ffe06201ac09 100644 --- a/mobile/src/transport/stable-logical-rpc-client.test.ts +++ b/mobile/src/transport/stable-logical-rpc-client.test.ts @@ -118,6 +118,46 @@ describe('stable logical RPC client', () => { pending.resolve(success('late')) }) + it.each([ + ['lan', 'relay'], + ['relay', 'lan'] + ] as const)( + 'attaches one browser screencast and publishes connected for %s to %s migration', + async (fromPath, toPath) => { + const oldSession = new FakeSession('connected') + const replacement = new FakeSession('connected') + const client = createStableLogicalRpcClient(oldSession, fromPath) + const states: ConnectionState[] = [] + const onBinaryFrame = vi.fn() + client.subscribe('browser.screencast', { worktree: 'id:wt-1', page: 'page-1' }, vi.fn(), { + onBinaryFrame + }) + client.onStateChange((state) => states.push(state)) + + await client.migrateTo(replacement, toPath) + + expect(replacement.subscribe).toHaveBeenCalledOnce() + expect(replacement.subscribe).toHaveBeenCalledWith( + 'browser.screencast', + { worktree: 'id:wt-1', page: 'page-1' }, + expect.any(Function), + { onBinaryFrame: expect.any(Function) } + ) + // Why invoke it: asserting the shape alone passes on a no-op, which is exactly the + // dropped-frame bug this migration path is meant to fix. + const replayedOpts = replacement.subscribe.mock.calls[0]![3] as { + onBinaryFrame?: (frame: unknown) => void + } + const frame = { opcode: 1, seq: 7 } + replayedOpts.onBinaryFrame?.(frame) + expect(onBinaryFrame).toHaveBeenCalledOnce() + expect(onBinaryFrame).toHaveBeenCalledWith(frame) + expect(oldSession.close).toHaveBeenCalledOnce() + expect(states).toEqual(['connected']) + expect(client.getActivePath()).toBe(toPath) + } + ) + it('keeps replies that commit before cutover and carries viewport state into replay', async () => { const oldSession = new FakeSession('connected') const nextSession = new FakeSession('connected')