fix(mobile): bound the bundle a manifest can make the client allocate

M1: the loose client reader kept every ceiling A1 declared except the one
that bounds their product. A manifest could pass `totalBytes` 0 alongside
256 assets of 10 MiB each and the fetch would allocate 2560 MiB against a
32 MiB contract. The reader now sums `assets[].byteLength` against
MOBILE_WEB_BUNDLE_MAX_TOTAL_BYTES. A ceiling rather than the host's
sum === totalBytes equality, because this client never trusts `totalBytes`
for anything and bounds what it will actually allocate instead.

L1: a tap dials the host, and nothing bounded that wait. A host whose client
never arrives left the row reading `Connecting…` with its button disabled
for the life of the screen. A deadline through the diagnostics folder's own
`startDiagnosticFetchTimeout` settles it to a failure and drops the
acquisition. Ten seconds, because acquiring a client is local work: the
connect and request timeouts live below this and only apply once one exists.

L2, four survivors now pinned: the eof break against a zero-byte asset end to
end, the offset half of the chunk echo check on its own, the anchor that
keeps `rpc (mobile_web_bundle_unavailable)` from reading as a code, and both
`abandoned` guards against a run the screen moved on from.

Also: the stop check moves above the per-asset buffer, which makes the
worker loop's copy redundant; drops the unreferenced chunk reply type; and
restores the comment pairing in operation-mutations.ts, where the bundle
entry had been inserted between the catalog mutation's comment and its entry.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
This commit is contained in:
Jinwoo-H
2026-09-18 02:00:34 -04:00
parent e1c6ea435d
commit 3252779fa7
8 changed files with 269 additions and 15 deletions
@@ -1,6 +1,6 @@
import { createElement } from 'react'
import { act, create, type ReactTestRenderer } from 'react-test-renderer'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { RpcClient } from '../transport/rpc-client'
import type { MobileWebBundleFetchResult } from '../transport/mobile-web-bundle-fetch'
@@ -117,6 +117,30 @@ function fetchedBundle(): MobileWebBundleFetchResult {
}
}
type Deferred<T> = {
readonly promise: Promise<T>
resolve: (value: T) => void
reject: (error: unknown) => void
}
function deferred<T>(): Deferred<T> {
const box: { resolve: (value: T) => void; reject: (error: unknown) => void } = {
resolve: () => {},
reject: () => {}
}
const promise = new Promise<T>((resolve, reject) => {
box.resolve = resolve
box.reject = reject
})
return { promise, resolve: box.resolve, reject: box.reject }
}
async function settle(): Promise<void> {
await act(async () => {
await Promise.resolve()
})
}
beforeEach(() => {
push.attach.mockReset().mockReturnValue(push.detach)
push.detach.mockReset()
@@ -125,6 +149,10 @@ beforeEach(() => {
fetchMock.mockReset()
})
afterEach(() => {
vi.useRealTimers()
})
describe('useMobileWebBundleProbe', () => {
it('dials no host until the row is tapped', async () => {
const probe = await renderProbe(HOST.id)
@@ -181,6 +209,85 @@ describe('useMobileWebBundleProbe', () => {
expect(probe.state).toEqual({ status: 'failed', detail: 'no paired host to fetch from' })
})
it('gives up on a host whose client never arrives instead of waiting forever', async () => {
vi.useFakeTimers()
// The host is not in the store, so no client is ever acquired for it and `awaitingHost` would
// otherwise stay true with the row's button disabled for the life of the screen.
loadHostsMock.mockResolvedValue([])
const probe = await renderProbe(HOST.id)
await probe.run()
expect(probe.state).toEqual({ status: 'running' })
expect(probe.awaitingHost).toBe(true)
await act(async () => {
await vi.advanceTimersByTimeAsync(9_999)
})
expect(probe.state).toEqual({ status: 'running' })
await act(async () => {
await vi.advanceTimersByTimeAsync(1)
})
expect(probe.state).toEqual({ status: 'failed', detail: 'no client for the host within 10s' })
// The row re-enables its button off `running`, and nothing is left dialling the host.
expect(probe.awaitingHost).toBe(false)
expect(fetchMock).not.toHaveBeenCalled()
})
it('does not clear the deadline for a host that did arrive', async () => {
vi.useFakeTimers()
fetchMock.mockReturnValue(new Promise(() => {}))
const probe = await renderProbe(HOST.id)
await probe.run()
expect(fetchMock).toHaveBeenCalledTimes(1)
await act(async () => {
await vi.advanceTimersByTimeAsync(60_000)
})
// A slow fetch is not a host that never opened: the deadline covers acquiring the client only.
expect(probe.state).toEqual({ status: 'running' })
})
it('ignores a result from a run the screen already moved on from', async () => {
const first = deferred<MobileWebBundleFetchResult>()
const second = deferred<MobileWebBundleFetchResult>()
fetchMock.mockReturnValueOnce(first.promise).mockReturnValueOnce(second.promise)
const probe = await renderProbe(HOST.id)
await probe.run()
await probe.run()
first.resolve({ ...fetchedBundle(), elapsedMs: 999 })
await settle()
expect(probe.state).toEqual({ status: 'running' })
second.resolve(fetchedBundle())
await settle()
expect(probe.state).toMatchObject({ status: 'done', elapsedMs: 12 })
})
it('ignores a failure from a run the screen already moved on from', async () => {
const first = deferred<MobileWebBundleFetchResult>()
const second = deferred<MobileWebBundleFetchResult>()
fetchMock.mockReturnValueOnce(first.promise).mockReturnValueOnce(second.promise)
const probe = await renderProbe(HOST.id)
await probe.run()
await probe.run()
first.reject(new Error('invalid_argument: mobile_web_bundle_unavailable'))
await settle()
expect(probe.state).toEqual({ status: 'running' })
second.resolve(fetchedBundle())
await settle()
expect(probe.state).toMatchObject({ status: 'done', elapsedMs: 12 })
})
it('aborts the run it started when the screen goes away', async () => {
const captured: { signal: AbortSignal | null } = { signal: null }
fetchMock.mockImplementation((args: { signal?: AbortSignal }) => {
@@ -2,6 +2,17 @@ import { useCallback, useEffect, useState } from 'react'
import { useHostClient } from '../transport/client-context'
import { fetchMobileWebBundle } from '../transport/mobile-web-bundle-fetch'
import { readMobileWebBundleErrorCode } from '../transport/mobile-web-bundle-operations'
import { startDiagnosticFetchTimeout } from './diagnostic-fetch-timeout'
/**
* How long a tap waits for the host's client object before it gives up.
*
* Generous, because acquiring one can queue behind another screen's, but bounded, because none of
* that is a network round trip: the connect and request timeouts live below this, inside the fetch,
* and only apply once a client exists. Without a bound here a host that never opens leaves the row
* reading `Connecting…` with its button disabled for the life of the screen.
*/
const HOST_CLIENT_DIAL_DEADLINE_MS = 10_000
export type MobileWebBundleProbeState =
| { status: 'idle' }
@@ -76,6 +87,27 @@ export function useMobileWebBundleProbe(hostId: string | null): {
}
}, [client, request])
useEffect(() => {
if (request === null || client !== null) {
return
}
const deadline = startDiagnosticFetchTimeout(HOST_CLIENT_DIAL_DEADLINE_MS)
const giveUp = () => {
setState({
status: 'failed',
detail: `no client for the host within ${HOST_CLIENT_DIAL_DEADLINE_MS / 1000}s`
})
// Drops the acquisition too, so a host that never opens stops being dialled.
setRequest(null)
}
deadline.signal.addEventListener('abort', giveUp)
return () => {
// Removed first: `dispose` aborts a signal it has not already aborted.
deadline.signal.removeEventListener('abort', giveUp)
deadline.dispose()
}
}, [client, request])
const run = useCallback(() => {
if (hostId === null) {
setState({ status: 'failed', detail: 'no paired host to fetch from' })
@@ -73,10 +73,6 @@ export const OPERATION_MUTATIONS = {
before: 'const snapshot = decodeAccountsSnapshot(accounts.value)',
after: 'const snapshot = decodeAccountsSnapshot(reply)'
},
// Puts the workspace catalog's reply back behind an unchecked reader, so a reply carrying neither
// rows nor an `unchanged` token reaches `admitWorktreeCatalogResponse` as an invalid admission
// instead of being named at the boundary — main's answer, and the one the host screen showed as
// an empty host rather than a failure (STA-3123).
// Writes every chunk of an asset at offset 0, so a multi-chunk asset reassembles as its last
// chunk over a zero-filled buffer. The length still matches the manifest; only the sha256 check
// and the decoded bytes in the projection say the bundle is wrong.
@@ -85,6 +81,10 @@ export const OPERATION_MUTATIONS = {
before: 'whole.set(bytes, offset)',
after: 'whole.set(bytes, 0)'
},
// Puts the workspace catalog's reply back behind an unchecked reader, so a reply carrying neither
// rows nor an `unchanged` token reaches `admitWorktreeCatalogResponse` as an invalid admission
// instead of being named at the boundary — main's answer, and the one the host screen showed as
// an empty host rather than a failure (STA-3123).
'worktree-catalog-unchecked-reader': {
file: 'worktree-catalog-operations.ts',
before: "read: rpcResultVariant('worktree-catalog', worktreeCatalogSchema)",
@@ -241,6 +241,51 @@ describe('fetchMobileWebBundle', () => {
)
})
it('refuses a chunk that answers the right path at the wrong offset', async () => {
// The path half of the echo check is already covered; this is the offset half on its own, so
// a host that re-serves chunk zero cannot have its bytes written at the offset we asked for.
const host = bundleHost(
{ 'index.html': 'abcdef' },
{
chunkBytes: 3,
intercept: (call) =>
call.method === 'mobileWeb.bundle.chunk' && paramField(call.params, 'offset') === 3
? {
buildId: BUILD_ID,
path: 'index.html',
offset: 0,
assetByteLength: 6,
sha256: toHex(sha256(bytesOf('abcdef'))),
dataBase64: encodeBase64(bytesOf('abc')),
eof: false
}
: undefined
}
)
await expect(fetchMobileWebBundle({ client: host.client })).rejects.toThrow(
'bundle chunk answered index.html at 0, not index.html at 3'
)
})
it('reads a zero-byte asset in one chunk and returns it empty', async () => {
// A real bundle carries these. The asset is whole the moment the host says eof, and nothing
// else in the loop can end it: a zero-length reply is otherwise how a host makes no progress.
const host = bundleHost({ 'assets/empty.css': '', 'index.html': 'abc' })
const fetched = await fetchMobileWebBundle({ client: host.client })
expect(fetched.assets.get('assets/empty.css')).toEqual(new Uint8Array(0))
expect(fetched.totalBytes).toBe(3)
expect(
host.calls.filter(
(call) =>
call.method === 'mobileWeb.bundle.chunk' &&
paramField(call.params, 'path') === 'assets/empty.css'
)
).toHaveLength(1)
})
it('never puts a fifth chunk request on one connection', async () => {
const peaks: number[] = []
const host = bundleHost(
@@ -53,7 +53,6 @@ export async function fetchMobileWebBundle(args: {
const worker = async (): Promise<void> => {
try {
for (let asset = pending.shift(); asset !== undefined; asset = pending.shift()) {
throwIfStopped(args.signal, stopped.signal)
const bytes = await readBundleAsset({
client: args.client,
asset,
@@ -92,6 +91,9 @@ async function readBundleAsset(args: {
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 (;;) {
@@ -98,6 +98,57 @@ describe('mobile web bundle manifest reply reader', () => {
}
})
it('bounds what a manifest can make the fetch allocate, however it declares totalBytes', () => {
// The ceilings above bound each asset and the asset count, and `totalBytes` separately. None
// of them bounds the product, which is what the fetch allocates.
const oversized = Array.from({ length: MOBILE_WEB_BUNDLE_MAX_ASSETS }, (_, index) =>
asset({ path: `assets/${index}.js`, byteLength: MOBILE_WEB_BUNDLE_MAX_ASSET_BYTES })
)
expect(readManifest(manifestReply({ totalBytes: 0, assets: oversized })).compatible).toBe(false)
expect(readManifest(manifestReply({ totalBytes: 12, assets: oversized })).compatible).toBe(
false
)
})
it('accepts a bundle that sums to the ceiling and refuses one byte more', () => {
// Four assets, because one quarter of the total ceiling is the largest share that still fits
// under the per-asset ceiling. `lastByteLength` moves only the final one.
const quarter = MOBILE_WEB_BUNDLE_MAX_TOTAL_BYTES / 4
const spread = (lastByteLength: number) =>
Array.from({ length: 4 }, (_, index) =>
asset({ path: `assets/${index}.js`, byteLength: index === 3 ? lastByteLength : quarter })
)
expect(
readManifest(
manifestReply({ totalBytes: MOBILE_WEB_BUNDLE_MAX_TOTAL_BYTES, assets: spread(quarter) })
).compatible
).toBe(true)
expect(
readManifest(
manifestReply({
totalBytes: MOBILE_WEB_BUNDLE_MAX_TOTAL_BYTES,
assets: spread(quarter + 1)
})
).compatible
).toBe(false)
})
it('refuses one asset over the per-asset ceiling and accepts one at it', () => {
expect(
readManifest(
manifestReply({
totalBytes: MOBILE_WEB_BUNDLE_MAX_ASSET_BYTES,
assets: [asset({ byteLength: MOBILE_WEB_BUNDLE_MAX_ASSET_BYTES })]
})
).compatible
).toBe(true)
expect(
readManifest(
manifestReply({ assets: [asset({ byteLength: MOBILE_WEB_BUNDLE_MAX_ASSET_BYTES + 1 })] })
).compatible
).toBe(false)
})
it('refuses a schemaVersion it does not know rather than guessing at the shape', () => {
expect(readManifest(manifestReply({ schemaVersion: 2 })).compatible).toBe(false)
expect(readManifest(manifestReply({ schemaVersion: undefined })).compatible).toBe(false)
@@ -221,6 +272,11 @@ describe('mobile web bundle error codes', () => {
expect(
readMobileWebBundleErrorCode(new Error('RPC mobile_web_bundle_unavailable failed'))
).toBeNull()
// The second position is `<envelope code>: `, exactly. Slicing the leading token's length off
// any message would make this one read as the code that follows the bracket.
expect(
readMobileWebBundleErrorCode(new Error('rpc (mobile_web_bundle_unavailable)'))
).toBeNull()
})
it('reads a dispatcher schema refusal, whose message is prose, as no code at all', () => {
@@ -37,13 +37,25 @@ const assetSchema = z.looseObject({
*
* `schemaVersion` stays a literal because the manifest is closed in both directions: a bump is the
* only change path, and an unrecognised one is an unusable bundle to re-fetch, never a crash. */
const manifestSchema = z.looseObject({
schemaVersion: z.literal(MOBILE_WEB_BUNDLE_SCHEMA_VERSION),
buildId: z.string().regex(SHA256_PATTERN),
entrypoint: MobileWebBundleAssetPathSchema,
totalBytes: z.number().int().nonnegative().max(MOBILE_WEB_BUNDLE_MAX_TOTAL_BYTES),
assets: z.array(assetSchema).min(1).max(MOBILE_WEB_BUNDLE_MAX_ASSETS)
})
const manifestSchema = z
.looseObject({
schemaVersion: z.literal(MOBILE_WEB_BUNDLE_SCHEMA_VERSION),
buildId: z.string().regex(SHA256_PATTERN),
entrypoint: MobileWebBundleAssetPathSchema,
totalBytes: z.number().int().nonnegative().max(MOBILE_WEB_BUNDLE_MAX_TOTAL_BYTES),
assets: z.array(assetSchema).min(1).max(MOBILE_WEB_BUNDLE_MAX_ASSETS)
})
// The allocation bound, and the reason it is the sum rather than `totalBytes`: the fetch
// allocates one buffer per asset from `byteLength` and holds them all, so a manifest declaring
// `totalBytes` 0 alongside 256 assets of 10 MiB each would pass every ceiling above and still
// cost 2560 MiB. The host pins sum === totalBytes; this client never trusts `totalBytes` for
// anything, so it bounds what it will actually allocate instead.
.refine(
(manifest) =>
manifest.assets.reduce((sum, asset) => sum + asset.byteLength, 0) <=
MOBILE_WEB_BUNDLE_MAX_TOTAL_BYTES,
'assets sum to more than the contract total'
)
/** `chunkBytes` is read, never assumed: the host may shrink it without a client release. Capped at
* the constant because a larger value would overshoot `dataBase64` above. */
@@ -66,6 +78,5 @@ export const MobileWebBundleChunkReplySchema = z.looseObject({
})
export type MobileWebBundleManifestReply = z.output<typeof MobileWebBundleManifestReplySchema>
export type MobileWebBundleChunkReply = z.output<typeof MobileWebBundleChunkReplySchema>
export type MobileWebBundleManifestRead = MobileWebBundleManifestReply['manifest']
export type MobileWebBundleAssetRead = MobileWebBundleManifestRead['assets'][number]
+2 -1
View File
@@ -175,7 +175,8 @@ export async function runRpcOperation<
operation: RpcOperation<Method, Acceptance, Variant, Value, 'on-settle'>,
// Shares the deferred sender's tuple so the two cannot disagree about what a params-less
// method may be called with: the catalog types those `void`, and an explicit `null` is the
// frame several shipped senders already put on the wire.
// frame three of them go out with today (`notifications.testPush`,
// `notifications.unregisterPush`, `speech.models.list`), all through the deferred entry point.
...args: RpcSendArguments<Method>
): Promise<RpcVerdict<Acceptance, Value>> {
const outcome = await request(client, operation, args[0], args[1])