From df2ddd9d4bbd7c49d93d55727e22bebcd0554d90 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Sat, 31 Oct 2026 19:02:33 -0700 Subject: [PATCH] perf(mobile): gzip large frames for phones that read them (#36801) Every JSON reply and stream event reached the phone as base64 of sealed UTF-8, a third larger than the JSON itself, and terminal snapshots (up to 512 KB per attach) went out uncompressed. Opening a host on a real desktop moved ~2 MB, about 17 s on a 1 Mbps link. Phones now advertise transport.compressed-frames.v1. For those sockets the desktop gzips text frames of 1 KiB or more, and terminal frames of that size, and seals them as binary frames tagged with a new kind byte, the payload kind and the inflated length. The phone inflates them (native gunzip when available) back into the same text or binary frame before routing. Screencast frames are never compressed, and the relaying desktop never offers the capability to paired servers, whose frames it rewrites. On the same desktop, opening a host now moves ~180 KB. Desktop CPU is unchanged: gzip then seal of the 1.5 MB catalog takes the same time as seal then base64 of the plain one. Setting ORCA_DISABLE_MOBILE_COMPRESSED_FRAMES=1 on a desktop turns compression off without a release. Older phones never advertise the capability and older desktops ignore it, so both keep plain frames. --- .../src/transport/compressed-frame-inflate.ts | 31 +++++++++++ .../mobile-e2ee-v2-physical-channel.test.ts | 28 ++++++++++ .../mobile-e2ee-v2-physical-channel.ts | 8 ++- .../mobile-runtime-client-capabilities.ts | 5 +- .../relayed-phone-frames.test.ts | 24 ++++++++ .../relayed-phone-frames.ts | 11 +++- src/main/runtime/rpc/e2ee-channel.ts | 19 +++---- .../terminal-multiplex-frame-delivery.ts | 7 ++- .../terminal/terminal-subscribe-binary.ts | 4 +- .../rpc/mobile-e2ee-desktop-outbound-owner.ts | 6 +- .../rpc/mobile-e2ee-outbound-admission.ts | 12 ++++ .../mobile-e2ee-v2-compressed-frame.test.ts | 52 ++++++++++++++++++ .../rpc/mobile-e2ee-v2-compressed-frame.ts | 42 ++++++++++++++ src/main/runtime/rpc/rpc-binary-sender.ts | 2 + src/shared/paired-e2ee-compressed-frame.ts | 55 +++++++++++++++++++ 15 files changed, 287 insertions(+), 19 deletions(-) create mode 100644 mobile/src/transport/compressed-frame-inflate.ts create mode 100644 src/main/runtime/rpc/mobile-e2ee-v2-compressed-frame.test.ts create mode 100644 src/main/runtime/rpc/mobile-e2ee-v2-compressed-frame.ts create mode 100644 src/shared/paired-e2ee-compressed-frame.ts diff --git a/mobile/src/transport/compressed-frame-inflate.ts b/mobile/src/transport/compressed-frame-inflate.ts new file mode 100644 index 00000000000..2287f82e7f7 --- /dev/null +++ b/mobile/src/transport/compressed-frame-inflate.ts @@ -0,0 +1,31 @@ +import { gunzipSync } from 'fflate' +import { readCompressedFrame } from '../../../src/shared/paired-e2ee-compressed-frame' +import { REMOTE_RUNTIME_MAX_OUTBOUND_JSON_BYTES } from '../../../src/shared/remote-runtime/remote-runtime-memory-limits' +import { pairedE2EENativeCodec } from './paired-e2ee-native-codec' + +export type InflatedFrame = { kind: 'text'; text: string } | { kind: 'binary'; bytes: Uint8Array } + +/** What a compressed frame carries, or null for any other binary frame; throws on a corrupt one. */ +export function inflateCompressedFrame(frame: Uint8Array): InflatedFrame | null { + const compressed = readCompressedFrame(frame) + if (!compressed) { + return null + } + const { payloadKind, inflatedByteLength, gzipped } = compressed + // Why: the desktop never sends a frame above its outbound cap, so a larger claim is a corrupt frame. + if (inflatedByteLength > REMOTE_RUNTIME_MAX_OUTBOUND_JSON_BYTES) { + throw new Error(`Compressed frame claims ${inflatedByteLength} bytes`) + } + const native = pairedE2EENativeCodec() + // One byte past the claim lets a member that inflates larger fail the length check, not overrun. + const bytes = native + ? native.gunzip(gzipped, inflatedByteLength + 1) + : gunzipSync(gzipped, { out: new Uint8Array(inflatedByteLength + 1) }) + if (bytes.byteLength !== inflatedByteLength) { + throw new Error('Compressed frame inflated to an unexpected length') + } + if (payloadKind === 'binary') { + return { kind: 'binary', bytes } + } + return { kind: 'text', text: native?.decodeUtf8(bytes) ?? new TextDecoder().decode(bytes) } +} diff --git a/mobile/src/transport/mobile-e2ee-v2-physical-channel.test.ts b/mobile/src/transport/mobile-e2ee-v2-physical-channel.test.ts index 007c0a98424..4437ae5099f 100644 --- a/mobile/src/transport/mobile-e2ee-v2-physical-channel.test.ts +++ b/mobile/src/transport/mobile-e2ee-v2-physical-channel.test.ts @@ -2,6 +2,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import nacl from 'tweetnacl' import { setFlagsFromString } from 'node:v8' import { runInNewContext } from 'node:vm' +import { gzipSync } from 'node:zlib' import { encodeMobileE2EEV2Transcript, validateMobileE2EEV2Handshake, @@ -11,6 +12,7 @@ import { openMobileE2EEV2Frame, sealMobileE2EEV2Frame } from '../../../src/shared/mobile-e2ee-v2-framing' +import { encodeCompressedFrame } from '../../../src/shared/paired-e2ee-compressed-frame' vi.mock('expo-crypto', () => ({ getRandomBytes: (length: number) => new Uint8Array(length).fill(9) @@ -205,6 +207,32 @@ describe('mobile E2EE v2 physical channel', () => { expect(ctx.onError).not.toHaveBeenCalled() }) + it('delivers compressed frames as what they carry, in counter order with plain frames', async () => { + const ctx = setup(async (raw) => (raw instanceof Uint8Array ? raw : null)) + await authenticate(ctx) + ctx.events.length = 0 + + const reply = JSON.stringify({ id: 'rpc-1', ok: true, result: { note: 'é'.repeat(4096) } }) + const utf8 = Buffer.from(reply, 'utf8') + const text = encodeCompressedFrame({ + payloadKind: 'text', + inflatedByteLength: utf8.byteLength, + gzipped: gzipSync(utf8) + }) + const terminal = new Uint8Array(2048).fill(0x74) + const binary = encodeCompressedFrame({ + payloadKind: 'binary', + inflatedByteLength: terminal.byteLength, + gzipped: gzipSync(terminal) + }) + await ctx.channel.handleMessage(serverFrame(text, 'binary', 1n, ctx.schedule)) + await ctx.channel.handleMessage(serverFrame(binary, 'binary', 2n, ctx.schedule)) + await ctx.channel.handleMessage(serverFrame(new Uint8Array([7]), 'binary', 3n, ctx.schedule)) + + expect(ctx.events).toEqual([`text:${reply}`, 'binary:116', 'binary:7']) + expect(ctx.onError).not.toHaveBeenCalled() + }) + it('queues outbound text and binary in one counter order', async () => { const ctx = setup(async () => null) await authenticate(ctx) diff --git a/mobile/src/transport/mobile-e2ee-v2-physical-channel.ts b/mobile/src/transport/mobile-e2ee-v2-physical-channel.ts index 0371cfe78a0..f9ef16def7c 100644 --- a/mobile/src/transport/mobile-e2ee-v2-physical-channel.ts +++ b/mobile/src/transport/mobile-e2ee-v2-physical-channel.ts @@ -8,6 +8,7 @@ import { } from '../../../src/shared/paired-e2ee-client-handshake' import type { MobileE2EEV2ClientSession } from './mobile-e2ee-v2-client-session' import { WsInboundMessageQueue } from '../../../src/shared/ws-inbound-message-queue' +import { inflateCompressedFrame } from './compressed-frame-inflate' type ChannelState = 'awaiting-ready' | 'awaiting-authenticated' | 'ready' type OutboundItem = { kind: 'text'; plaintext: string } | { kind: 'binary'; plaintext: Uint8Array } @@ -118,7 +119,12 @@ export class MobileE2EEV2PhysicalChannel { } else if (typeof plaintext === 'string') { this.args.onText(plaintext) } else { - this.args.onBinary(plaintext) + const inflated = inflateCompressedFrame(plaintext) + if (inflated?.kind === 'text') { + this.args.onText(inflated.text) + } else { + this.args.onBinary(inflated?.bytes ?? plaintext) + } } } diff --git a/mobile/src/transport/mobile-runtime-client-capabilities.ts b/mobile/src/transport/mobile-runtime-client-capabilities.ts index 5bb7195022f..bda1b1a9566 100644 --- a/mobile/src/transport/mobile-runtime-client-capabilities.ts +++ b/mobile/src/transport/mobile-runtime-client-capabilities.ts @@ -23,6 +23,7 @@ import { import { AGENT_SESSION_OPTIONAL_MODEL_CLIENT_CAPABILITY } from '../../../src/shared/agent-session-optional-model-capability' import { remoteRuntimeClientCapabilities } from '../../../src/shared/remote-runtime/remote-runtime-client-capabilities' import { AGENT_SESSION_CREATE_MESSAGE_RUNTIME_CAPABILITY } from '../../../src/shared/agent-session-create-capabilities' +import { COMPRESSED_FRAMES_CLIENT_CAPABILITY } from '../../../src/shared/paired-e2ee-compressed-frame' export const MOBILE_RUNTIME_CLIENT_CAPABILITIES = remoteRuntimeClientCapabilities([ AGENT_SESSION_COMMANDS_ON_REQUEST_CAPABILITY, @@ -50,7 +51,9 @@ export const MOBILE_RUNTIME_CLIENT_CAPABILITIES = remoteRuntimeClientCapabilitie // Native chat offers no Stop for a turn the host restored but could not confirm. AGENT_STATUS_RESTORED_UNCONFIRMED_RUNTIME_CAPABILITY, AGENT_SESSION_CREATE_MESSAGE_RUNTIME_CAPABILITY, - AGENT_SESSION_QUEUED_IMAGE_CARDS_CLIENT_CAPABILITY + AGENT_SESSION_QUEUED_IMAGE_CARDS_CLIENT_CAPABILITY, + // Every socket this list is sent on reads frames through MobileE2EEV2PhysicalChannel, which inflates them. + COMPRESSED_FRAMES_CLIENT_CAPABILITY ]) export const MOBILE_RUNTIME_CLIENT_CAPABILITY_UPDATE_METHOD = diff --git a/src/main/runtime/mobile-desktop-relay/relayed-phone-frames.test.ts b/src/main/runtime/mobile-desktop-relay/relayed-phone-frames.test.ts index 82d5ab11626..1c0c9b5ae98 100644 --- a/src/main/runtime/mobile-desktop-relay/relayed-phone-frames.test.ts +++ b/src/main/runtime/mobile-desktop-relay/relayed-phone-frames.test.ts @@ -13,8 +13,12 @@ import { BrowserScreencastOpcode, encodeBrowserScreencastFrame } from '../../../shared/browser-screencast-protocol' +import { COMPRESSED_FRAMES_CLIENT_CAPABILITY } from '../../../shared/paired-e2ee-compressed-frame' +import { BROWSER_CLIENT_HOST_RUNTIME_CAPABILITY } from '../../../shared/protocol-version' import { + declareRelayedPhoneCapabilities, RelayedPhoneReplies, + relayedPhoneCapabilities, rewriteRelayedPhoneRequest, withRelayedLaunchChatDefault } from './relayed-phone-frames' @@ -57,6 +61,26 @@ describe('rewriteRelayedPhoneRequest', () => { }) }) +describe('relayed phone capabilities', () => { + // The relaying desktop rewrites stream ids and reply bookkeeping, which compressed frames would hide. + it('never offers a paired server compressed frames, at auth or on update', () => { + const phone = ['agent-session.turn-item.v1', COMPRESSED_FRAMES_CLIENT_CAPABILITY] + const update = JSON.stringify({ + id: 'r1', + method: 'runtime.clientCapabilities.update', + params: { clientCapabilities: phone } + }) + + expect(relayedPhoneCapabilities(phone)).toEqual([ + 'agent-session.turn-item.v1', + BROWSER_CLIENT_HOST_RUNTIME_CAPABILITY + ]) + expect(JSON.parse(declareRelayedPhoneCapabilities(update)).params.clientCapabilities).toEqual( + relayedPhoneCapabilities(phone) + ) + }) +}) + describe('RelayedPhoneReplies', () => { function replies(firstDesktopId = 100) { let next = firstDesktopId diff --git a/src/main/runtime/mobile-desktop-relay/relayed-phone-frames.ts b/src/main/runtime/mobile-desktop-relay/relayed-phone-frames.ts index f1405988321..0b4e801bfa8 100644 --- a/src/main/runtime/mobile-desktop-relay/relayed-phone-frames.ts +++ b/src/main/runtime/mobile-desktop-relay/relayed-phone-frames.ts @@ -1,4 +1,5 @@ import { BROWSER_CLIENT_HOST_RUNTIME_CAPABILITY } from '../../../shared/protocol-version' +import { COMPRESSED_FRAMES_CLIENT_CAPABILITY } from '../../../shared/paired-e2ee-compressed-frame' import { readTerminalStreamFrameId, writeTerminalStreamFrameId @@ -25,10 +26,14 @@ export function rewriteRelayedPhoneRequest( } // Servers expose client-placed tabs only with this capability; mobile scope still cannot host them. +// Withhold compressed frames: they would hide relayed replies and stream ids from the rewriting below. export function relayedPhoneCapabilities(capabilities: readonly string[]): readonly string[] { - return capabilities.includes(BROWSER_CLIENT_HOST_RUNTIME_CAPABILITY) - ? capabilities - : [...capabilities, BROWSER_CLIENT_HOST_RUNTIME_CAPABILITY] + const forwarded = capabilities.filter( + (capability) => capability !== COMPRESSED_FRAMES_CLIENT_CAPABILITY + ) + return forwarded.includes(BROWSER_CLIENT_HOST_RUNTIME_CAPABILITY) + ? forwarded + : [...forwarded, BROWSER_CLIENT_HOST_RUNTIME_CAPABILITY] } export function declareRelayedPhoneCapabilities(frame: string): string { diff --git a/src/main/runtime/rpc/e2ee-channel.ts b/src/main/runtime/rpc/e2ee-channel.ts index dc1ca50373e..f5a90222ca1 100644 --- a/src/main/runtime/rpc/e2ee-channel.ts +++ b/src/main/runtime/rpc/e2ee-channel.ts @@ -13,7 +13,11 @@ import { describeMobileE2EEAuthFailure, type DeviceKeyResolution } from './mobile-e2ee-auth-validation' -import { isMobileE2EEOutboundItemWithinLimit } from './mobile-e2ee-outbound-admission' +import { + isMobileE2EEOutboundItemWithinLimit, + trackMobileE2EEOutboundBudgetClose, + type MobileE2EEOutboundBudgetEmitter +} from './mobile-e2ee-outbound-admission' import { parseRemoteRuntimeJsonText } from '../../../shared/remote-runtime/requests/remote-runtime-request-frames' import type { MobileE2EEOutboundMemoryBudget } from './mobile-e2ee-outbound-memory-budget' import { MobileE2EEDesktopOutboundOwner } from './mobile-e2ee-desktop-outbound-owner' @@ -24,12 +28,8 @@ import type { RpcBinarySendResult, RpcBinarySender } from './rpc-binary-sender' -import type { EventProps } from '../../../shared/telemetry-events' -import { track } from '../../telemetry/client' import type { DevicePrincipal } from '../device-principal' -type OutboundBudgetEmitter = EventProps<'remote_outbound_budget_close'>['emitter'] - const HANDSHAKE_TIMEOUT_MS = 10_000 const MAX_CONSECUTIVE_DECRYPT_FAILURES = 5 @@ -98,6 +98,7 @@ export class E2EEChannel { ws, () => Boolean(this.sharedKey), () => this.closeForOutboundBudget('queue'), + this.capabilitySource, options.outboundMemoryBudget ) @@ -310,12 +311,8 @@ export class E2EEChannel { } // Both budget closes kill the remote session: size identifies an oversized producer, queue a backed-up link. - private closeForOutboundBudget(emitter: OutboundBudgetEmitter): void { - try { - track('remote_outbound_budget_close', { emitter }) - } catch { - // Telemetry is best-effort; closing the unsafe socket remains authoritative. - } + private closeForOutboundBudget(emitter: MobileE2EEOutboundBudgetEmitter): void { + trackMobileE2EEOutboundBudgetClose(emitter) this.onError(1013, 'Outbound reply buffer overflow') } diff --git a/src/main/runtime/rpc/methods/terminal/terminal-multiplex-frame-delivery.ts b/src/main/runtime/rpc/methods/terminal/terminal-multiplex-frame-delivery.ts index 2b095b6716e..02fae5a70ec 100644 --- a/src/main/runtime/rpc/methods/terminal/terminal-multiplex-frame-delivery.ts +++ b/src/main/runtime/rpc/methods/terminal/terminal-multiplex-frame-delivery.ts @@ -37,7 +37,12 @@ export function installMultiplexFrameDelivery(state: TerminalMultiplexConnection typeof seq === 'number' ? seq : opcode === TerminalStreamOpcode.Output ? 0 : state.cursor++ let sent: RpcBinarySendResult try { - sent = sendBinary(encodeTerminalStreamFrame({ opcode, streamId, seq: resolvedSeq, payload })) + sent = sendBinary( + encodeTerminalStreamFrame({ opcode, streamId, seq: resolvedSeq, payload }), + { + compressible: true + } + ) } catch { state.closeMultiplex() return false diff --git a/src/main/runtime/rpc/methods/terminal/terminal-subscribe-binary.ts b/src/main/runtime/rpc/methods/terminal/terminal-subscribe-binary.ts index 8cf6dfeb654..d621183f964 100644 --- a/src/main/runtime/rpc/methods/terminal/terminal-subscribe-binary.ts +++ b/src/main/runtime/rpc/methods/terminal/terminal-subscribe-binary.ts @@ -83,7 +83,9 @@ export async function runTerminalBinarySubscription(args: TerminalSubscriptionAr if (closed || !sendBinary) { return } - sendBinary(encodeTerminalStreamFrame({ opcode, streamId, seq: frameSeq, payload })) + sendBinary(encodeTerminalStreamFrame({ opcode, streamId, seq: frameSeq, payload }), { + compressible: true + }) } outputBatcher = createTerminalOutputBatcher((data, meta) => { if (meta?.cwd !== undefined) { diff --git a/src/main/runtime/rpc/mobile-e2ee-desktop-outbound-owner.ts b/src/main/runtime/rpc/mobile-e2ee-desktop-outbound-owner.ts index bfe85f68cae..a24491381c3 100644 --- a/src/main/runtime/rpc/mobile-e2ee-desktop-outbound-owner.ts +++ b/src/main/runtime/rpc/mobile-e2ee-desktop-outbound-owner.ts @@ -14,6 +14,8 @@ import { type MobileE2EEOutboundSocketMemory } from './mobile-e2ee-outbound-memory-budget' import type { RpcBinarySendOptions } from './rpc-binary-sender' +import type { RuntimeClientCapabilities } from './runtime-client-capabilities' +import { compressMobileE2EEV2Item } from './mobile-e2ee-v2-compressed-frame' export class MobileE2EEDesktopOutboundOwner { private readonly memoryBudget: MobileE2EEOutboundMemoryBudget @@ -26,6 +28,7 @@ export class MobileE2EEDesktopOutboundOwner { private readonly ws: WebSocket, private readonly isLegacyKeyed: () => boolean, private readonly onOverflow: () => void, + private readonly clientCapabilities: RuntimeClientCapabilities, memoryBudget: MobileE2EEOutboundMemoryBudget = createMobileE2EEOutboundMemoryBudget() ) { this.memoryBudget = memoryBudget @@ -62,10 +65,11 @@ export class MobileE2EEDesktopOutboundOwner { } enqueueV2( - item: DesktopMobileE2EEV2OutboundItem, + plain: DesktopMobileE2EEV2OutboundItem, session: DesktopMobileE2EEV2Session, options?: RpcBinarySendOptions ): boolean | 'backlogged' { + const item = compressMobileE2EEV2Item(plain, this.clientCapabilities.current, options) if (!this.socketMemory) { this.onOverflow() return false diff --git a/src/main/runtime/rpc/mobile-e2ee-outbound-admission.ts b/src/main/runtime/rpc/mobile-e2ee-outbound-admission.ts index 8dc68369eaf..9c6cdba7c6b 100644 --- a/src/main/runtime/rpc/mobile-e2ee-outbound-admission.ts +++ b/src/main/runtime/rpc/mobile-e2ee-outbound-admission.ts @@ -11,6 +11,18 @@ import type { MobileE2EEOutboundMemoryBudget, MobileE2EEOutboundSocketMemory } from './mobile-e2ee-outbound-memory-budget' +import type { EventProps } from '../../../shared/telemetry-events' +import { track } from '../../telemetry/client' + +export type MobileE2EEOutboundBudgetEmitter = EventProps<'remote_outbound_budget_close'>['emitter'] + +export function trackMobileE2EEOutboundBudgetClose(emitter: MobileE2EEOutboundBudgetEmitter): void { + try { + track('remote_outbound_budget_close', { emitter }) + } catch { + // Telemetry is best-effort; closing the unsafe socket remains authoritative. + } +} export function mobileE2EETextPayloadAdmissionBytes(value: string): number { const bytes = Buffer.byteLength(value, 'utf8') diff --git a/src/main/runtime/rpc/mobile-e2ee-v2-compressed-frame.test.ts b/src/main/runtime/rpc/mobile-e2ee-v2-compressed-frame.test.ts new file mode 100644 index 00000000000..8fb0f261fc8 --- /dev/null +++ b/src/main/runtime/rpc/mobile-e2ee-v2-compressed-frame.test.ts @@ -0,0 +1,52 @@ +import { gunzipSync } from 'node:zlib' +import { describe, expect, it, vi } from 'vitest' +import { + COMPRESSED_FRAMES_CLIENT_CAPABILITY, + readCompressedFrame +} from '../../../shared/paired-e2ee-compressed-frame' +import { compressMobileE2EEV2Item } from './mobile-e2ee-v2-compressed-frame' + +const capable = [COMPRESSED_FRAMES_CLIENT_CAPABILITY] + +function inflate(item: ReturnType) { + const frame = item.kind === 'binary' ? readCompressedFrame(item.plaintext) : null + return frame && { payloadKind: frame.payloadKind, bytes: gunzipSync(frame.gzipped) } +} + +describe('compressMobileE2EEV2Item', () => { + it('sends large text to a capable phone as a compressed frame that inflates to the same text', () => { + const plaintext = JSON.stringify({ rows: Array.from({ length: 200 }, (_, i) => `ü-row-${i}`) }) + + const inflated = inflate(compressMobileE2EEV2Item({ kind: 'text', plaintext }, capable)) + + expect(inflated?.payloadKind).toBe('text') + expect(inflated?.bytes.toString('utf8')).toBe(plaintext) + }) + + it('compresses binary only when its sender marks it compressible', () => { + const terminal = { kind: 'binary' as const, plaintext: new Uint8Array(4096).fill(0x74) } + + expect(compressMobileE2EEV2Item(terminal, capable)).toBe(terminal) + const inflated = inflate(compressMobileE2EEV2Item(terminal, capable, { compressible: true })) + expect(inflated?.payloadKind).toBe('binary') + expect(new Uint8Array(inflated!.bytes)).toEqual(terminal.plaintext) + }) + + it('leaves frames alone for phones without the capability and for small frames', () => { + const large = { kind: 'text' as const, plaintext: 'x'.repeat(4096) } + const small = { kind: 'text' as const, plaintext: '{"id":"1"}' } + + expect(compressMobileE2EEV2Item(large, [])).toBe(large) + expect(compressMobileE2EEV2Item(small, capable)).toBe(small) + }) + + it('sends plain frames to every phone while the rollback switch is set', () => { + const large = { kind: 'text' as const, plaintext: 'x'.repeat(4096) } + vi.stubEnv('ORCA_DISABLE_MOBILE_COMPRESSED_FRAMES', '1') + try { + expect(compressMobileE2EEV2Item(large, capable)).toBe(large) + } finally { + vi.unstubAllEnvs() + } + }) +}) diff --git a/src/main/runtime/rpc/mobile-e2ee-v2-compressed-frame.ts b/src/main/runtime/rpc/mobile-e2ee-v2-compressed-frame.ts new file mode 100644 index 00000000000..22b334968f0 --- /dev/null +++ b/src/main/runtime/rpc/mobile-e2ee-v2-compressed-frame.ts @@ -0,0 +1,42 @@ +import { gzipSync } from 'node:zlib' +import { + COMPRESSED_FRAMES_CLIENT_CAPABILITY, + COMPRESSED_FRAME_MIN_BYTES, + encodeCompressedFrame +} from '../../../shared/paired-e2ee-compressed-frame' +import type { RuntimeCapability } from '../../../shared/protocol-version' +import type { DesktopMobileE2EEV2OutboundItem } from './mobile-e2ee-v2-desktop-outbound' +import type { RpcBinarySendOptions } from './rpc-binary-sender' + +/** Gzips large text, and binary its sender marked compressible, for phones that read compressed frames. */ +export function compressMobileE2EEV2Item( + item: DesktopMobileE2EEV2OutboundItem, + clientCapabilities: readonly RuntimeCapability[], + options?: RpcBinarySendOptions +): DesktopMobileE2EEV2OutboundItem { + // Why: screencast JPEGs never shrink, so binary is gzipped only when its sender knows it is text-like. + const eligible = item.kind === 'text' || options?.compressible === true + if ( + !eligible || + // Field rollback without a release: phones then read plain frames, as from older hosts. + process.env.ORCA_DISABLE_MOBILE_COMPRESSED_FRAMES === '1' || + item.plaintext.length < COMPRESSED_FRAME_MIN_BYTES || + !clientCapabilities.includes(COMPRESSED_FRAMES_CLIENT_CAPABILITY) + ) { + return item + } + const payload = item.kind === 'text' ? Buffer.from(item.plaintext, 'utf8') : item.plaintext + const gzipped = gzipSync(payload) + // Why: already-dense payloads can grow under gzip; the plain frame then costs less. + if (gzipped.byteLength >= payload.byteLength) { + return item + } + return { + kind: 'binary', + plaintext: encodeCompressedFrame({ + payloadKind: item.kind, + inflatedByteLength: payload.byteLength, + gzipped + }) + } +} diff --git a/src/main/runtime/rpc/rpc-binary-sender.ts b/src/main/runtime/rpc/rpc-binary-sender.ts index 2a56ac5f28a..3f6e36c50aa 100644 --- a/src/main/runtime/rpc/rpc-binary-sender.ts +++ b/src/main/runtime/rpc/rpc-binary-sender.ts @@ -1,6 +1,8 @@ export type RpcBinarySendOptions = { // Why: a lossy stream resends its newest frame later, so queueing stale frames behind a backlog only delays it. dropWhenBacklogged?: boolean + // Text-like payloads (terminal output, snapshots) a phone that reads compressed frames receives gzipped. + compressible?: boolean } /** False: not sent or queued. 'backlogged': skipped under dropWhenBacklogged; the socket is alive. */ diff --git a/src/shared/paired-e2ee-compressed-frame.ts b/src/shared/paired-e2ee-compressed-frame.ts new file mode 100644 index 00000000000..cfb0e84e872 --- /dev/null +++ b/src/shared/paired-e2ee-compressed-frame.ts @@ -0,0 +1,55 @@ +/** + * A text or binary frame the desktop gzipped and sealed as a binary frame: a 6-byte header (kind, + * inflated payload kind, little-endian inflated byte length) and one gzip member. Binary carries it + * without base64's third, and the kind byte keeps it apart from terminal (0x74) and screencast frames. + */ +export const COMPRESSED_FRAMES_CLIENT_CAPABILITY = 'transport.compressed-frames.v1' as const + +const COMPRESSED_FRAME_KIND = 0x7a +const HEADER_BYTES = 6 +const PAYLOAD_KIND_BYTES = { text: 1, binary: 2 } as const + +export type CompressedFramePayloadKind = keyof typeof PAYLOAD_KIND_BYTES + +// Why: below this a gzip member's fixed overhead and the inflate call outweigh the saved bytes. +export const COMPRESSED_FRAME_MIN_BYTES = 1024 + +export function encodeCompressedFrame(args: { + payloadKind: CompressedFramePayloadKind + inflatedByteLength: number + gzipped: Uint8Array +}): Uint8Array { + const frame = new Uint8Array(HEADER_BYTES + args.gzipped.byteLength) + const view = new DataView(frame.buffer) + view.setUint8(0, COMPRESSED_FRAME_KIND) + view.setUint8(1, PAYLOAD_KIND_BYTES[args.payloadKind]) + view.setUint32(2, args.inflatedByteLength, true) + frame.set(args.gzipped, HEADER_BYTES) + return frame +} + +/** Null for any other binary frame, so callers pass it through unchanged. */ +export function readCompressedFrame(frame: Uint8Array): { + payloadKind: CompressedFramePayloadKind + inflatedByteLength: number + gzipped: Uint8Array +} | null { + if (frame.byteLength < HEADER_BYTES || frame[0] !== COMPRESSED_FRAME_KIND) { + return null + } + const payloadKind = + frame[1] === PAYLOAD_KIND_BYTES.text + ? 'text' + : frame[1] === PAYLOAD_KIND_BYTES.binary + ? 'binary' + : null + if (!payloadKind) { + return null + } + const view = new DataView(frame.buffer, frame.byteOffset, frame.byteLength) + return { + payloadKind, + inflatedByteLength: view.getUint32(2, true), + gzipped: frame.subarray(HEADER_BYTES) + } +}