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
This commit is contained in:
Jinwoo-H
2026-09-22 23:33:18 -04:00
parent 6e7782f741
commit 0aed3fdbff
9 changed files with 53 additions and 88 deletions
@@ -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<unknown> => Promise.reject(new Error('no manifest staged')),
fetch: (_args: unknown): Promise<unknown> => Promise.reject(new Error('no fetch staged'))
fetch: (): Promise<unknown> => 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' }])
})
})
@@ -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 })
})
@@ -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,
@@ -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,
@@ -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 },
@@ -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)
})
})
@@ -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<string, unknown> = {}) {
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')
})
})
@@ -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'
}
@@ -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