mirror of
https://github.com/stablyai/orca.git
synced 2026-09-28 08:02:43 +00:00
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:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user