From e7cd24ef105b8415f10c0e1290bed40e0bd91e9b Mon Sep 17 00:00:00 2001 From: Jinwoo-H Date: Sun, 20 Sep 2026 05:30:03 -0400 Subject: [PATCH] test(mobile): pin the frame budget against the shell's real frame MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ruling 2's pin. `binaryEventEnvelopeBytes()` sizes the mobile view's device scale from a skeleton it builds itself, and until now its only check was another skeleton of the same shape in the same file: two copies of one assumption agreeing with each other. This measures the real thing. A frame with CDP's nine metadata fields and a real `Page.screencastFrame` timestamp, encoded by C6.1's `encodeBridgeScreencastFrame` and serialized by the real `BridgeHostSubscriptions`, posted through the host harness: 303 bytes besides the image, against a bound of 516. Held above is not enough on its own — 213 bytes of slack is room for the shell to grow the envelope by a field the page never hears about — so the bound is reconstructed exactly instead. Every byte of that slack is a number this frame prints narrower than a double can; adding those back gives 516 on the nose. The budget cases run a generated noise image at the budgeted scale, not a committed fixture: the worst case is the image JPEG compresses least, and a photograph sits a tenth of the way to it. 901,161 px at 0.545 bytes per pixel is 491,132 bytes, which the shell posts at 654,857 of the 655,360-byte cap. One envelope more and the shell drops it, which is ruling 1 read from the budget's side. Red first, two ways. Drop the metadata widening from the bound and three cases fail, the sharpest being the real shell answering the frame the page thought it could send with zero posts. Add a field to the shell's own envelope and the reconstruction fails at 516 against 548, where the existing suite stays green on all 14 — which is the drift this file exists for. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb --- ...ser-screencast-budget-at-the-shell.test.ts | 169 ++++++++++++++++++ .../browser/browser-screencast-request.web.ts | 5 +- 2 files changed, 172 insertions(+), 2 deletions(-) create mode 100644 mobile/src/browser/browser-screencast-budget-at-the-shell.test.ts diff --git a/mobile/src/browser/browser-screencast-budget-at-the-shell.test.ts b/mobile/src/browser/browser-screencast-budget-at-the-shell.test.ts new file mode 100644 index 00000000000..18c561d4de1 --- /dev/null +++ b/mobile/src/browser/browser-screencast-budget-at-the-shell.test.ts @@ -0,0 +1,169 @@ +/** + * The page's frame budget, checked against the frame the shell really posts. + * + * `browser-screencast-request.web.ts` sizes the mobile view from `binaryEventEnvelopeBytes()`, a + * bound it derives from a skeleton it builds itself. Its own suite checks that bound against + * another skeleton of the same shape, which is two copies of one assumption agreeing. This is the + * case that makes it evidence: a real `event.binary` encoded by C6.1's encoder and serialized by + * the real `BridgeHostSubscriptions`, measured, and the bound held above it. + * + * C6 ruling 2's pin. The frames are generated noise at the budgeted scale rather than a fixture + * committed to the tree: the budget's worst case is the image JPEG compresses least, and a + * downloaded photograph would sit a tenth of the way to it and prove nothing. + */ +import { describe, expect, it } from 'vitest' +import { BRIDGE_MAX_MESSAGE_BYTES, utf8ByteLength } from '../mobile-web-shell/bridge/bridge-caps' +import { clientFrame } from '../mobile-web-shell/bridge-host-test-fakes' +import { harness, ID } from '../mobile-web-shell/bridge-host-test-harness' +import { + BrowserScreencastOpcode, + METADATA_KEYS, + type BrowserScreencastFrame +} from '../transport/browser-screencast-protocol' +import { + binaryEventEnvelopeBytes, + mobileBrowserFrameAreaBudget, + WORST_CASE_JPEG_BYTES_PER_PIXEL +} from './browser-screencast-request.web' + +/** What the bound spends per number: seventeen significant digits and the widest fixed notation + * JSON writes, `-0.0000012345678901234567`, and `Number.MAX_SAFE_INTEGER` for the two counters. */ +const WIDEST_JSON_DOUBLE_CHARS = 25 +const LARGEST_INTEGER_CHARS = JSON.stringify(Number.MAX_SAFE_INTEGER).length + +/** + * A frame's metadata as Chromium sends it: all nine fields, and a `timestamp` that is a real + * `Page.screencastFrame` value rather than a toy integer. + * + * The timestamp is the whole difference between a plausible envelope and the real one — epoch + * seconds with microseconds is sixteen characters where `1` is one — so a pin written with a small + * number would measure an envelope no frame ever has. + */ +const CDP_METADATA = { + offsetTop: 0, + pageScaleFactor: 1, + deviceWidth: 390, + deviceHeight: 712, + imageWidth: 780, + imageHeight: 1424, + scrollOffsetX: 0, + scrollOffsetY: 2048.5, + timestamp: 1_758_326_400.123456 +} + +/** Noise, which is what the worst case is: any structure at all is something JPEG would compress. + * Deterministic rather than seeded off the clock, so the measured bytes are the same every run. */ +function noise(byteLength: number): Uint8Array { + const bytes = new Uint8Array(byteLength) + let state = 0x9e37_79b9 + for (let index = 0; index < byteLength; index += 1) { + state = (Math.imul(state, 1_664_525) + 1_013_904_223) >>> 0 + bytes[index] = (state >>> 24) & 0xff + } + return bytes +} + +function screencastFrame(image: Uint8Array): BrowserScreencastFrame { + return { + opcode: BrowserScreencastOpcode.Frame, + seq: FRAME_SEQ, + format: 'jpeg', + metadata: CDP_METADATA, + image + } +} + +/** The screencast counter the frame below carries, and the event seq the shell gives its first + * event, both needed to reconstruct what the bound assumed about them. */ +const FRAME_SEQ = 4_096 +const FIRST_EVENT_SEQ = 1 + +/** The one frame the shell posted for this image, and what it cost besides the image. */ +function postedFrame(image: Uint8Array): { bytes: number; envelope: number; posts: number } { + const bridge = harness({ ready: true }) + bridge.host.receive( + clientFrame({ + type: 'subscribe', + id: ID, + method: 'browser.screencast', + params: { worktree: 'id:w', page: 'p' }, + wantsBinary: true + }) + ) + const before = bridge.posted.length + bridge.client.streams[0]?.emitBinary?.(screencastFrame(image)) + const json = bridge.posted.at(-1) ?? '' + const posts = bridge.posted.length - before + const event = posts === 0 ? null : bridge.last() + const b64 = event !== null && 'binary' in event ? event.binary.b64 : '' + return { bytes: utf8ByteLength(json), envelope: utf8ByteLength(json) - b64.length, posts } +} + +describe('the envelope bound against a real posted frame', () => { + it('holds above what the shell spends on everything but the image', () => { + const { envelope } = postedFrame(noise(64 * 1024)) + + expect(binaryEventEnvelopeBytes()).toBeGreaterThanOrEqual(envelope) + }) + + it('is the same cost whatever the image is, which is what makes it an envelope', () => { + // Base64 is ASCII and JSON escapes none of it, so the image contributes its characters and + // nothing else. This is the premise the shell prices an unencoded frame on. + expect(postedFrame(noise(1_024)).envelope).toBe(postedFrame(noise(256 * 1024)).envelope) + }) + + /** + * The bound, reconstructed from the real frame rather than merely held above it. + * + * Above-it alone is satisfied by 213 bytes of slack, which is room for the shell to grow the + * envelope by a field the page never hears about. The slack is not arbitrary: every byte of it + * is a value this frame prints narrower than a double can. Adding exactly those back is the + * whole difference, so an envelope field the bound does not know about fails here at one byte. + */ + it('is exactly what this frame costs once every number is widened to a double', () => { + const widen = (value: number): number => WIDEST_JSON_DOUBLE_CHARS - JSON.stringify(value).length + const widened = + postedFrame(noise(1_024)).envelope + + (LARGEST_INTEGER_CHARS - JSON.stringify(FIRST_EVENT_SEQ).length) + + (LARGEST_INTEGER_CHARS - JSON.stringify(FRAME_SEQ).length) + + METADATA_KEYS.reduce((total, key) => total + widen(CDP_METADATA[key]), 0) + + expect(binaryEventEnvelopeBytes()).toBe(widened) + }) + + it('covers every metadata field the protocol declares, not the ones this case sends', () => { + // If a tenth field is added, `METADATA_KEYS` grows, the bound grows with it, and the frame + // above keeps fitting. The guard is that this case sends all of them. + expect(Object.keys(CDP_METADATA).sort()).toEqual([...METADATA_KEYS].sort()) + }) +}) + +describe('a frame at exactly the budgeted area', () => { + /** The image the budget says the mobile view's worst case produces, to the byte. */ + const BUDGETED_IMAGE_BYTES = Math.floor( + mobileBrowserFrameAreaBudget() * WORST_CASE_JPEG_BYTES_PER_PIXEL + ) + + it('encodes under the frame cap and the shell posts it', () => { + const { bytes, posts } = postedFrame(noise(BUDGETED_IMAGE_BYTES)) + + expect(posts).toBe(1) + expect(bytes).toBeLessThanOrEqual(BRIDGE_MAX_MESSAGE_BYTES) + }) + + it('is tight: the budget spends nearly the whole cap', () => { + // A budget with room to spare is pixels the pane could have had. Within one base64 group plus + // the slack the envelope bound deliberately carries. + const { bytes } = postedFrame(noise(BUDGETED_IMAGE_BYTES)) + + expect(BRIDGE_MAX_MESSAGE_BYTES - bytes).toBeLessThan(binaryEventEnvelopeBytes()) + }) + + it('is a ceiling: an image past it is dropped by the shell, not sent over the cap', () => { + // C6 ruling 1 from the budget's side. The area is a worst case, so a real frame this size is + // the one the page could not predict, and the shell is what keeps it off the wire. + const { posts } = postedFrame(noise(BUDGETED_IMAGE_BYTES + binaryEventEnvelopeBytes())) + + expect(posts).toBe(0) + }) +}) diff --git a/mobile/src/browser/browser-screencast-request.web.ts b/mobile/src/browser/browser-screencast-request.web.ts index f9e34cd34c4..41b4e285908 100644 --- a/mobile/src/browser/browser-screencast-request.web.ts +++ b/mobile/src/browser/browser-screencast-request.web.ts @@ -62,8 +62,9 @@ const NARROWEST_JSON_DOUBLE_CHARS = 1 * predict: the metadata object is a loose one, so a shell may send keys this list has never heard * of, and web view mode's frame is a letterboxed desktop viewport the page cannot size. * - * Pinning this against C6.1's real encoder belongs to C6.5, once the encoder and this are both on - * main; until then the bound is checked against a serialized envelope of the same shape. + * `browser-screencast-budget-at-the-shell.test.ts` is what makes this evidence rather than an + * assumption checked against a copy of itself: it reconstructs this number, to the byte, from a + * frame the real encoder produced and the real host serialized. */ export function binaryEventEnvelopeBytes(): number { const skeleton = JSON.stringify({