perf(emulator): park device stream when the window is hidden (#9842)

The iOS MJPEG and Android scrcpy device streams are gated only on the pane
being the active tab (isActive, PR #7382). When the emulator tab is frontmost
but the whole Orca window is hidden/minimized/occluded/display-asleep, the
full-fps pipeline keeps running: main-process socket read + JPEG/H.264 decode
+ IPC + renderer decode. Renderer background-throttling (#9395) cannot stop it
because the pipeline is IPC-push driven from main.

Gate showStream additionally on window visibility via a new occlusion-safe
hook that honors the terminal stale-visibility latch (so a display-sleep
occlusion wedge can't freeze the emulator on a black frame) and delays the
visible->hidden park by 500ms so a quick Cmd+Tab round-trip doesn't renegotiate
the device stream.

Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
Neil 2026-07-22 14:33:03 -07:00 committed by GitHub
parent 17e471c51e
commit 8cfb8a2a2b
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
3 changed files with 148 additions and 3 deletions

View File

@ -4,6 +4,8 @@ import { act } from 'react'
import { createRoot, type Root } from 'react-dom/client'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { EmulatorDeviceFrame } from './emulator-device-frame'
import { resetStaleDocumentVisibilityForTesting } from '../terminal-pane/stale-document-visibility'
import { EMULATOR_STREAM_PARK_DELAY_MS } from './use-emulator-stream-window-visibility'
// Why: a backgrounded but still-attached emulator must stop streaming frames.
// The perf contract is that no frame stream is started (no per-frame IPC / MJPEG
@ -56,9 +58,20 @@ afterEach(() => {
delete (URL as Partial<typeof URL>).createObjectURL
delete (URL as Partial<typeof URL>).revokeObjectURL
delete (window as { api?: unknown }).api
setDocumentVisibility('visible')
resetStaleDocumentVisibilityForTesting()
vi.useRealTimers()
vi.restoreAllMocks()
})
function setDocumentVisibility(state: 'visible' | 'hidden'): void {
Object.defineProperty(document, 'visibilityState', {
configurable: true,
get: () => state
})
document.dispatchEvent(new Event('visibilitychange'))
}
async function renderFrame(isActive: boolean): Promise<void> {
await act(async () => {
root.render(
@ -102,3 +115,71 @@ describe('EmulatorDeviceFrame visibility gating', () => {
expect(startFrameStream).toHaveBeenCalledTimes(2)
})
})
describe('EmulatorDeviceFrame window-visibility gating', () => {
it('parks the stream after the window stays hidden past the park delay, and resumes when visible', async () => {
vi.useFakeTimers()
await renderFrame(true)
expect(startFrameStream).toHaveBeenCalledTimes(1)
// Hiding the window does not tear down immediately — a short grace covers a
// quick Cmd+Tab round-trip.
await act(async () => {
setDocumentVisibility('hidden')
})
expect(stopFrameStream).not.toHaveBeenCalled()
// Once the grace elapses, the stream parks at the source.
await act(async () => {
vi.advanceTimersByTime(EMULATOR_STREAM_PARK_DELAY_MS)
})
expect(stopFrameStream).toHaveBeenCalledWith({ streamId: 'stream-1' })
// Returning to the window resumes immediately; the session was never detached.
await act(async () => {
setDocumentVisibility('visible')
})
expect(startFrameStream).toHaveBeenCalledTimes(2)
})
it('does not tear down on a quick hide/show within the park delay', async () => {
vi.useFakeTimers()
await renderFrame(true)
expect(startFrameStream).toHaveBeenCalledTimes(1)
await act(async () => {
setDocumentVisibility('hidden')
})
await act(async () => {
vi.advanceTimersByTime(EMULATOR_STREAM_PARK_DELAY_MS - 100)
})
await act(async () => {
setDocumentVisibility('visible')
})
// The park timer was cancelled by the return-to-visible, so the stream never
// stopped and no reconnect was needed.
await act(async () => {
vi.advanceTimersByTime(EMULATOR_STREAM_PARK_DELAY_MS)
})
expect(stopFrameStream).not.toHaveBeenCalled()
expect(startFrameStream).toHaveBeenCalledTimes(1)
})
it('keeps streaming while hidden when occlusion state is proven stale (display-sleep wedge)', async () => {
vi.useFakeTimers()
await renderFrame(true)
expect(startFrameStream).toHaveBeenCalledTimes(1)
// Window reports hidden, but real user input proves the occlusion tracker is
// wedged; the stream must keep running instead of freezing on a black frame.
await act(async () => {
setDocumentVisibility('hidden')
document.dispatchEvent(new KeyboardEvent('keydown', { key: 'a' }))
})
await act(async () => {
vi.advanceTimersByTime(EMULATOR_STREAM_PARK_DELAY_MS * 2)
})
expect(stopFrameStream).not.toHaveBeenCalled()
expect(startFrameStream).toHaveBeenCalledTimes(1)
})
})

View File

@ -31,6 +31,7 @@ import { EmulatorScreenSurface } from './emulator-screen-surface'
import { useEmulatorControlStream } from './use-emulator-control-stream'
import { useEmulatorPaneSize } from './use-emulator-pane-size'
import { useEmulatorScreenKeyboard } from './use-emulator-screen-keyboard'
import { useEmulatorStreamWindowVisible } from './use-emulator-stream-window-visibility'
type EmulatorDeviceFrameProps = {
previewUrl?: string
@ -335,9 +336,13 @@ export function EmulatorDeviceFrame({
setStreamError(true)
}, [])
// Why: hidden panes still receive emulator frames, including over SSH, so
// parking the stream avoids background decode/IPC churn while staying attached.
const showStream = isActive && isLive && Boolean(previewUrl)
// Why: a hidden/occluded window (or a background tab) still receives emulator
// frames, including over SSH; parking the stream avoids background decode/IPC
// churn while staying attached. isActive covers the background-tab case;
// windowVisibleForStream additionally parks when the whole window is hidden
// (minimize / occlusion / display sleep), which no tab gate catches.
const windowVisibleForStream = useEmulatorStreamWindowVisible()
const showStream = isActive && isLive && windowVisibleForStream && Boolean(previewUrl)
const streamAspectRatio = streamSize ? streamSize.width / streamSize.height : 9 / 19
// Why: serve-sim may keep portrait-sized pixels for portrait-locked apps; the
// physical frame still follows the last successful rotate request.

View File

@ -0,0 +1,59 @@
import { useEffect, useState, useSyncExternalStore } from 'react'
import { isWindowVisible } from '@/lib/window-visibility-interval'
import {
isDocumentVisibilityProvenStale,
registerStaleDocumentVisibilityRecovery
} from '../terminal-pane/stale-document-visibility'
// Why: after display sleep macOS can wedge document.visibilityState at 'hidden'
// with no further visibilitychange event; honor the terminal occlusion-staleness
// latch so a window the user is actually looking at is never treated as hidden —
// otherwise the emulator freezes on a black frame with no recovery (same bug class
// as the 78MB terminal drop that motivated the latch).
function getWindowVisibleSnapshot(): boolean {
return isWindowVisible() || isDocumentVisibilityProvenStale()
}
function subscribeWindowVisible(onChange: () => void): () => void {
const handler = (): void => onChange()
document.addEventListener('visibilitychange', handler)
// Why: the stale latch flips visibility to proven-visible without emitting a
// visibilitychange, so recompute when it fires too.
const unregister = registerStaleDocumentVisibilityRecovery(handler)
return () => {
document.removeEventListener('visibilitychange', handler)
unregister()
}
}
// Why: parking is delayed so a quick Cmd+Tab / app-switch round-trip does not tear
// down and renegotiate the device stream (MJPEG reconnect or scrcpy H.264 keyframe),
// which is heavier than a terminal resync and flashes the "Connecting…" UI. Re-showing
// restores immediately.
export const EMULATOR_STREAM_PARK_DELAY_MS = 500
/**
* Reactive "is this window visible enough to keep the emulator device stream
* running" signal. Returns true while the window is visible (or occlusion state is
* proven stale) and defers the visiblehidden transition by `parkDelayMs`.
*/
export function useEmulatorStreamWindowVisible(
parkDelayMs = EMULATOR_STREAM_PARK_DELAY_MS
): boolean {
const rawVisible = useSyncExternalStore(
subscribeWindowVisible,
getWindowVisibleSnapshot,
getWindowVisibleSnapshot
)
const [effectiveVisible, setEffectiveVisible] = useState(rawVisible)
useEffect(() => {
if (rawVisible) {
// Restore immediately so returning to the window resumes without delay.
setEffectiveVisible(true)
return
}
const timer = window.setTimeout(() => setEffectiveVisible(false), parkDelayMs)
return () => window.clearTimeout(timer)
}, [rawVisible, parkDelayMs])
return effectiveVisible
}