mirror of
https://github.com/stablyai/orca.git
synced 2026-10-26 08:02:44 +00:00
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:
@@ -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}`
|
||||
|
||||
Reference in New Issue
Block a user