perf(mobile): keep four bundle chunk reads in flight across the whole manifest

The fetch ran one worker per asset and paged inside an asset sequentially, so
the largest script's 71 chunks were 71 serial round trips while the other
readers idled. One window of four chunk reads now covers every (asset, offset)
on the host's chunk grid, largest asset first. A read_limited refusal narrows
the window and retries the read; eof is still read from the reply.

Synthetic manifest (one 71-chunk asset, five small): 72 round trips -> 19.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
This commit is contained in:
Jinwoo-H
2026-09-22 22:16:50 -04:00
parent 1b85be67d8
commit a1ee317368
3 changed files with 441 additions and 84 deletions
@@ -0,0 +1,257 @@
import { sha256 } from '@noble/hashes/sha256'
import { describe, expect, it, vi } from 'vitest'
import { computeMobileWebBundleId } from '../../../src/shared/mobile-web-bundle/manifest-contract'
import { fetchMobileWebBundle } from './mobile-web-bundle-fetch'
import { MobileWebBundleFetchError } from './mobile-web-bundle-fetch-refusal'
import { readMobileWebBundleErrorCode } from './mobile-web-bundle-operations'
import type { RpcClient } from './rpc-client'
import type { RpcResponse } from './types'
const CHUNK_BYTES = 4
function toHex(bytes: Uint8Array): string {
return Array.from(bytes, (byte) => byte.toString(16).padStart(2, '0')).join('')
}
function bytesOf(text: string): Uint8Array {
return new TextEncoder().encode(text)
}
type ChunkRequest = { path: string; offset: number }
type Reply = { error: string } | { result: unknown }
type WaveHostOptions = {
/** Host-side read slots left for this client; the host refuses a read that arrives over it. */
readSlots?: number
/** Replaces the reply for one request. */
intercept?: (request: ChunkRequest) => Reply | undefined
}
/**
* A host whose chunk replies wait until the test releases them, a whole wave at a time. A wave is
* one round trip: every read in flight when it is released answers together, so the number of
* waves is the number of round trips on the critical path, with no clock involved.
*/
function waveHost(files: Record<string, string>, options: WaveHostOptions = {}) {
const contents = new Map(Object.entries(files).map(([path, text]) => [path, bytesOf(text)]))
const assets = [...contents.entries()]
.map(([path, content]) => ({
path,
sha256: toHex(sha256(content)),
byteLength: content.byteLength,
contentType: 'text/plain'
}))
.sort((left, right) => (left.path < right.path ? -1 : 1))
const buildId = computeMobileWebBundleId(assets)
const manifest = {
schemaVersion: 1,
buildId,
desktopVersion: '1.4.200',
minCompatibleRuntimeProtocolVersion: 2,
runtimeProtocolVersion: 2,
entrypoint: assets[0]!.path,
totalBytes: assets.reduce((total, entry) => total + entry.byteLength, 0),
assets
}
const requests: ChunkRequest[] = []
const peaks: number[] = []
let refusals = 0
let inFlight = 0
let waiting: (() => void)[] = []
const answer = (request: ChunkRequest): Reply => {
const content = contents.get(request.path)!
const slice = content.subarray(request.offset, request.offset + CHUNK_BYTES)
return {
result: {
buildId,
path: request.path,
offset: request.offset,
assetByteLength: content.byteLength,
sha256: toHex(sha256(content)),
dataBase64: btoa(String.fromCharCode(...slice)),
eof: request.offset + slice.byteLength >= content.byteLength
}
}
}
const respond = (reply: Reply): RpcResponse =>
'error' in reply
? {
id: 'rpc-1',
ok: false,
error: { code: 'invalid_argument', message: reply.error },
_meta: { runtimeId: 'runtime-1' }
}
: { id: 'rpc-1', ok: true, result: reply.result, _meta: { runtimeId: 'runtime-1' } }
const client: RpcClient = {
sendRequest: vi.fn(async (method: string, params?: unknown): Promise<RpcResponse> => {
if (method === 'mobileWeb.bundle.manifest') {
return respond({ result: { manifest, chunkBytes: CHUNK_BYTES } })
}
const boxed: Record<string, unknown> = Object(params)
const request = { path: String(boxed.path), offset: Number(boxed.offset) }
requests.push(request)
inFlight += 1
peaks.push(inFlight)
const refused = inFlight > (options.readSlots ?? 4)
if (refused) {
refusals += 1
}
await new Promise<void>((resolve) => waiting.push(resolve))
inFlight -= 1
if (refused) {
return respond({ error: 'mobile_web_bundle_read_limited' })
}
return respond(options.intercept?.(request) ?? answer(request))
}),
subscribe: vi.fn(() => () => {}),
updateTerminalSubscriptionViewport: vi.fn(),
getState: () => 'connected',
getReconnectAttempt: () => 0,
getLastConnectedAt: () => 1,
onStateChange: () => () => {},
notifyForeground: vi.fn(),
close: vi.fn()
}
const settleMicrotasks = () => new Promise((resolve) => setTimeout(resolve, 0))
/** Releases wave after wave until `done` settles; returns how many waves it took. */
const runWaves = async (done: Promise<unknown>): Promise<number> => {
let settled = false
done.then(
() => (settled = true),
() => (settled = true)
)
let waves = 0
await settleMicrotasks()
while (!settled && waiting.length > 0) {
const wave = waiting
waiting = []
waves += 1
wave.forEach((release) => release())
await settleMicrotasks()
}
return waves
}
const refusalCount = () => refusals
return { client, manifest, requests, peaks, refusalCount, runWaves }
}
/** One 71-chunk asset, the shape of the real bundle's largest script, behind five small ones. */
function syntheticBundle(): Record<string, string> {
const big = Array.from({ length: 71 * CHUNK_BYTES - 1 }, (_, index) =>
String.fromCharCode(97 + (index % 26))
).join('')
return {
'a.js': 'a1',
'b.js': 'bb2',
'c.js': 'ccc3',
'd.js': 'd',
'e.js': 'eee',
'z-big.js': big
}
}
describe('fetchMobileWebBundle chunk pipeline', () => {
it('keeps four chunk reads in flight across the whole bundle, largest asset first', async () => {
const files = syntheticBundle()
const host = waveHost(files)
const fetched = fetchMobileWebBundle({ client: host.client })
const waves = await host.runWaves(fetched)
const result = await fetched
// 71 + 5 chunks at four per round trip is 19; one asset per reader was 1 + 71 = 72.
expect(waves).toBe(19)
expect(host.requests).toHaveLength(76)
expect(host.requests.slice(0, 4)).toEqual([
{ path: 'z-big.js', offset: 0 },
{ path: 'z-big.js', offset: 4 },
{ path: 'z-big.js', offset: 8 },
{ path: 'z-big.js', offset: 12 }
])
expect(Math.max(...host.peaks)).toBe(4)
for (const [path, text] of Object.entries(files)) {
expect(new TextDecoder().decode(result.assets.get(path))).toBe(text)
}
})
it('narrows the window on a read-limited refusal and still reassembles every byte', async () => {
const files = syntheticBundle()
const host = waveHost(files, { readSlots: 3 })
const fetched = fetchMobileWebBundle({ client: host.client })
await host.runWaves(fetched)
const result = await fetched
// One refusal narrows the window for the rest of the fetch, so no later wave is refused.
expect(host.refusalCount()).toBe(1)
expect(host.requests).toHaveLength(77)
expect(Math.max(...host.peaks.slice(4))).toBe(3)
for (const [path, text] of Object.entries(files)) {
expect(new TextDecoder().decode(result.assets.get(path))).toBe(text)
}
})
it('fails with the host code when even one read is refused', async () => {
const host = waveHost({ 'index.html': 'abcdefgh' }, { readSlots: 0 })
const fetched = fetchMobileWebBundle({ client: host.client })
await host.runWaves(fetched)
const error = await fetched.catch((thrown: unknown) => thrown)
expect(readMobileWebBundleErrorCode(error)).toBe('mobile_web_bundle_read_limited')
})
it('stops every other read once one chunk fails', async () => {
const host = waveHost(syntheticBundle(), {
intercept: (request) =>
request.path === 'z-big.js' && request.offset === 4
? { error: 'mobile_web_bundle_asset_unknown' }
: undefined
})
const fetched = fetchMobileWebBundle({ client: host.client })
await host.runWaves(fetched)
const error = await fetched.catch((thrown: unknown) => thrown)
expect(readMobileWebBundleErrorCode(error)).toBe('mobile_web_bundle_asset_unknown')
// The first wave's four, plus at most the one its first sibling reply dispatched before the
// failing reply settled.
expect(host.requests.length).toBeLessThanOrEqual(5)
})
it('refuses a chunk short of its slot that does not end the asset', async () => {
// Offsets are planned, not chained, so a short middle chunk would leave a hole.
const host = waveHost(
{ 'index.html': 'abcdefghij' },
{
intercept: (request) =>
request.offset === 0
? {
result: {
buildId: host.manifest.buildId,
path: 'index.html',
offset: 0,
assetByteLength: 10,
sha256: host.manifest.assets[0]!.sha256,
dataBase64: btoa('ab'),
eof: false
}
}
: undefined
}
)
const fetched = fetchMobileWebBundle({ client: host.client })
await host.runWaves(fetched)
const error = await fetched.catch((thrown: unknown) => thrown)
expect(error).toBeInstanceOf(MobileWebBundleFetchError)
expect(error instanceof MobileWebBundleFetchError ? error.refusal : null).toBe('asset-short')
})
})
@@ -161,7 +161,7 @@ describe('fetchMobileWebBundle', () => {
expect(host.calls[0]!.params).toEqual({})
})
it('asks for each chunk at the offset the previous reply ended on', async () => {
it('asks for each chunk at the next step of the advertised chunk size', async () => {
const host = bundleHost({ 'index.html': 'abcdefghij' }, { chunkBytes: 3 })
await fetchMobileWebBundle({ client: host.client })
@@ -431,7 +431,7 @@ describe('fetchMobileWebBundle', () => {
expect(await refusalOf(failed)).toBe('asset-no-progress')
})
it('stops the other workers mid-asset once one asset is refused', async () => {
it('stops the other reads mid-asset once one chunk is refused', async () => {
const host = bundleHost(
{
'a.js': 'x',
@@ -442,7 +442,9 @@ describe('fetchMobileWebBundle', () => {
{
chunkBytes: 1,
intercept: (call) =>
call.method === 'mobileWeb.bundle.chunk' && paramField(call.params, 'path') === 'a.js'
call.method === 'mobileWeb.bundle.chunk' &&
paramField(call.params, 'path') === 'b.js' &&
paramField(call.params, 'offset') === 1
? new Error('mobile_web_bundle_asset_unknown')
: undefined
}
@@ -456,8 +458,8 @@ describe('fetchMobileWebBundle', () => {
// The refusal is what the caller sees; the internal stop never surfaces.
expect(readMobileWebBundleErrorCode(error)).toBe('mobile_web_bundle_asset_unknown')
// 120 chunks would page the other three assets to the end. One more round of four is the most
// the abandoned workers can add, because each checks the stop before it asks for a chunk.
// 120 chunks would page all three large assets to the end. One more round of four is the most
// the window can add, because it checks the stop before it asks for a chunk.
expect(chunkCallCount(host.calls)).toBeLessThanOrEqual(atRejection + 4)
expect(chunkCallCount(host.calls)).toBeLessThan(10)
})
+177 -79
View File
@@ -1,7 +1,8 @@
import { sha256 } from '@noble/hashes/sha256'
import {
mobileWebBundleChunkRead,
mobileWebBundleManifestRead
mobileWebBundleManifestRead,
readMobileWebBundleErrorCode
} from './mobile-web-bundle-operations'
import type {
MobileWebBundleAssetRead,
@@ -12,8 +13,8 @@ import { MobileWebBundleFetchError } from './mobile-web-bundle-fetch-refusal'
import { runRpcOperation } from './rpc-operation'
/** The host refuses the fifth concurrent read on one connection with `mobile_web_bundle_read_limited`,
* so the client never offers a fifth. Paging inside one asset stays sequential: the next offset is
* only known to be wanted once the previous reply says it is not the last. */
* so the client never offers a fifth. The four are chunk reads across the whole manifest, not one
* asset each: paging a large asset alone would put every one of its chunks on the critical path. */
const MAX_CONCURRENT_ASSET_READS = 4
export type MobileWebBundleFetchProgress = {
@@ -30,6 +31,15 @@ export type MobileWebBundleFetchResult = {
readonly elapsedMs: number
}
type AssetReassembly = {
readonly entry: MobileWebBundleAssetRead
whole: Uint8Array | null
receivedBytes: number
outstandingChunks: number
}
type ChunkRead = { readonly asset: AssetReassembly; readonly offset: number }
/**
* Reads the manifest, pages every asset, and returns the verified bytes.
*
@@ -47,101 +57,189 @@ export async function fetchMobileWebBundle(args: {
throwIfStopped(args.signal, stopped.signal)
const opened = await runRpcOperation(args.client, mobileWebBundleManifestRead, null)
const manifest = opened.manifest
const pending = [...manifest.assets]
const assets = new Map<string, Uint8Array>()
let receivedBytes = 0
const worker = async (): Promise<void> => {
try {
for (let asset = pending.shift(); asset !== undefined; asset = pending.shift()) {
const bytes = await readBundleAsset({
client: args.client,
asset,
buildId: manifest.buildId,
chunkBytes: opened.chunkBytes,
signal: args.signal,
stopped: stopped.signal
})
assets.set(asset.path, bytes)
receivedBytes += bytes.byteLength
args.onProgress?.({
completedAssets: assets.size,
totalAssets: manifest.assets.length,
receivedBytes,
totalBytes: manifest.totalBytes
})
}
} catch (error) {
// One failed asset stops the other three mid-asset, not just between assets: every chunk they
// would still ask for holds one of the host's four read slots against the caller's retry.
stopped.abort()
throw error
const readChunk = async (read: ChunkRead): Promise<void> => {
const chunk = await runRpcOperation(args.client, mobileWebBundleChunkRead, {
buildId: manifest.buildId,
path: read.asset.entry.path,
offset: read.offset
})
// A sibling already failed the fetch; this reply is not worth checking or hashing.
if (stopped.signal.aborted || read.asset.whole === null) {
return
}
assertChunkDescribesAsset(chunk, read.asset.entry, manifest.buildId, read.offset)
const bytes = decodeBase64(chunk.dataBase64)
assertChunkFillsItsSlot(read, bytes.byteLength, chunk.eof, opened.chunkBytes)
const { whole } = read.asset
const { offset } = read
whole.set(bytes, offset)
read.asset.receivedBytes += bytes.byteLength
read.asset.outstandingChunks -= 1
if (read.asset.outstandingChunks > 0) {
return
}
assets.set(read.asset.entry.path, verifyReassembledAsset(read.asset, read.asset.whole))
receivedBytes += read.asset.whole.byteLength
args.onProgress?.({
completedAssets: assets.size,
totalAssets: manifest.assets.length,
receivedBytes,
totalBytes: manifest.totalBytes
})
}
const workers = Math.min(MAX_CONCURRENT_ASSET_READS, pending.length)
await Promise.all(Array.from({ length: workers }, () => worker()))
await runChunkWindow({
reads: planChunkReads(manifest.assets, opened.chunkBytes),
readChunk,
signal: args.signal,
stopped
})
return { manifest, assets, totalBytes: receivedBytes, elapsedMs: Date.now() - startedAt }
}
async function readBundleAsset(args: {
client: RpcClient
asset: MobileWebBundleAssetRead
buildId: string
/** Largest asset first, so the biggest script's tail is never the last read left in flight. Offsets
* are the host's chunk grid, so every read is known up front; `eof` still comes from the reply. */
function planChunkReads(
entries: readonly MobileWebBundleAssetRead[],
chunkBytes: number
): ChunkRead[] {
const largestFirst = [...entries].sort((left, right) => right.byteLength - left.byteLength)
return largestFirst.flatMap((entry) => {
const count = Math.max(1, Math.ceil(entry.byteLength / chunkBytes))
const asset: AssetReassembly = {
entry,
whole: null,
receivedBytes: 0,
outstandingChunks: count
}
return Array.from({ length: count }, (_, index) => ({ asset, offset: index * chunkBytes }))
})
}
/**
* Keeps up to four chunk reads in flight over one queue. A `read_limited` refusal means something
* else holds one of the host's slots: the window narrows once per refusal at the current width and
* the read is retried; a refusal of a read sent alone is the host's verdict and fails the fetch.
*/
function runChunkWindow(args: {
reads: ChunkRead[]
readChunk: (read: ChunkRead) => Promise<void>
signal?: AbortSignal
stopped: AbortSignal
}): Promise<Uint8Array> {
// Before the buffer, not after: an asset can be a tenth of the total ceiling, and a worker that
// picked one up after a sibling failed would otherwise allocate it only to drop it.
throwIfStopped(args.signal, args.stopped)
const whole = new Uint8Array(args.asset.byteLength)
let offset = 0
for (;;) {
throwIfStopped(args.signal, args.stopped)
const chunk = await runRpcOperation(args.client, mobileWebBundleChunkRead, {
buildId: args.buildId,
path: args.asset.path,
offset
})
assertChunkDescribesAsset(chunk, args.asset, args.buildId, offset)
const bytes = decodeBase64(chunk.dataBase64)
if (bytes.byteLength > args.chunkBytes) {
throw new MobileWebBundleFetchError(
'chunk-oversize',
`bundle chunk for ${args.asset.path} at ${offset} is ${bytes.byteLength} bytes, over the host's ${args.chunkBytes}`
)
stopped: AbortController
}): Promise<void> {
const queue = args.reads
let width = MAX_CONCURRENT_ASSET_READS
let inFlight = 0
return new Promise((resolve, reject) => {
// One failed chunk stops every other read, not just the next: each read it would still send
// holds one of the host's four slots against the caller's retry.
const fail = (error: unknown): void => {
if (!args.stopped.signal.aborted) {
args.stopped.abort()
reject(error)
}
}
if (offset + bytes.byteLength > whole.byteLength) {
throw new MobileWebBundleFetchError(
'asset-overlong',
`bundle asset ${args.asset.path} is longer than the manifest declares`
)
}
whole.set(bytes, offset)
offset += bytes.byteLength
if (chunk.eof) {
break
}
// Without this a host that keeps answering an unchanged offset with no bytes pages forever.
if (bytes.byteLength === 0) {
throw new MobileWebBundleFetchError(
'asset-no-progress',
`bundle asset ${args.asset.path} made no progress at ${offset}`
)
const pump = (): void => {
if (args.stopped.signal.aborted) {
return
}
if (queue.length === 0 && inFlight === 0) {
resolve()
return
}
while (inFlight < width && queue.length > 0) {
try {
throwIfStopped(args.signal, args.stopped.signal)
} catch (error) {
fail(error)
return
}
const read = queue.shift()!
// Allocated at the asset's first read, so a fetch that stops early never holds the rest.
read.asset.whole ??= new Uint8Array(read.asset.entry.byteLength)
const sentAtWidth = width
inFlight += 1
args.readChunk(read).then(
() => {
inFlight -= 1
pump()
},
(error: unknown) => {
inFlight -= 1
if (
sentAtWidth > 1 &&
readMobileWebBundleErrorCode(error) === 'mobile_web_bundle_read_limited'
) {
width = sentAtWidth === width ? width - 1 : width
queue.unshift(read)
pump()
return
}
fail(error)
}
)
}
}
pump()
})
}
/** Offsets are planned, so a chunk that falls short without ending the asset would leave a hole. */
function assertChunkFillsItsSlot(
read: ChunkRead,
byteLength: number,
eof: boolean,
chunkBytes: number
): void {
const { path, byteLength: declared } = read.asset.entry
const end = read.offset + byteLength
if (byteLength > chunkBytes) {
throw new MobileWebBundleFetchError(
'chunk-oversize',
`bundle chunk for ${path} at ${read.offset} is ${byteLength} bytes, over the host's ${chunkBytes}`
)
}
if (offset !== whole.byteLength) {
if (end > declared || (!eof && byteLength > 0 && end >= declared)) {
throw new MobileWebBundleFetchError(
'asset-overlong',
`bundle asset ${path} is longer than the manifest declares`
)
}
if (eof && end < declared) {
throw new MobileWebBundleFetchError(
'asset-short',
`bundle asset ${args.asset.path} ended at ${offset} of ${whole.byteLength} declared bytes`
`bundle asset ${path} ended at ${end} of ${declared} declared bytes`
)
}
if (!eof && byteLength === 0) {
throw new MobileWebBundleFetchError(
'asset-no-progress',
`bundle asset ${path} made no progress at ${read.offset}`
)
}
if (!eof && byteLength < chunkBytes) {
throw new MobileWebBundleFetchError(
'asset-short',
`bundle chunk for ${path} at ${read.offset} carried ${byteLength} of ${chunkBytes} bytes without ending the asset`
)
}
}
function verifyReassembledAsset(asset: AssetReassembly, whole: Uint8Array): Uint8Array {
if (asset.receivedBytes !== whole.byteLength) {
throw new MobileWebBundleFetchError(
'asset-short',
`bundle asset ${asset.entry.path} ended at ${asset.receivedBytes} of ${whole.byteLength} declared bytes`
)
}
const digest = toHex(sha256(whole))
if (digest !== args.asset.sha256) {
if (digest !== asset.entry.sha256) {
throw new MobileWebBundleFetchError(
'asset-checksum-mismatch',
`bundle asset ${args.asset.path} hashed ${digest}, not ${args.asset.sha256}`
`bundle asset ${asset.entry.path} hashed ${digest}, not ${asset.entry.sha256}`
)
}
return whole
@@ -186,7 +284,7 @@ function assertChunkDescribesAsset(
}
/** The caller's abort is what it asked for; the internal one never leaves this module, because the
* asset that failed rejects first and is what `Promise.all` reports. */
* read that failed rejects the window before any sibling can. */
function throwIfStopped(caller: AbortSignal | undefined, stopped: AbortSignal): void {
if (caller?.aborted === true) {
throw new MobileWebBundleFetchError('fetch-stopped', 'mobile web bundle fetch aborted')
@@ -194,7 +292,7 @@ function throwIfStopped(caller: AbortSignal | undefined, stopped: AbortSignal):
if (stopped.aborted) {
throw new MobileWebBundleFetchError(
'fetch-stopped',
'mobile web bundle fetch stopped after an earlier asset failed'
'mobile web bundle fetch stopped after an earlier chunk failed'
)
}
}