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:
parent
17e471c51e
commit
8cfb8a2a2b
|
|
@ -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)
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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 visible→hidden 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
|
||||
}
|
||||
Loading…
Reference in New Issue