perf(emulator): stop decoding frames for hidden emulator panes

A backgrounded but still-attached emulator kept the frame stream running:
continuous WebCodecs H.264 decode + ctx.drawImage (Android) or Blob +
createObjectURL per MJPEG frame (iOS) painted to a visibility:hidden canvas,
with frames still flowing over IPC (and the network for paired SSH/remote
clients) to no viewer. This stacks per open worktree holding a live emulator.

EmulatorPane already threads isActive (it gates autoAttachOnMount) but never
passed it to EmulatorDeviceFrame, so showStream gated only on isLive. Gate it
on isActive too. The overlay slot already sets visibility:hidden when isActive
is false, so this can never blank a visible stream. The session stays attached
(autoAttachOnMount untouched), so the stream re-fires within a frame on re-show
via the existing enabled effect. Mirrors the browser pane's park-when-hidden.

Both stream hooks tear down on disable: useEmulatorVideoStream stops the H.264
forwarding at the source (stopVideoStream) and closes the decoder;
useEmulatorFrameStream calls stopFrameStream and revokes the Blob URL.

Test asserts the IPC contract (not just DOM): no startFrameStream while the
pane is inactive, and stopFrameStream on hide. Reverting the gate fails 2/3.

Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
Neil
2026-07-04 17:15:58 -07:00
co-authored by Orca
parent 568722a320
commit cc55033320
4 changed files with 116 additions and 1 deletions
@@ -80,6 +80,7 @@ export default function EmulatorPane({ tab, worktreeId, isActive = true }: Emula
deviceName={displayName}
loading={loading}
isLive={isLive}
isActive={isActive}
onTap={(x, y) => void sendTap(x, y)}
onGesture={(points) => void sendGesture(points)}
/>
@@ -0,0 +1,103 @@
// @vitest-environment happy-dom
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'
// 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
// blob churn / H.264 decode) while the pane is inactive, and that a running
// stream is torn down when the pane is hidden — so this asserts the IPC calls,
// not just the rendered DOM.
type FrameListener = (data: { streamId: string; bytes: ArrayBuffer }) => void
type ErrorListener = (data: { streamId: string; message: string }) => void
let container: HTMLDivElement
let root: Root
let startFrameStream: ReturnType<typeof vi.fn>
let stopFrameStream: ReturnType<typeof vi.fn>
let streamCounter: number
beforeEach(() => {
;(
globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean }
).IS_REACT_ACT_ENVIRONMENT = true
streamCounter = 0
startFrameStream = vi.fn(async () => ({ streamId: `stream-${++streamCounter}` }))
stopFrameStream = vi.fn(async () => {})
Object.defineProperty(URL, 'createObjectURL', {
configurable: true,
value: vi.fn(() => 'blob:emulator-frame')
})
Object.defineProperty(URL, 'revokeObjectURL', { configurable: true, value: vi.fn() })
Object.defineProperty(window, 'api', {
configurable: true,
value: {
emulator: {
startFrameStream,
stopFrameStream,
onFrameStreamFrame: (_listener: FrameListener) => () => {},
onFrameStreamError: (_listener: ErrorListener) => () => {}
}
}
})
container = document.createElement('div')
document.body.appendChild(container)
root = createRoot(container)
})
afterEach(() => {
act(() => {
root.unmount()
})
container.remove()
delete (URL as Partial<typeof URL>).createObjectURL
delete (URL as Partial<typeof URL>).revokeObjectURL
delete (window as { api?: unknown }).api
vi.restoreAllMocks()
})
async function renderFrame(isActive: boolean): Promise<void> {
await act(async () => {
root.render(
<EmulatorDeviceFrame
previewUrl="http://127.0.0.1:3100/stream.mjpeg"
wsUrl="ws://127.0.0.1:3100/ws"
loading={false}
isLive={true}
isActive={isActive}
onTap={vi.fn()}
onGesture={vi.fn()}
/>
)
})
}
describe('EmulatorDeviceFrame visibility gating', () => {
it('streams frames while the pane is active', async () => {
await renderFrame(true)
expect(startFrameStream).toHaveBeenCalledWith(
expect.objectContaining({ streamUrl: 'http://127.0.0.1:3100/stream.mjpeg' })
)
})
it('does not start a frame stream while the pane is inactive', async () => {
await renderFrame(false)
expect(startFrameStream).not.toHaveBeenCalled()
})
it('tears the stream down when the pane becomes inactive and resumes when active again', async () => {
await renderFrame(true)
expect(startFrameStream).toHaveBeenCalledTimes(1)
// Parking the pane must stop the stream at the source (no more IPC frames).
await renderFrame(false)
expect(stopFrameStream).toHaveBeenCalledWith({ streamId: 'stream-1' })
// Re-showing re-fires the stream; the session was never detached.
await renderFrame(true)
expect(startFrameStream).toHaveBeenCalledTimes(2)
})
})
@@ -99,6 +99,7 @@ function renderFrame(props?: {
wsUrl="ws://127.0.0.1:3100/ws"
loading={false}
isLive={true}
isActive={true}
onTap={props?.onTap ?? vi.fn()}
onGesture={props?.onGesture ?? vi.fn()}
/>
@@ -39,6 +39,9 @@ type EmulatorDeviceFrameProps = {
deviceName?: string
loading: boolean
isLive: boolean
/** False when the pane is backgrounded (hidden tab/worktree). Gates the frame
* stream so a parked emulator stops decoding, matching the pane's visibility. */
isActive: boolean
onTap: (x: number, y: number) => void
onGesture: (points: EmulatorGesturePoint[]) => void
}
@@ -65,6 +68,7 @@ export function EmulatorDeviceFrame({
deviceName,
loading,
isLive,
isActive,
onTap,
onGesture
}: EmulatorDeviceFrameProps) {
@@ -327,7 +331,13 @@ export function EmulatorDeviceFrame({
setStreamError(true)
}, [])
const showStream = isLive && Boolean(previewUrl)
// Why: only decode frames while the pane is visible. A backgrounded but still
// attached emulator otherwise keeps running the WebCodecs H.264 decode / MJPEG
// blob churn against a hidden canvas, with frames still flowing over IPC (and
// the network for paired SSH/remote clients). The session stays attached, so
// the stream re-fires within a frame on re-show. Mirrors the browser pane's
// park-when-hidden behavior.
const showStream = isActive && isLive && Boolean(previewUrl)
const screenAspectRatio = streamSize ? streamSize.width / streamSize.height : 9 / 19
const screenAspectRatioStyle = streamSize
? `${streamSize.width} / ${streamSize.height}`