From 0aed3fdbff0b2837092f5360a1f78013e9e79e2e Mon Sep 17 00:00:00 2001 From: Jinwoo-H Date: Tue, 22 Sep 2026 23:33:18 -0400 Subject: [PATCH] refactor(mobile): drop the range capability read and its session threading The phone will read rangeBytes off the loose manifest reply instead, so the read-method module goes and the session effects and hook return to the pipeline branch's version. Range imports move to the bundle RPC contract, the reply reader reuses the shared SHA256_PATTERN, and a new test pins that node's level-6 gzip from the host encoder inflates with fflate to the same bytes. The fetch and window-read modules still import the deleted names until part 2. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb --- .../mobile-web-shell-session-effects.test.ts | 30 ++------------- .../mobile-web-shell-session-effects.ts | 4 -- .../use-mobile-web-shell-session.ts | 2 - .../transport/mobile-web-bundle-operations.ts | 6 +-- .../mobile-web-bundle-range-decode.ts | 3 +- .../mobile-web-bundle-range-interop.test.ts | 38 +++++++++++++++++++ .../mobile-web-bundle-range-reply.test.ts | 29 +------------- .../mobile-web-bundle-read-method.ts | 16 -------- .../mobile-web-bundle-reply-schemas.ts | 13 +++---- 9 files changed, 53 insertions(+), 88 deletions(-) create mode 100644 mobile/src/transport/mobile-web-bundle-range-interop.test.ts delete mode 100644 mobile/src/transport/mobile-web-bundle-read-method.ts diff --git a/mobile/src/mobile-web-shell/mobile-web-shell-session-effects.test.ts b/mobile/src/mobile-web-shell/mobile-web-shell-session-effects.test.ts index f73710a9c21..24f3e9a01b8 100644 --- a/mobile/src/mobile-web-shell/mobile-web-shell-session-effects.test.ts +++ b/mobile/src/mobile-web-shell/mobile-web-shell-session-effects.test.ts @@ -4,7 +4,7 @@ import type { MobileWebShellSessionEvent } from './mobile-web-shell-session-cont /** Each place a read of the host's generation can fail, and the cause it reports. */ const doubles = vi.hoisted(() => ({ manifest: (): Promise => Promise.reject(new Error('no manifest staged')), - fetch: (_args: unknown): Promise => Promise.reject(new Error('no fetch staged')) + fetch: (): Promise => Promise.reject(new Error('no fetch staged')) })) vi.mock('expo-file-system', () => ({ Directory: class {}, File: class {}, Paths: { cache: '' } })) @@ -13,13 +13,9 @@ vi.mock('../transport/rpc-operation', () => ({ runRpcOperation: () => doubles.manifest() })) vi.mock('../transport/mobile-web-bundle-fetch', () => ({ - fetchMobileWebBundle: (args: unknown) => doubles.fetch(args) + fetchMobileWebBundle: () => doubles.fetch() })) -import { - MOBILE_WEB_BUNDLE_CAPABILITY, - MOBILE_WEB_BUNDLE_RANGE_CAPABILITY -} from '../../../src/shared/mobile-web-bundle/mobile-web-bundle-capability' import { MobileWebBundleFetchError } from '../transport/mobile-web-bundle-fetch-refusal' import { markRpcDeliveryUnknown } from '../transport/rpc-delivery-ambiguity' import type { RpcClient } from '../transport/rpc-client' @@ -81,15 +77,10 @@ function collect(): { return { events, send: (event) => events.push(event) } } -async function runDownload( - client: RpcClient | null, - store: GenerationStore, - hostCapabilities: readonly string[] = [MOBILE_WEB_BUNDLE_CAPABILITY] -) { +async function runDownload(client: RpcClient | null, store: GenerationStore) { const { events, send } = collect() await download({ client, - hostCapabilities, store, hostKey: 'k', flow: 3, @@ -177,19 +168,4 @@ describe('the download', () => { it('that lands whole reports no failure', async () => { expect(await runDownload(CLIENT, storeThat(null))).toEqual([]) }) - - it('pages a host that advertised ranges in ranges, and any other in chunks', async () => { - const seen: unknown[] = [] - const landed = doubles.fetch - doubles.fetch = (args) => { - seen.push(args) - return landed(args) - } - await runDownload(CLIENT, storeThat(null), [ - MOBILE_WEB_BUNDLE_CAPABILITY, - MOBILE_WEB_BUNDLE_RANGE_CAPABILITY - ]) - await runDownload(CLIENT, storeThat(null)) - expect(seen).toMatchObject([{ readMethod: 'range' }, { readMethod: 'chunk' }]) - }) }) diff --git a/mobile/src/mobile-web-shell/mobile-web-shell-session-effects.ts b/mobile/src/mobile-web-shell/mobile-web-shell-session-effects.ts index fd24ed622d6..989f56bb99b 100644 --- a/mobile/src/mobile-web-shell/mobile-web-shell-session-effects.ts +++ b/mobile/src/mobile-web-shell/mobile-web-shell-session-effects.ts @@ -1,6 +1,5 @@ import { fetchMobileWebBundle } from '../transport/mobile-web-bundle-fetch' import { mobileWebBundleManifestRead } from '../transport/mobile-web-bundle-operations' -import { mobileWebBundleReadMethodFor } from '../transport/mobile-web-bundle-read-method' import { runRpcOperation } from '../transport/rpc-operation' import type { RpcClient } from '../transport/rpc-client' import type { GenerationStore } from './generation-store' @@ -92,8 +91,6 @@ export async function readManifest( export async function download(args: { client: RpcClient | null - /** The capabilities the gates proved for this connection, which name the read method. */ - hostCapabilities: readonly string[] store: GenerationStore hostKey: string flow: number @@ -114,7 +111,6 @@ export async function download(args: { try { const fetched = await fetchMobileWebBundle({ client, - readMethod: mobileWebBundleReadMethodFor(args.hostCapabilities), signal: controller.signal, onProgress: (progress) => send({ type: 'fetch-progress', flow, ...progress }) }) diff --git a/mobile/src/mobile-web-shell/use-mobile-web-shell-session.ts b/mobile/src/mobile-web-shell/use-mobile-web-shell-session.ts index ae73d6be420..f4e89065782 100644 --- a/mobile/src/mobile-web-shell/use-mobile-web-shell-session.ts +++ b/mobile/src/mobile-web-shell/use-mobile-web-shell-session.ts @@ -168,8 +168,6 @@ export function useMobileWebShellSession(args: { case 'download': await download({ client, - // The gates the reducer decided to download under, not a later render's. - hostCapabilities: sessionRef.current.gates?.hostCapabilities ?? [], store, hostKey, flow, diff --git a/mobile/src/transport/mobile-web-bundle-operations.ts b/mobile/src/transport/mobile-web-bundle-operations.ts index e11f6447214..d15610534fd 100644 --- a/mobile/src/transport/mobile-web-bundle-operations.ts +++ b/mobile/src/transport/mobile-web-bundle-operations.ts @@ -2,9 +2,9 @@ import { MobileWebBundleErrorCodeSchema, MOBILE_WEB_BUNDLE_CHUNK_METHOD, MOBILE_WEB_BUNDLE_MANIFEST_METHOD, + MOBILE_WEB_BUNDLE_RANGE_METHOD, type MobileWebBundleErrorCode } from '../../../src/shared/mobile-web-bundle/bundle-rpc-contract' -import { MOBILE_WEB_BUNDLE_RANGE_METHOD } from '../../../src/shared/mobile-web-bundle/bundle-range-rpc-contract' import { MobileWebBundleChunkReplySchema, MobileWebBundleManifestReplySchema, @@ -39,8 +39,8 @@ export const mobileWebBundleChunkRead = defineRpcOperation({ read: rpcResultVariant('mobile-web-bundle-chunk', MobileWebBundleChunkReplySchema) }) -/** Up to 384 KiB of one asset, gzipped when that shrinks it. Only sent to a host that advertised - * `mobileWeb.bundle.range.v1`; see `mobile-web-bundle-read-method.ts`. */ +/** One 384 KiB window of an asset, gzipped when that shrinks it. Only sent to a host whose manifest + * reply named `rangeBytes`. */ export const mobileWebBundleRangeRead = defineRpcOperation({ name: 'mobileWeb.bundle-range', method: MOBILE_WEB_BUNDLE_RANGE_METHOD, diff --git a/mobile/src/transport/mobile-web-bundle-range-decode.ts b/mobile/src/transport/mobile-web-bundle-range-decode.ts index 3f785ee027e..0f1deb3b312 100644 --- a/mobile/src/transport/mobile-web-bundle-range-decode.ts +++ b/mobile/src/transport/mobile-web-bundle-range-decode.ts @@ -5,7 +5,8 @@ import { MobileWebBundleFetchError } from './mobile-web-bundle-fetch-refusal' * The raw bytes of one range, from the `dataBase64` bytes the host sent under `encoding`. * * Inflated into a buffer one byte past the window: fflate fills a supplied `out` and never grows it, - * so a gzip bomb costs at most that allocation, and a body that fills the spare byte is overlong. + * so a gzip bomb costs at most that much memory (not time: inflating still runs to the body's end), + * and a body that fills the spare byte is overlong. */ export function decodeMobileWebBundleRange( range: { readonly path: string; readonly offset: number; readonly encoding: string }, diff --git a/mobile/src/transport/mobile-web-bundle-range-interop.test.ts b/mobile/src/transport/mobile-web-bundle-range-interop.test.ts new file mode 100644 index 00000000000..97ce5780b94 --- /dev/null +++ b/mobile/src/transport/mobile-web-bundle-range-interop.test.ts @@ -0,0 +1,38 @@ +import { randomBytes } from 'node:crypto' +import { gunzipSync } from 'fflate' +import { describe, expect, it } from 'vitest' +import { encodeMobileWebBundleRange } from '../../../src/main/runtime/rpc/methods/mobile-web-bundle-range-encoding' +import { MOBILE_WEB_BUNDLE_RANGE_BYTES } from '../../../src/shared/mobile-web-bundle/bundle-rpc-contract' + +/** Script-like and a full range long, so the host's level-6 deflate emits dynamic Huffman blocks. */ +function scriptRange(): Buffer { + const words = ['const ', 'function ', 'return ', 'export ', '=> ', '{', '}', ';\n', 'orca'] + let text = '' + for (let index = 0; text.length < MOBILE_WEB_BUNDLE_RANGE_BYTES; index += 1) { + text += words[(index * 7) % words.length] + String(index % 1000) + } + return Buffer.from(text.slice(0, MOBILE_WEB_BUNDLE_RANGE_BYTES)) +} + +// The host deflates with node's zlib and the phone inflates with fflate: two implementations that +// must agree on every range the host can send. +describe('a host-encoded range decoded by the phone', () => { + it('inflates node gzip output with fflate to the same bytes', async () => { + const raw = scriptRange() + const encoded = await encodeMobileWebBundleRange(raw) + + expect(encoded.encoding).toBe('gzip') + const inflated = gunzipSync(new Uint8Array(encoded.bytes), { + out: new Uint8Array(raw.byteLength + 1) + }) + expect(Buffer.from(inflated).equals(raw)).toBe(true) + }) + + it('leaves incompressible bytes as identity, which needs no decoder', async () => { + const raw = randomBytes(4096) + const encoded = await encodeMobileWebBundleRange(raw) + + expect(encoded.encoding).toBe('identity') + expect(encoded.bytes.equals(raw)).toBe(true) + }) +}) diff --git a/mobile/src/transport/mobile-web-bundle-range-reply.test.ts b/mobile/src/transport/mobile-web-bundle-range-reply.test.ts index eb09f22af46..560671cae54 100644 --- a/mobile/src/transport/mobile-web-bundle-range-reply.test.ts +++ b/mobile/src/transport/mobile-web-bundle-range-reply.test.ts @@ -2,14 +2,9 @@ import { describe, expect, it } from 'vitest' import { MOBILE_WEB_BUNDLE_RANGE_MAX_DATA_BASE64_LENGTH, MOBILE_WEB_BUNDLE_RANGE_METHOD -} from '../../../src/shared/mobile-web-bundle/bundle-range-rpc-contract' -import { - MOBILE_WEB_BUNDLE_CAPABILITY, - MOBILE_WEB_BUNDLE_RANGE_CAPABILITY -} from '../../../src/shared/mobile-web-bundle/mobile-web-bundle-capability' +} from '../../../src/shared/mobile-web-bundle/bundle-rpc-contract' import { MOBILE_WEB_BUNDLE_MAX_ASSET_BYTES } from '../../../src/shared/mobile-web-bundle/manifest-contract' import { mobileWebBundleRangeRead } from './mobile-web-bundle-operations' -import { mobileWebBundleReadMethodFor } from './mobile-web-bundle-read-method' function rangeReply(overrides: Record = {}) { return { @@ -72,25 +67,3 @@ describe('mobile web bundle range reply reader', () => { expect(mobileWebBundleRangeRead.barrier).toBe('on-settle') }) }) - -describe('which read method a host is paged with', () => { - const NEW_DESKTOP = [ - 'files.pathsExist', - MOBILE_WEB_BUNDLE_CAPABILITY, - MOBILE_WEB_BUNDLE_RANGE_CAPABILITY, - 'terminal.quick-commands.v1' - ] - /** Derived, never written down: the one string removed from what the new desktop sends. */ - const OLD_DESKTOP = NEW_DESKTOP.filter( - (capability) => capability !== MOBILE_WEB_BUNDLE_RANGE_CAPABILITY - ) - - it('pages a new client against a new host in ranges', () => { - expect(mobileWebBundleReadMethodFor(NEW_DESKTOP)).toBe('range') - }) - - it('pages a new client against an old host in chunks', () => { - expect(mobileWebBundleReadMethodFor(OLD_DESKTOP)).toBe('chunk') - expect(mobileWebBundleReadMethodFor([])).toBe('chunk') - }) -}) diff --git a/mobile/src/transport/mobile-web-bundle-read-method.ts b/mobile/src/transport/mobile-web-bundle-read-method.ts deleted file mode 100644 index 59f7d40d3db..00000000000 --- a/mobile/src/transport/mobile-web-bundle-read-method.ts +++ /dev/null @@ -1,16 +0,0 @@ -import { MOBILE_WEB_BUNDLE_RANGE_CAPABILITY } from '../../../src/shared/mobile-web-bundle/mobile-web-bundle-capability' - -/** `range` pages 384 KiB gzipped windows; `chunk` pages the 48 KiB raw ones every bundle host serves. */ -export type MobileWebBundleReadMethod = 'range' | 'chunk' - -/** - * Read off the `status.get` capabilities this connection already proved, never probed: a phone - * calling a method an older desktop never allowlisted is told `forbidden`, not `method_not_found`, - * so a probe would need to read an authorization code as absence. A host without the capability - * keeps being paged in chunks, which is what it has always served. - */ -export function mobileWebBundleReadMethodFor( - hostCapabilities: readonly string[] -): MobileWebBundleReadMethod { - return hostCapabilities.includes(MOBILE_WEB_BUNDLE_RANGE_CAPABILITY) ? 'range' : 'chunk' -} diff --git a/mobile/src/transport/mobile-web-bundle-reply-schemas.ts b/mobile/src/transport/mobile-web-bundle-reply-schemas.ts index c8ef1dc5915..61ce58e6b06 100644 --- a/mobile/src/transport/mobile-web-bundle-reply-schemas.ts +++ b/mobile/src/transport/mobile-web-bundle-reply-schemas.ts @@ -1,6 +1,8 @@ import { z } from 'zod' -import { MOBILE_WEB_BUNDLE_CHUNK_BYTES } from '../../../src/shared/mobile-web-bundle/bundle-rpc-contract' -import { MOBILE_WEB_BUNDLE_RANGE_MAX_DATA_BASE64_LENGTH } from '../../../src/shared/mobile-web-bundle/bundle-range-rpc-contract' +import { + MOBILE_WEB_BUNDLE_CHUNK_BYTES, + MOBILE_WEB_BUNDLE_RANGE_MAX_DATA_BASE64_LENGTH +} from '../../../src/shared/mobile-web-bundle/bundle-rpc-contract' import { computeMobileWebBundleId, MobileWebBundleAssetPathSchema, @@ -8,7 +10,8 @@ import { MOBILE_WEB_BUNDLE_MAX_ASSET_BYTES, MOBILE_WEB_BUNDLE_MAX_ROUTE_GRANTS, MOBILE_WEB_BUNDLE_MAX_ROUTES, - MOBILE_WEB_BUNDLE_MAX_TOTAL_BYTES + MOBILE_WEB_BUNDLE_MAX_TOTAL_BYTES, + SHA256_PATTERN } from '../../../src/shared/mobile-web-bundle/manifest-contract' // Hoisted, never built inside a reader: a schema constructed per parse cost 2275 ns against 156 ns @@ -19,10 +22,6 @@ import { // member would turn a later optional field into a released-client break instead of the Rule 1 // addition `docs/reference/remote-wire-compatibility.md` allows. -/** Lowercase hex digest. The shared contract keeps its copy private, so this is the one place the - * client states the shape it accepts. */ -const SHA256_PATTERN = /^[a-f0-9]{64}$/ - /** Base64 of one chunk, bounded by the same arithmetic as `skill-upload-session-contract.ts`, so a * host that overshoots is refused at the boundary instead of at reassembly. */ const MAX_DATA_BASE64_LENGTH = Math.ceil(MOBILE_WEB_BUNDLE_CHUNK_BYTES / 3) * 4 + 8