diff --git a/config/scripts/generate-rpc-params-catalog.mjs b/config/scripts/generate-rpc-params-catalog.mjs index acf31aae6a1..e42ea39beb1 100644 --- a/config/scripts/generate-rpc-params-catalog.mjs +++ b/config/scripts/generate-rpc-params-catalog.mjs @@ -197,9 +197,10 @@ ${uncataloged.map((name) => ` '${name}'`).join(',\n')} export type RpcMethodName = keyof typeof RPC_PARAMS_BY_METHOD -// Why: z.output is the post-parse shape the handler receives. z.input is not a -// send-side type here — requiredString is z.unknown().transform(...), so its input -// admits any value and loses optional/default semantics. +// Why: z.output is the post-parse shape the handler receives, which is not what a +// client may send — a .default() field reads as required. z.input is not the answer +// either: requiredString is z.unknown().transform(...), so its input admits any value. +// Senders use RpcSendParams from ./rpc-send-params, which is derived from this map. export type RpcParams = (typeof RPC_PARAMS_BY_METHOD)[Method] extends z.ZodType ? z.output<(typeof RPC_PARAMS_BY_METHOD)[Method]> diff --git a/mobile/src/transport/rpc-client.ts b/mobile/src/transport/rpc-client.ts index 10f586d2780..38941483c4d 100644 --- a/mobile/src/transport/rpc-client.ts +++ b/mobile/src/transport/rpc-client.ts @@ -1,19 +1,11 @@ import type { BrowserScreencastFrame } from './browser-screencast-protocol' import { DirectRpcClient } from './direct-rpc-client' -import type { - ConnectionLogSink, - ConnectionState, - ForegroundNudgeReason, - RpcResponse -} from './types' +import type { ConnectionLogSink, ConnectionState, ForegroundNudgeReason } from './types' +import type { UnvalidatedRpcRequestPort } from './unvalidated-rpc-request-port' -export type SendRequestOptions = { - timeoutMs?: number - /** Include the connect wait in the caller's timeout budget. */ - budgetSpansConnect?: boolean - /** Reject instead of replaying the request after reconnect. */ - failWhenDisconnected?: boolean -} +// Re-export shim: the options type moved to the port module with the sender it belongs to, +// and re-exporting is what keeps that move from touching every importer. +export type { SendRequestOptions } from './unvalidated-rpc-request-port' type SubscribeOptions = { onBinaryFrame?: (frame: BrowserScreencastFrame) => void @@ -21,12 +13,9 @@ type SubscribeOptions = { type StreamingListener = (result: unknown) => void -export type RpcClient = { - sendRequest: ( - method: string, - params?: unknown, - options?: SendRequestOptions - ) => Promise +// Still structurally carries the raw sender, so holding a client is still holding the port — +// which is why the boundary is inventoried rather than merely declared. +export type RpcClient = UnvalidatedRpcRequestPort & { subscribe: ( method: string, params: unknown, diff --git a/mobile/src/transport/rpc-incompatible-reply-error.ts b/mobile/src/transport/rpc-incompatible-reply-error.ts new file mode 100644 index 00000000000..026732ca3b5 --- /dev/null +++ b/mobile/src/transport/rpc-incompatible-reply-error.ts @@ -0,0 +1,27 @@ +import type { RpcDecodeIssue } from './rpc-operation-contract' + +const INCOMPATIBLE_REPLY_MESSAGE_PREFIX = 'incompatible_reply: ' + +// Why: a reply the operation's reader cannot read says nothing about what the host did. +// On a mutation it is NOT evidence the mutation failed and authorizes no retry — only a +// host-negotiated idempotency capability inside its dedupe window does (see +// tasks/worktree-create-retry.ts). So this error is deliberately neither marked +// delivery-unknown nor shaped like the cutover error the retry loops replay on. +export class RpcIncompatibleReplyError extends Error { + constructor( + readonly operationName: string, + readonly method: string, + readonly issues: readonly RpcDecodeIssue[] + ) { + super(`${INCOMPATIBLE_REPLY_MESSAGE_PREFIX}${operationName} (${method})`) + } +} + +// Why: instanceof can miss across bundle copies, so also match by message, mirroring +// isLogicalClientCutoverError. +export function isRpcIncompatibleReplyError(error: unknown): boolean { + return ( + error instanceof RpcIncompatibleReplyError || + (error instanceof Error && error.message.startsWith(INCOMPATIBLE_REPLY_MESSAGE_PREFIX)) + ) +} diff --git a/mobile/src/transport/rpc-operation-barrier.test.ts b/mobile/src/transport/rpc-operation-barrier.test.ts new file mode 100644 index 00000000000..43c74198b44 --- /dev/null +++ b/mobile/src/transport/rpc-operation-barrier.test.ts @@ -0,0 +1,138 @@ +import { describe, expect, it } from 'vitest' +import type { RpcResponse } from './types' +import { FakeSession } from './mobile-endpoint-supervisor-test-fakes' +import { isRpcDeliveryUnknown, markRpcDeliveryUnknown } from './rpc-delivery-ambiguity' +import { interpretAtRpcBarrier, startRpcOperation } from './rpc-operation' +import { + rpcRefusal, + rpcSuccess, + terminalListAtBarrier, + workspaceListAtBarrier, + worktreePsProbeAtBarrier +} from './rpc-operation-test-families' + +const rows = { worktrees: [{ id: 'w1' }] } + +function deferred(): { promise: Promise; resolve: (value: T) => void } { + let resolve!: (value: T) => void + const promise = new Promise((resolvePromise) => { + resolve = resolvePromise + }) + return { promise, resolve } +} + +function replying(response: RpcResponse): FakeSession { + const session = new FakeSession('connected') + session.sendRequest.mockResolvedValue(response) + return session +} + +function settleAfter(milliseconds: number): Promise<'still waiting'> { + return new Promise((resolve) => setTimeout(() => resolve('still waiting'), milliseconds)) +} + +describe('the post-barrier combinator', () => { + it('starts every request before anything is awaited', () => { + const first = replying(rpcSuccess(rows)) + const second = replying(rpcSuccess({ terminals: [] })) + + startRpcOperation(first, workspaceListAtBarrier, {}) + startRpcOperation(second, terminalListAtBarrier, {}) + + expect(first.sendRequest).toHaveBeenCalledTimes(1) + expect(second.sendRequest).toHaveBeenCalledTimes(1) + }) + + it('yields one verdict per operation, in declared order', async () => { + const verdicts = await interpretAtRpcBarrier([ + startRpcOperation(replying(rpcSuccess(rows)), workspaceListAtBarrier, {}), + startRpcOperation(replying(rpcSuccess({ terminals: [] })), terminalListAtBarrier, {}), + startRpcOperation(replying(rpcSuccess(rows)), worktreePsProbeAtBarrier, {}) + ]) + + expect(verdicts).toEqual([rows, { terminals: [] }, false]) + }) + + // The bug class this exists to remove: whichever peer lost the race used to decide which + // error the user saw. Here the second request fails first in time and the first one refuses + // afterwards, and the declaration still decides. + it('interprets in declared order rather than completion order', async () => { + const lateRefusal = deferred() + const refusing = new FakeSession('connected') + refusing.sendRequest.mockReturnValue(lateRefusal.promise) + const dropped = new FakeSession('connected') + dropped.sendRequest.mockRejectedValue(new Error('socket closed first')) + + const barrier = interpretAtRpcBarrier([ + startRpcOperation(refusing, workspaceListAtBarrier, {}), + startRpcOperation(dropped, terminalListAtBarrier, {}) + ]) + lateRefusal.resolve(rpcRefusal('method_not_found', 'no such method')) + + await expect(barrier).rejects.toThrow('method_not_found: no such method') + }) + + it('keeps the middle operation error when a later one also fails', async () => { + const barrier = interpretAtRpcBarrier([ + startRpcOperation(replying(rpcSuccess(rows)), workspaceListAtBarrier, {}), + startRpcOperation( + replying(rpcRefusal('conflict', 'middle refused')), + workspaceListAtBarrier, + {} + ), + startRpcOperation( + replying(rpcRefusal('runtime_error', 'later refused')), + workspaceListAtBarrier, + {} + ) + ]) + + await expect(barrier).rejects.toThrow('conflict: middle refused') + }) + + it('does not interpret until every raw request has settled', async () => { + const refusing = replying(rpcRefusal('runtime_error', 'boom')) + const pending = deferred() + const stalled = new FakeSession('connected') + stalled.sendRequest.mockReturnValue(pending.promise) + + const barrier = interpretAtRpcBarrier([ + startRpcOperation(refusing, workspaceListAtBarrier, {}), + startRpcOperation(stalled, terminalListAtBarrier, {}) + ]) + const raced = await Promise.race([ + barrier.then( + () => 'resolved' as const, + () => 'rejected' as const + ), + settleAfter(50) + ]) + expect(raced).toBe('still waiting') + + pending.resolve(rpcSuccess({ terminals: [] })) + await expect(barrier).rejects.toThrow('runtime_error: boom') + }) + + it('rethrows a captured transport rejection as the original error object', async () => { + const error = markRpcDeliveryUnknown(new Error('socket closed before response')) + const dropped = new FakeSession('connected') + dropped.sendRequest.mockRejectedValue(error) + + const caught = await interpretAtRpcBarrier([ + startRpcOperation(replying(rpcSuccess(rows)), workspaceListAtBarrier, {}), + startRpcOperation(dropped, terminalListAtBarrier, {}) + ]).catch((thrown: unknown) => thrown) + + expect(caught).toBe(error) + expect(isRpcDeliveryUnknown(caught)).toBe(true) + }) + + it('applies the policy each family declared, at the barrier', async () => { + const verdicts = await interpretAtRpcBarrier([ + startRpcOperation(replying(rpcRefusal('runtime_error')), terminalListAtBarrier, {}), + startRpcOperation(replying(rpcRefusal('method_not_found')), worktreePsProbeAtBarrier, {}) + ]) + + expect(verdicts).toEqual([null, true]) + }) +}) diff --git a/mobile/src/transport/rpc-operation-cast-fence.test.ts b/mobile/src/transport/rpc-operation-cast-fence.test.ts new file mode 100644 index 00000000000..83d76eb87ab --- /dev/null +++ b/mobile/src/transport/rpc-operation-cast-fence.test.ts @@ -0,0 +1,256 @@ +import { readFileSync, readdirSync } from 'node:fs' +import { fileURLToPath } from 'node:url' +import { extname, join, relative, resolve } from 'node:path' +import ts from 'typescript' +import { describe, expect, it } from 'vitest' + +/** + * Bans the escapes that would make the typed boundary decorative. + * + * An operation's whole claim is that a reply arrives as a declared type because a reader + * decoded it. `as`, `any` and a `@ts-` suppression each produce the same declared type without + * the decode, so one of them anywhere in an operation implementation buys back exactly the + * drift the contract removed — and it buys it silently, since the code still compiles and the + * types still read as validated. + * + * The fenced region includes the operation API, contract and result-reader factory, plus + * non-test files importing them and files that re-export a + * file that is (transitively). Step 4's operation modules therefore land inside the fence the + * moment they are written, with nothing to remember. + * + * What this does NOT catch, all accepted: + * - A lying reader. `z.unknown()` or a schema looser than the reply decodes anything, and no + * syntax check can tell a permissive schema from a wrong one. + * - Structural laundering: a helper in an unfenced module that returns the wrong type + * honestly, which the operation then consumes without a cast. + * - `!` non-null assertions, and the widening that an untyped intermediate variable gives + * you for free. + * - A screen. Screens are outside the region by design until they hold an operation; the + * raw-port inventory is what governs them. + */ + +const mobileRoot = fileURLToPath(new URL('../..', import.meta.url)) +const scannedRoots = ['app', 'src'].map((directory) => join(mobileRoot, directory)) +const sourceExtensions = new Set(['.js', '.jsx', '.ts', '.tsx']) +const transportRoot = join(mobileRoot, 'src', 'transport') + +/** Importing any of these is what makes a file an operation implementation. */ +const REGION_SEEDS = new Set( + ['rpc-operation', 'rpc-operation-contract', 'rpc-operation-result-reader'].map((name) => + join(transportRoot, name) + ) +) + +export type RpcOperationEscape = 'assertion' | 'any' | 'suppression' + +type CastFenceException = { + readonly file: string + readonly allows: readonly RpcOperationEscape[] +} + +/** + * The modules that own the `unknown` → declared-type transition, so the erasure has to land + * somewhere. Held as data, per escape kind, so an exception cannot quietly widen into the + * others. Every entry is also checked for staleness. + */ +const CAST_FENCE_EXCEPTIONS: readonly CastFenceException[] = [ + // The interpreter. Its casts re-apply type parameters that `AnyRpcOperation` erased on the + // way in; none of them invents a shape the reader did not already produce. + { file: 'src/transport/rpc-operation.ts', allows: ['assertion'] }, + // The reader factory. `safeParse` returns the schema's own output type as `unknown`. + { file: 'src/transport/rpc-operation-result-reader.ts', allows: ['assertion'] }, + // Nothing but suppressions: every directive in it is an assertion that tsc still rejects + // the thing above it, which is the compile fence's entire mechanism. + { file: 'src/transport/rpc-operation-compile-fence.ts', allows: ['suppression'] } +] + +// Text, not AST: a suppression is a comment, and comments are not nodes. A directive spelled +// inside a string literal therefore reads as one — which fails closed. +const SUPPRESSION = /@ts-(?:expect-error|ignore|nocheck)\b/ + +function sourceFiles(directory: string): string[] { + return readdirSync(directory, { withFileTypes: true }).flatMap((entry) => { + const path = join(directory, entry.name) + if (entry.isDirectory()) { + return entry.name === 'node_modules' ? [] : sourceFiles(path) + } + return [path] + }) +} + +function parse(path: string, source: string): ts.SourceFile { + const extension = extname(path) + return ts.createSourceFile( + path, + source, + ts.ScriptTarget.Latest, + true, + extension === '.tsx' || extension === '.jsx' ? ts.ScriptKind.TSX : ts.ScriptKind.TS + ) +} + +function resolvedSpecifier(path: string, node: ts.Node | undefined): string | null { + if (!node || !ts.isStringLiteral(node) || !node.text.startsWith('.')) { + return null + } + return resolve(path, '..', node.text) +} + +/** `as const` narrows a literal; it declares nothing the value was not already. */ +function isConstAssertion(node: ts.AsExpression): boolean { + return ( + ts.isTypeReferenceNode(node.type) && + ts.isIdentifier(node.type.typeName) && + node.type.typeName.text === 'const' + ) +} + +export function rpcOperationEscapes(path: string, source: string): RpcOperationEscape[] { + const found: RpcOperationEscape[] = [] + const visit = (node: ts.Node): void => { + if ( + (ts.isAsExpression(node) && !isConstAssertion(node)) || + ts.isTypeAssertionExpression(node) + ) { + found.push('assertion') + } + if (node.kind === ts.SyntaxKind.AnyKeyword) { + found.push('any') + } + ts.forEachChild(node, visit) + } + visit(parse(path, source)) + if (SUPPRESSION.test(source)) { + found.push('suppression') + } + return [...new Set(found)].sort() +} + +/** Imports and re-exports that make the importer part of the operation region. */ +function moduleEdges(path: string, source: string): { imports: string[]; reExports: string[] } { + const imports: string[] = [] + const reExports: string[] = [] + for (const statement of parse(path, source).statements) { + if (ts.isImportDeclaration(statement)) { + const target = resolvedSpecifier(path, statement.moduleSpecifier) + if (target) { + imports.push(target) + } + continue + } + if (ts.isExportDeclaration(statement) && statement.moduleSpecifier) { + const target = resolvedSpecifier(path, statement.moduleSpecifier) + if (target) { + imports.push(target) + reExports.push(target) + } + } + } + return { imports, reExports } +} + +const scanned = scannedRoots + .flatMap(sourceFiles) + .filter((path) => sourceExtensions.has(extname(path))) + .filter((path) => !/\.test\.tsx?$/.test(path)) + +const sources = new Map(scanned.map((path) => [path, readFileSync(path, 'utf8')] as const)) +const edges = new Map([...sources].map(([path, source]) => [path, moduleEdges(path, source)])) + +/** Modules are keyed without their extension, the way a relative specifier resolves. */ +function moduleKey(path: string): string { + return path.replace(/\.[jt]sx?$/, '') +} + +const region = new Set(scanned.filter((path) => REGION_SEEDS.has(moduleKey(path)))) +for (const [path, { imports }] of edges) { + if (imports.some((target) => REGION_SEEDS.has(target))) { + region.add(path) + } +} +// Fixpoint over re-export edges: a barrel that re-exports an operation module is in the fence +// too, which is where a cast would otherwise sit unwatched between definition and screen. +for (let changed = true; changed;) { + changed = false + const members = new Set([...region].map(moduleKey)) + for (const [path, { reExports }] of edges) { + if (!region.has(path) && reExports.some((target) => members.has(target))) { + region.add(path) + changed = true + } + } +} + +const relativeRegion = [...region].map((path) => + relative(mobileRoot, path).split(/[/\\]/).join('/') +) + +describe('RPC operation cast fence', () => { + const probe = join(mobileRoot, 'src', 'transport', 'probe.ts') + + it('recognizes each escape and leaves honest code alone', () => { + expect(rpcOperationEscapes(probe, 'const v = raw as WorkspaceRows')).toEqual(['assertion']) + expect(rpcOperationEscapes(probe, 'const v = raw as unknown as WorkspaceRows')).toEqual([ + 'assertion' + ]) + expect(rpcOperationEscapes(probe, 'const v: any = raw')).toEqual(['any']) + expect(rpcOperationEscapes(probe, 'function f(raw: any) {}')).toEqual(['any']) + expect(rpcOperationEscapes(probe, 'const v = raw as any')).toEqual(['any', 'assertion']) + expect(rpcOperationEscapes(probe, '// @ts-expect-error\nconst v = raw')).toEqual([ + 'suppression' + ]) + expect(rpcOperationEscapes(probe, '// @ts-ignore\nconst v = raw')).toEqual(['suppression']) + expect(rpcOperationEscapes(probe, "const v = ['a'] as const")).toEqual([]) + expect(rpcOperationEscapes(probe, 'const v = read(raw)')).toEqual([]) + expect(rpcOperationEscapes(probe, 'const v = raw satisfies WorkspaceRows')).toEqual([]) + expect(rpcOperationEscapes(probe, 'const v = value!')).toEqual([]) + }) + + it('puts every operation module in the fenced region', () => { + for (const file of [ + 'src/transport/rpc-operation.ts', + 'src/transport/rpc-operation-contract.ts', + 'src/transport/rpc-operation-test-families.ts', + 'src/transport/rpc-operation-compile-fence.ts', + 'src/transport/rpc-operation-result-reader.ts', + 'src/transport/rpc-incompatible-reply-error.ts' + ]) { + expect(relativeRegion, `${file} must be fenced`).toContain(file) + } + // A screen that only holds a client is governed by the raw-port inventory, not by this. + expect(relativeRegion).not.toContain('src/transport/rpc-client.ts') + }) + + it('has no operation module casting, widening or suppressing its way to a type', () => { + const allowed = new Map(CAST_FENCE_EXCEPTIONS.map((entry) => [entry.file, entry.allows])) + const offenders = [...region] + .map((path) => { + const file = relative(mobileRoot, path).split(/[/\\]/).join('/') + const escapes = rpcOperationEscapes(path, sources.get(path) ?? '') + const permitted = allowed.get(file) ?? [] + return { file, escapes: escapes.filter((escape) => !permitted.includes(escape)) } + }) + .filter((entry) => entry.escapes.length > 0) + .map((entry) => `${entry.file}: ${entry.escapes.join(', ')}`) + .sort() + + expect( + offenders, + 'Decode the reply with a reader instead. An operation that asserts its own result type is not typed.' + ).toEqual([]) + }) + + it('has no stale cast-fence exception', () => { + const stale = CAST_FENCE_EXCEPTIONS.flatMap((entry) => { + const path = join(mobileRoot, entry.file) + if (!region.has(path)) { + return [`${entry.file}: no longer in the fenced region`] + } + const escapes = rpcOperationEscapes(path, sources.get(path) ?? '') + return entry.allows + .filter((escape) => !escapes.includes(escape)) + .map((escape) => `${entry.file}: no longer uses '${escape}'`) + }) + expect(stale, 'Narrow or delete the exception in rpc-operation-cast-fence.test.ts.').toEqual([]) + }) +}) diff --git a/mobile/src/transport/rpc-operation-compile-fence.ts b/mobile/src/transport/rpc-operation-compile-fence.ts new file mode 100644 index 00000000000..3188774743c --- /dev/null +++ b/mobile/src/transport/rpc-operation-compile-fence.ts @@ -0,0 +1,191 @@ +import type { RpcClient } from './rpc-client' +import type { RpcMethodName, RpcParams, RpcSendParams } from './rpc-params-contract' +import { defineRpcOperation, runRpcOperation, startRpcOperation } from './rpc-operation' +import { rpcResultVariants } from './rpc-operation-result-reader' +import { + workspaceListAtBarrier, + workspaceListOrNull, + workspaceRowsReader, + worktreePsProbe, + type WorkspaceRows +} from './rpc-operation-test-families' +import type { + CapabilityProbeRpcDefinition, + ObjectResultRpcDefinition, + RequireResultRpcDefinition, + RpcAcceptanceName, + RpcCompatibleReader, + RpcOperation +} from './rpc-operation-contract' + +// Why this file exists: the descriptor's whole point is that a call site cannot pick the +// acceptance policy, the interpretation barrier, or the send-side params for itself. Every +// expect-error directive below is that claim as an assertion — tsc fails on a directive that +// stops catching an error, so `pnpm --dir mobile typecheck` is the gate. Nothing here runs and +// no app code imports it. + +declare const client: RpcClient + +// @ts-expect-error a variant reader combinator must have at least one reader +const _fenceEmptyVariantReaders = rpcResultVariants([]) + +export const fenceProbeWithReader: CapabilityProbeRpcDefinition<'worktree.ps', 'on-settle'> = { + name: 'fence.probeWithReader', + method: 'worktree.ps', + acceptance: 'method-not-found-refusal', + barrier: 'on-settle', + // @ts-expect-error a refusal-code probe reads no payload, so it cannot carry a reader + read: workspaceRowsReader +} + +// @ts-expect-error 'require-result-or-throw' has no value to return without a reader +export const fenceDecodingWithoutReader: RequireResultRpcDefinition< + 'worktree.ps', + 'rows', + WorkspaceRows, + 'on-settle' +> = { + name: 'fence.decodingWithoutReader', + method: 'worktree.ps', + acceptance: 'require-result-or-throw', + barrier: 'on-settle' +} + +// @ts-expect-error the four policies in rpc-acceptance-policies.ts are the whole vocabulary +export const fenceInventedPolicy: RpcAcceptanceName = 'no-error-means-fine' + +// A reader for a payload no acceptance policy here admits, i.e. one belonging to some other +// family's shape. +const fenceTextReader: RpcCompatibleReader = (raw) => ({ + compatible: true, + variant: 'text', + value: raw, + salvage: { droppedPaths: [], droppedCount: 0 } +}) + +export const fenceObjectPolicyWrongReader: ObjectResultRpcDefinition< + 'worktree.ps', + 'text', + string, + 'on-settle' +> = { + name: 'fence.objectPolicyWrongReader', + method: 'worktree.ps', + acceptance: 'object-result-or-null', + barrier: 'on-settle', + // @ts-expect-error the policy admits a non-null object, not the string this reader expects + read: fenceTextReader +} + +export const fenceDefineRejectsMismatch = defineRpcOperation({ + name: 'fence.defineRejectsMismatch', + method: 'worktree.ps', + // @ts-expect-error no overload of defineRpcOperation pairs a probe with a payload reader + acceptance: 'method-not-found-refusal', + barrier: 'on-settle', + // @ts-expect-error ... and the reader it would need is exactly what the probe overload bans + read: workspaceRowsReader +}) + +// @ts-expect-error only generated catalog method names are addressable +export const fenceUnknownMethod: RpcMethodName = 'worktree.nope' + +// The send-side params type. z.output (what the handler receives) and z.input (what the +// coercing builders admit) are both wrong for a sender in opposite directions, so these pin +// the two failures a regression to either one would reintroduce. + +// `query` and `limit` carry .default(), so a sender may leave them out. Under z.output both +// read as required and this line stops compiling. +export const fenceOmitsDefaultedField: RpcSendParams<'files.searchPaths'> = { worktree: 'w' } + +export const fenceRejectsWrongFieldType: RpcSendParams<'files.searchPaths'> = { + // @ts-expect-error z.input of a z.unknown().transform builder admits any value; this does not + worktree: 42 +} + +// @ts-expect-error `worktree` has neither a default nor an optional marker +export const fenceKeepsRequiredField: RpcSendParams<'files.searchPaths'> = { query: 'x' } + +// Catalog-wide: anything a handler could have been handed is something a sender may write. +// A method that ever resolves tighter than its parsed shape lands in this union. +declare const fenceTighterThanParsed: { + [Method in RpcMethodName]: RpcParams extends RpcSendParams ? never : Method +}[RpcMethodName] & {} +export const fenceNoTighterMethod: never = fenceTighterThanParsed + +// z.input collapses every coercing builder to `unknown`. Only plugins.panelAction may be +// unknown, because its schema is literally z.unknown(). +declare const fenceUnknownParams: { + [Method in RpcMethodName]: unknown extends RpcSendParams ? Method : never +}[RpcMethodName] & {} +export const fenceOnlyDeclaredUnknown: 'plugins.panelAction' = fenceUnknownParams + +export async function fenceBarrierAndParams(): Promise { + await runRpcOperation( + client, + // @ts-expect-error this family interprets after all requests, so it has no on-settle run + workspaceListAtBarrier, + {} + ) + startRpcOperation( + client, + // @ts-expect-error an on-settle family must not be parked behind someone else's barrier + worktreePsProbe, + {} + ) + await runRpcOperation( + client, + workspaceListOrNull, + // @ts-expect-error worktree.ps takes a numeric limit + { limit: 'ten' } + ) +} + +export async function fenceVerdictTypes(): Promise { + // @ts-expect-error the probe's policy yields a boolean, not the other family's rows + const rows: WorkspaceRows = await runRpcOperation(client, worktreePsProbe, {}) + void rows +} + +// @ts-expect-error the public descriptor also requires decoding, even without the factory +export const fenceManualWithoutReader: RpcOperation< + 'worktree.ps', + 'require-result-or-throw', + 'rows', + WorkspaceRows, + 'on-settle' +> = { + name: 'fence.manual', + method: 'worktree.ps', + acceptance: 'require-result-or-throw', + barrier: 'on-settle' +} + +// @ts-expect-error widening the policy cannot disconnect it from its required reader +export const fenceBroadWithoutReader: RpcOperation< + 'worktree.ps', + RpcAcceptanceName, + 'rows', + WorkspaceRows, + 'on-settle' +> = { + name: 'fence.broad', + method: 'worktree.ps', + acceptance: 'require-result-or-throw', + barrier: 'on-settle', + read: undefined +} + +// @ts-expect-error object acceptance must decode, just like require-result acceptance +export const fenceObjectWithoutReader: RpcOperation< + 'worktree.ps', + 'object-result-or-null', + 'rows', + WorkspaceRows, + 'on-settle' +> = { + name: 'fence.object', + method: 'worktree.ps', + acceptance: 'object-result-or-null', + barrier: 'on-settle' +} diff --git a/mobile/src/transport/rpc-operation-contract.ts b/mobile/src/transport/rpc-operation-contract.ts new file mode 100644 index 00000000000..581506a97a7 --- /dev/null +++ b/mobile/src/transport/rpc-operation-contract.ts @@ -0,0 +1,155 @@ +import type { RpcMethodName } from './rpc-params-contract' +import type { RpcFailure, RpcResponse, RpcSuccess } from './types' + +// An operation descriptor fixes the method, the acceptance policy and the interpretation +// barrier at definition time. Per-call freedom over those three is what produced acceptance +// drift and settlement-order drift across mobile's RPC call sites, so none of them is a +// parameter of any send helper. + +/** One of the named policies in rpc-acceptance-policies.ts, chosen per operation family. */ +export type RpcAcceptanceName = + | 'require-result-or-throw' + | 'object-result-or-null' + | 'method-not-found-refusal' + | 'streaming-opener' + +/** Where a settled reply may become a value or a throw. */ +export type RpcInterpretationBarrier = 'on-settle' | 'after-all-requests' + +export type RpcDecodeIssue = { readonly path: string; readonly message: string } + +/** Bounded salvage diagnostics for a reply that decoded with parts dropped. */ +export type RpcSalvageReport = { + readonly droppedPaths: readonly string[] + readonly droppedCount: number +} + +export type RpcReadResult = + | { + readonly compatible: true + readonly variant: Variant + readonly value: Value + readonly salvage: RpcSalvageReport + } + | { readonly compatible: false; readonly issues: readonly RpcDecodeIssue[] } + +/** Reads the payload its acceptance policy admits into one declared semantic variant. */ +export type RpcCompatibleReader = ( + raw: Raw +) => RpcReadResult + +export type RpcStreamOpenerReply = RpcSuccess & { streaming: true } + +// Only a fulfilled outer envelope is classified. Transport rejection stays on the promise +// channel, so an operation in a Promise.all still fails the group immediately instead of +// waiting for a peer and letting a later policy surface a different error. +export type RpcRequestOutcome = + | { + readonly kind: 'outer-refused' + readonly error: RpcFailure['error'] + readonly raw: RpcResponse + } + | { + readonly kind: 'decoded' + readonly variant: Variant + readonly value: Value + readonly raw: RpcResponse + readonly salvage: RpcSalvageReport + } + | { + readonly kind: 'incompatible' + readonly raw: RpcResponse + readonly issues: readonly RpcDecodeIssue[] + } + +export type RpcOperation< + Method extends RpcMethodName, + Acceptance extends RpcAcceptanceName, + Variant extends string, + Value, + Barrier extends RpcInterpretationBarrier +> = { + /** Family name, not the method: two families may share a method with different acceptance. */ + readonly name: string + readonly method: Method + readonly barrier: Barrier +} & { + [Policy in RpcAcceptanceName]: { + readonly acceptance: Policy + readonly read: Policy extends 'require-result-or-throw' | 'object-result-or-null' + ? RpcCompatibleReader + : undefined + } +}[Acceptance] + +// Internal interpreter view; public send APIs retain the policy/reader correlation. +export type AnyRpcOperation = Pick< + RpcOperation, + 'name' | 'method' | 'acceptance' | 'barrier' +> & { readonly read: RpcCompatibleReader | undefined } + +/** The verdict the declared policy yields. Not a per-call choice. */ +export type RpcVerdict< + Acceptance extends RpcAcceptanceName, + Value +> = Acceptance extends 'require-result-or-throw' + ? Value + : Acceptance extends 'object-result-or-null' + ? Value | null + : Acceptance extends 'method-not-found-refusal' + ? boolean + : Acceptance extends 'streaming-opener' + ? RpcStreamOpenerReply | null + : never + +export type RpcOperationSettlement = + | { readonly status: 'fulfilled'; readonly outcome: RpcRequestOutcome } + | { readonly status: 'rejected'; readonly error: unknown } + +type RpcOperationDefinition< + Method extends RpcMethodName, + Barrier extends RpcInterpretationBarrier +> = { + name: string + method: Method + barrier: Barrier +} + +export type RequireResultRpcDefinition< + Method extends RpcMethodName, + Variant extends string, + Value, + Barrier extends RpcInterpretationBarrier +> = RpcOperationDefinition & { + acceptance: 'require-result-or-throw' + read: RpcCompatibleReader +} + +export type ObjectResultRpcDefinition< + Method extends RpcMethodName, + Variant extends string, + Value, + Barrier extends RpcInterpretationBarrier +> = RpcOperationDefinition & { + acceptance: 'object-result-or-null' + // Raw is the non-null object rpcObjectResultOrNull admits; anything else is incompatible. + read: RpcCompatibleReader, Variant, Value> +} + +export type CapabilityProbeRpcDefinition< + Method extends RpcMethodName, + Barrier extends RpcInterpretationBarrier +> = RpcOperationDefinition & { + acceptance: 'method-not-found-refusal' + /** A probe answers from the refusal code alone, so a reader would have nothing to read. */ + read?: never +} + +export type StreamOpenerRpcDefinition< + Method extends RpcMethodName, + Barrier extends RpcInterpretationBarrier +> = RpcOperationDefinition & { + acceptance: 'streaming-opener' + /** The opener's value is the reply itself; frames arrive on the subscription, not here. */ + read?: never +} diff --git a/mobile/src/transport/rpc-operation-result-reader.test.ts b/mobile/src/transport/rpc-operation-result-reader.test.ts new file mode 100644 index 00000000000..0a5393352b3 --- /dev/null +++ b/mobile/src/transport/rpc-operation-result-reader.test.ts @@ -0,0 +1,145 @@ +import { describe, expect, it } from 'vitest' +import { z } from 'zod' +import { salvagingArray } from '../../../src/shared/zod-salvage' +import { FakeSession } from './mobile-endpoint-supervisor-test-fakes' +import { captureRpcOperationSettlement, defineRpcOperation } from './rpc-operation' +import { rpcResultVariant, rpcResultVariants } from './rpc-operation-result-reader' +import { + WORKSPACE_ROWS_SCHEMA, + rpcSuccess, + workspaceRowsReader +} from './rpc-operation-test-families' + +const SALVAGING_ROWS_SCHEMA = z.object({ + worktrees: salvagingArray(z.object({ id: z.string() })) +}) + +const salvagingReader = rpcResultVariant('rows', SALVAGING_ROWS_SCHEMA) + +describe('a single-variant reader', () => { + it('decodes a matching payload and reports nothing dropped', () => { + expect(workspaceRowsReader({ worktrees: [{ id: 'w1' }] })).toEqual({ + compatible: true, + variant: 'rows', + value: { worktrees: [{ id: 'w1' }] }, + salvage: { droppedPaths: [], droppedCount: 0 } + }) + }) + + it('reports dotted issue paths for a payload it cannot read', () => { + const result = workspaceRowsReader({ worktrees: [{ id: 1 }] }) + + expect(result.compatible).toBe(false) + if (result.compatible) { + throw new Error('expected an incompatible read') + } + expect(result.issues).toEqual([{ path: 'worktrees.0.id', message: expect.any(String) }]) + }) + + it('carries the salvage report when the schema drops an element', () => { + const result = salvagingReader({ worktrees: [{ id: 'w1' }, { id: 7 }] }) + + expect(result).toEqual({ + compatible: true, + variant: 'rows', + value: { worktrees: [{ id: 'w1' }] }, + // zod-salvage reports the path relative to the salvaging container, not the envelope. + salvage: { droppedPaths: ['1'], droppedCount: 1 } + }) + }) + + // zod-salvage keeps its collector at module level, so a leak here would blame the next + // reply for the previous one's drops. + it('does not leak drop diagnostics into the next read', () => { + salvagingReader({ worktrees: [{ id: 7 }] }) + + expect(salvagingReader({ worktrees: [{ id: 'w1' }] })).toMatchObject({ + salvage: { droppedPaths: [], droppedCount: 0 } + }) + }) + + it('bounds the issues it reports and says how many it dropped', () => { + const wide = { worktrees: Array.from({ length: 25 }, () => ({ id: 1 })) } + const result = workspaceRowsReader(wide) + + if (result.compatible) { + throw new Error('expected an incompatible read') + } + expect(result.issues).toHaveLength(21) + expect(result.issues[20]).toEqual({ path: '', message: '5 further issues omitted' }) + }) + + // The caveat that comes with zod-salvage: it wraps a synchronous parse only. An async + // schema must read as incompatible rather than leaking a promise into the outcome. + it('reads an async schema as incompatible instead of leaking a promise', () => { + const asyncReader = rpcResultVariant( + 'rows', + z.object({ id: z.string() }).refine(async () => true) + ) + + const result = asyncReader({ id: 'w1' }) + + expect(result.compatible).toBe(false) + if (result.compatible) { + throw new Error('expected an incompatible read') + } + expect(result.issues[0].message).toContain('synchronous parse') + }) +}) + +describe('a multi-variant reader', () => { + const reader = rpcResultVariants<'rows' | 'legacy-array', unknown>([ + workspaceRowsReader, + rpcResultVariant('legacy-array', z.array(z.object({ id: z.string() }))) + ]) + + it('takes the first declared variant that reads', () => { + expect(reader({ worktrees: [{ id: 'w1' }] })).toMatchObject({ variant: 'rows' }) + }) + + it('falls through to a later variant', () => { + expect(reader([{ id: 'w1' }])).toMatchObject({ + variant: 'legacy-array', + value: [{ id: 'w1' }] + }) + }) + + it('tags every variant it tried when none of them reads', () => { + const result = reader('neither shape') + + if (result.compatible) { + throw new Error('expected an incompatible read') + } + expect(result.issues.map((issue) => issue.path)).toEqual(['rows', 'legacy-array']) + }) +}) + +describe('salvage through a descriptor', () => { + const salvagingList = defineRpcOperation({ + name: 'test.salvagingWorkspaceList', + method: 'worktree.ps', + acceptance: 'require-result-or-throw', + barrier: 'on-settle', + read: salvagingReader + }) + + it('reports the dropped paths on the decoded outcome', async () => { + const session = new FakeSession('connected') + session.sendRequest.mockResolvedValue(rpcSuccess({ worktrees: [{ id: 'w1' }, { id: 7 }] })) + + const settlement = await captureRpcOperationSettlement(session, salvagingList, {}) + + expect(settlement.status === 'fulfilled' && settlement.outcome).toMatchObject({ + kind: 'decoded', + value: { worktrees: [{ id: 'w1' }] }, + salvage: { droppedPaths: ['1'], droppedCount: 1 } + }) + }) +}) + +describe('the shared workspace schema', () => { + it('is the strict shape the salvaging variant relaxes', () => { + expect(WORKSPACE_ROWS_SCHEMA.safeParse({ worktrees: [{ id: 7 }] }).success).toBe(false) + expect(SALVAGING_ROWS_SCHEMA.safeParse({ worktrees: [{ id: 7 }] }).success).toBe(true) + }) +}) diff --git a/mobile/src/transport/rpc-operation-result-reader.ts b/mobile/src/transport/rpc-operation-result-reader.ts new file mode 100644 index 00000000000..b9341e0e8bc --- /dev/null +++ b/mobile/src/transport/rpc-operation-result-reader.ts @@ -0,0 +1,89 @@ +import { z } from 'zod' +import { collectSalvageDrops } from '../../../src/shared/zod-salvage' +import type { RpcCompatibleReader, RpcDecodeIssue } from './rpc-operation-contract' + +const MAX_REPORTED_DECODE_ISSUES = 20 + +/** A reader that names the semantic variant it decodes, so a combinator can tag its issues. */ +export type NamedRpcResultReader = RpcCompatibleReader< + unknown, + Variant, + Value +> & { readonly variant: Variant } + +/** Builds a compatible reader for one semantic variant of a reply payload. */ +export function rpcResultVariant( + variant: Variant, + schema: Schema +): NamedRpcResultReader> { + const read: RpcCompatibleReader> = (raw) => { + try { + // Why: zod-salvage holds module-level collector state and wraps a *synchronous* + // parse only; safeParse throws on an async schema, which reads as incompatible. + const parsed = collectSalvageDrops(() => schema.safeParse(raw)) + if (!parsed.value.success) { + return { compatible: false, issues: decodeIssues(parsed.value.error) } + } + return { + compatible: true, + variant, + value: parsed.value.data as z.output, + salvage: { droppedPaths: parsed.droppedPaths, droppedCount: parsed.droppedCount } + } + } catch (error) { + return { compatible: false, issues: [{ path: '', message: describeThrow(error) }] } + } + } + return Object.assign(read, { variant }) +} + +/** Tries each variant in declared order and takes the first that reads. */ +export function rpcResultVariants( + readers: readonly [ + NamedRpcResultReader, + ...NamedRpcResultReader[] + ] +): RpcCompatibleReader { + return (raw) => { + const issues: RpcDecodeIssue[] = [] + for (const reader of readers) { + const result = reader(raw) + if (result.compatible) { + return result + } + for (const issue of result.issues) { + issues.push({ path: joinPath(reader.variant, issue.path), message: issue.message }) + } + } + return { compatible: false, issues: boundIssues(issues) } + } +} + +function decodeIssues(error: z.ZodError): RpcDecodeIssue[] { + return boundIssues( + error.issues.map((issue) => ({ + path: issue.path.map((segment) => String(segment)).join('.'), + message: issue.message + })) + ) +} + +// Why: a hostile or very foreign reply can issue per element; report a bounded sample and +// say how many were dropped rather than letting the diagnostic grow with the payload. +function boundIssues(issues: readonly RpcDecodeIssue[]): RpcDecodeIssue[] { + if (issues.length <= MAX_REPORTED_DECODE_ISSUES) { + return [...issues] + } + return [ + ...issues.slice(0, MAX_REPORTED_DECODE_ISSUES), + { path: '', message: `${issues.length - MAX_REPORTED_DECODE_ISSUES} further issues omitted` } + ] +} + +function joinPath(variant: string, path: string): string { + return path ? `${variant}.${path}` : variant +} + +function describeThrow(error: unknown): string { + return error instanceof Error ? error.message : String(error) +} diff --git a/mobile/src/transport/rpc-operation-test-families.ts b/mobile/src/transport/rpc-operation-test-families.ts new file mode 100644 index 00000000000..73554b7a4f8 --- /dev/null +++ b/mobile/src/transport/rpc-operation-test-families.ts @@ -0,0 +1,99 @@ +import { z } from 'zod' +import type { RpcResponse } from './types' +import type { RpcCompatibleReader } from './rpc-operation-contract' +import { rpcResultVariant, rpcResultVariants } from './rpc-operation-result-reader' +import { defineRpcOperation } from './rpc-operation' + +// Operation families used by the rpc-operation suites and by the compile fence. Kept in one +// place so the tests and the fence assert against the same descriptors, and so tsc sees them +// (the app tsconfig excludes *.test.ts). No app code imports this module. + +export const WORKSPACE_ROWS_SCHEMA = z.object({ + worktrees: z.array(z.object({ id: z.string() })) +}) + +const LEGACY_WORKSPACE_ROWS_SCHEMA = z.array(z.object({ id: z.string() })) + +export type WorkspaceRows = z.output +export type LegacyWorkspaceRows = z.output + +export const workspaceRowsReader = rpcResultVariant('rows', WORKSPACE_ROWS_SCHEMA) + +/** Two semantic variants: the modern envelope, then a host that answered a bare array. */ +export const workspaceRowsOrLegacyReader: RpcCompatibleReader< + unknown, + 'rows' | 'legacy-array', + WorkspaceRows | LegacyWorkspaceRows +> = rpcResultVariants<'rows' | 'legacy-array', WorkspaceRows | LegacyWorkspaceRows>([ + workspaceRowsReader, + rpcResultVariant('legacy-array', LEGACY_WORKSPACE_ROWS_SCHEMA) +]) + +export const workspaceListOrThrow = defineRpcOperation({ + name: 'test.workspaceListOrThrow', + method: 'worktree.ps', + acceptance: 'require-result-or-throw', + barrier: 'on-settle', + read: workspaceRowsOrLegacyReader +}) + +// Same method, a different family: main's callers disagreed about acceptance, so both rules +// stay named rather than being unified behind one descriptor. +export const workspaceListOrNull = defineRpcOperation({ + name: 'test.workspaceListOrNull', + method: 'worktree.ps', + acceptance: 'object-result-or-null', + barrier: 'on-settle', + read: workspaceRowsReader +}) + +export const worktreePsProbe = defineRpcOperation({ + name: 'test.worktreePsProbe', + method: 'worktree.ps', + acceptance: 'method-not-found-refusal', + barrier: 'on-settle' +}) + +export const terminalStreamOpener = defineRpcOperation({ + name: 'test.terminalStreamOpener', + method: 'terminal.subscribe', + acceptance: 'streaming-opener', + barrier: 'on-settle' +}) + +export const workspaceListAtBarrier = defineRpcOperation({ + name: 'test.workspaceListAtBarrier', + method: 'worktree.ps', + acceptance: 'require-result-or-throw', + barrier: 'after-all-requests', + read: workspaceRowsReader +}) + +export const terminalListAtBarrier = defineRpcOperation({ + name: 'test.terminalListAtBarrier', + method: 'terminal.list', + acceptance: 'object-result-or-null', + barrier: 'after-all-requests', + read: rpcResultVariant('terminals', z.object({ terminals: z.array(z.unknown()) })) +}) + +export const worktreePsProbeAtBarrier = defineRpcOperation({ + name: 'test.worktreePsProbeAtBarrier', + method: 'worktree.ps', + acceptance: 'method-not-found-refusal', + barrier: 'after-all-requests' +}) + +export function rpcSuccess(result: unknown, streaming?: true): RpcResponse { + return { + id: 'rpc-1', + ok: true, + result, + _meta: { runtimeId: 'runtime-1' }, + ...(streaming ? { streaming } : {}) + } +} + +export function rpcRefusal(code: string, message = 'Nope'): RpcResponse { + return { id: 'rpc-1', ok: false, error: { code, message }, _meta: { runtimeId: 'runtime-1' } } +} diff --git a/mobile/src/transport/rpc-operation.test.ts b/mobile/src/transport/rpc-operation.test.ts new file mode 100644 index 00000000000..91542b48528 --- /dev/null +++ b/mobile/src/transport/rpc-operation.test.ts @@ -0,0 +1,335 @@ +import { describe, expect, it } from 'vitest' +import type { RpcResponse } from './types' +import { FakeSession } from './mobile-endpoint-supervisor-test-fakes' +import { + createStableLogicalRpcClient, + isLogicalClientCutoverError +} from './stable-logical-rpc-client' +import { isRpcDeliveryUnknown, markRpcDeliveryUnknown } from './rpc-delivery-ambiguity' +import { + RpcIncompatibleReplyError, + isRpcIncompatibleReplyError +} from './rpc-incompatible-reply-error' +import { captureRpcOperationSettlement, runRpcOperation } from './rpc-operation' +import { + rpcRefusal, + rpcSuccess, + terminalListAtBarrier, + terminalStreamOpener, + workspaceListOrNull, + workspaceListOrThrow, + worktreePsProbe +} from './rpc-operation-test-families' + +function connectedSession(response?: RpcResponse): FakeSession { + const session = new FakeSession('connected') + if (response) { + session.sendRequest.mockResolvedValue(response) + } + return session +} + +const rows = { worktrees: [{ id: 'w1' }] } + +describe('request classification', () => { + it('decodes a compatible reply, naming the variant and keeping the raw envelope', async () => { + const response = rpcSuccess(rows) + const settlement = await captureRpcOperationSettlement( + connectedSession(response), + workspaceListOrThrow, + {} + ) + + expect(settlement).toEqual({ + status: 'fulfilled', + outcome: { + kind: 'decoded', + variant: 'rows', + value: rows, + raw: response, + salvage: { droppedPaths: [], droppedCount: 0 } + } + }) + }) + + it('names the legacy variant when the host answered the older shape', async () => { + const settlement = await captureRpcOperationSettlement( + connectedSession(rpcSuccess([{ id: 'w1' }])), + workspaceListOrThrow, + {} + ) + + expect(settlement.status === 'fulfilled' && settlement.outcome.kind).toBe('decoded') + expect(settlement.status === 'fulfilled' && settlement.outcome).toMatchObject({ + variant: 'legacy-array', + value: [{ id: 'w1' }] + }) + }) + + it('classifies a refusal as outer-refused rather than throwing', async () => { + const response = rpcRefusal('runtime_error', 'boom') + const settlement = await captureRpcOperationSettlement( + connectedSession(response), + workspaceListOrThrow, + {} + ) + + expect(settlement).toEqual({ + status: 'fulfilled', + outcome: { + kind: 'outer-refused', + error: { code: 'runtime_error', message: 'boom' }, + raw: response + } + }) + }) + + it('classifies a reply the reader cannot read as incompatible, with bounded issues', async () => { + const response = rpcSuccess({ worktrees: [{ id: 7 }] }) + const settlement = await captureRpcOperationSettlement( + connectedSession(response), + workspaceListOrThrow, + {} + ) + + expect(settlement.status).toBe('fulfilled') + if (settlement.status !== 'fulfilled' || settlement.outcome.kind !== 'incompatible') { + throw new Error('expected an incompatible outcome') + } + expect(settlement.outcome.raw).toBe(response) + expect(settlement.outcome.issues.map((issue) => issue.path)).toContain('rows.worktrees.0.id') + }) + + it('treats a reply that is not an object as incompatible for the nullable family', async () => { + const settlement = await captureRpcOperationSettlement( + connectedSession(rpcSuccess('not an object')), + workspaceListOrNull, + {} + ) + + expect(settlement.status === 'fulfilled' && settlement.outcome.kind).toBe('incompatible') + }) + + it('treats a throwing reader as incompatible, never as a transport failure', async () => { + const exploding = { + ...workspaceListOrThrow, + read: () => { + throw new Error('reader exploded') + } + } + const settlement = await captureRpcOperationSettlement( + connectedSession(rpcSuccess(rows)), + exploding, + {} + ) + + expect(settlement.status === 'fulfilled' && settlement.outcome.kind).toBe('incompatible') + }) +}) + +describe('transport rejection stays on the promise channel', () => { + it('rejects with the original error object', async () => { + const error = new Error('socket closed') + const session = new FakeSession('connected') + session.sendRequest.mockRejectedValue(error) + + await expect(runRpcOperation(session, workspaceListOrThrow, {})).rejects.toBe(error) + }) + + it('keeps a delivery-unknown mark readable through the descriptor', async () => { + const error = markRpcDeliveryUnknown(new Error('socket closed before response')) + const session = new FakeSession('connected') + session.sendRequest.mockRejectedValue(error) + + const caught = await runRpcOperation(session, workspaceListOrThrow, {}).catch( + (thrown: unknown) => thrown + ) + + expect(caught).toBe(error) + expect(isRpcDeliveryUnknown(caught)).toBe(true) + }) + + // The cutover predicate matches class or exact message because instanceof misses across + // bundle copies; a clone from another copy must still read as a cutover through the descriptor. + it('keeps a cutover error from another bundle copy recognisable', async () => { + class ForeignBundleCutoverError extends Error {} + const error = new ForeignBundleCutoverError('RPC interrupted by connection migration') + const session = new FakeSession('connected') + session.sendRequest.mockRejectedValue(error) + + const caught = await runRpcOperation(session, workspaceListOrThrow, {}).catch( + (thrown: unknown) => thrown + ) + + expect(caught).toBe(error) + expect(isLogicalClientCutoverError(caught)).toBe(true) + }) + + it('fails a Promise.all group immediately instead of waiting for a stalled peer', async () => { + const error = new Error('socket closed') + const failing = new FakeSession('connected') + failing.sendRequest.mockRejectedValue(error) + const stalled = new FakeSession('connected') + stalled.sendRequest.mockReturnValue(new Promise(() => {})) + + const group = Promise.all([ + runRpcOperation(failing, workspaceListOrThrow, {}), + runRpcOperation(stalled, workspaceListOrThrow, {}) + ]) + const raced = await Promise.race([ + group.then( + () => 'resolved' as const, + (caught: unknown) => caught + ), + new Promise((resolve) => setTimeout(() => resolve('still waiting'), 50)) + ]) + + expect(raced).toBe(error) + }) + + it('only captures a rejection when a caller names the all-settled helper', async () => { + const error = new Error('socket closed') + const session = new FakeSession('connected') + session.sendRequest.mockRejectedValue(error) + + await expect(captureRpcOperationSettlement(session, workspaceListOrThrow, {})).resolves.toEqual( + { status: 'rejected', error } + ) + }) +}) + +describe('the send path', () => { + it('carries the worktree.ps capability stamp, because it goes through the logical client', async () => { + const session = connectedSession(rpcSuccess(rows)) + const logical = createStableLogicalRpcClient(session, 'lan') + + await runRpcOperation(logical, workspaceListOrThrow, { limit: 500 }) + + expect(session.sendRequest).toHaveBeenCalledWith( + 'worktree.ps', + { limit: 500, supportsWorktreeVisibilitySourceDefaults: true }, + undefined + ) + }) + + it('sends the caller params object untouched for a method with no projection', async () => { + const session = connectedSession(rpcSuccess({ terminals: [] })) + const logical = createStableLogicalRpcClient(session, 'lan') + const params = { worktree: 'w1' } + + await captureRpcOperationSettlement(logical, terminalListAtBarrier, params, { + timeoutMs: 1234 + }) + + expect(session.sendRequest.mock.calls[0][0]).toBe('terminal.list') + expect(session.sendRequest.mock.calls[0][1]).toBe(params) + expect(session.sendRequest.mock.calls[0][2]).toEqual({ timeoutMs: 1234 }) + }) +}) + +describe('acceptance is a property of the family', () => { + const refusal = rpcRefusal('method_not_found', 'no such method') + + it('surfaces the refusal as a coded error for the throwing family', async () => { + await expect( + runRpcOperation(connectedSession(refusal), workspaceListOrThrow, {}) + ).rejects.toThrow('method_not_found: no such method') + }) + + it('answers null to the same refusal for the nullable family', async () => { + await expect( + runRpcOperation(connectedSession(refusal), workspaceListOrNull, {}) + ).resolves.toBeNull() + }) + + it('answers true to the same refusal for the capability probe', async () => { + await expect(runRpcOperation(connectedSession(refusal), worktreePsProbe, {})).resolves.toBe( + true + ) + }) + + it('keeps the probe false for another refusal code and for a success', async () => { + await expect( + runRpcOperation(connectedSession(rpcRefusal('runtime_error')), worktreePsProbe, {}) + ).resolves.toBe(false) + await expect( + runRpcOperation(connectedSession(rpcSuccess(rows)), worktreePsProbe, {}) + ).resolves.toBe(false) + }) + + it('returns the decoded value for the throwing family', async () => { + await expect( + runRpcOperation(connectedSession(rpcSuccess(rows)), workspaceListOrThrow, {}) + ).resolves.toEqual(rows) + }) + + it('returns the reply itself only when it opened a stream', async () => { + const opener = rpcSuccess({ subscriptionId: 's1' }, true) + await expect( + runRpcOperation(connectedSession(opener), terminalStreamOpener, { terminal: 't1' }) + ).resolves.toBe(opener) + await expect( + runRpcOperation( + connectedSession(rpcSuccess({ subscriptionId: 's1' })), + terminalStreamOpener, + { + terminal: 't1' + } + ) + ).resolves.toBeNull() + await expect( + runRpcOperation(connectedSession(rpcRefusal('runtime_error')), terminalStreamOpener, { + terminal: 't1' + }) + ).resolves.toBeNull() + }) +}) + +describe('an incompatible reply', () => { + const incompatible = rpcSuccess({ worktrees: [{ id: 7 }] }) + + it('throws a named incompatible-reply error for the throwing family', async () => { + const caught = await runRpcOperation( + connectedSession(incompatible), + workspaceListOrThrow, + {} + ).catch((thrown: unknown) => thrown) + + expect(caught).toBeInstanceOf(RpcIncompatibleReplyError) + expect(isRpcIncompatibleReplyError(caught)).toBe(true) + expect((caught as RpcIncompatibleReplyError).method).toBe('worktree.ps') + expect((caught as RpcIncompatibleReplyError).operationName).toBe('test.workspaceListOrThrow') + expect((caught as RpcIncompatibleReplyError).issues.length).toBeGreaterThan(0) + }) + + // A reply nobody can read says nothing about what the host did, so it must not look like + // either of the two errors the mutation retry loops replay on. + it('authorizes no retry', async () => { + const caught = await runRpcOperation( + connectedSession(incompatible), + workspaceListOrThrow, + {} + ).catch((thrown: unknown) => thrown) + + expect(isRpcDeliveryUnknown(caught)).toBe(false) + expect(isLogicalClientCutoverError(caught)).toBe(false) + }) + + it('answers null for the nullable family', async () => { + await expect( + runRpcOperation(connectedSession(incompatible), workspaceListOrNull, {}) + ).resolves.toBeNull() + }) +}) + +describe('a descriptor', () => { + it('cannot have its policy or barrier swapped at runtime', () => { + expect(Object.isFrozen(workspaceListOrThrow)).toBe(true) + expect(() => { + ;(workspaceListOrThrow as { acceptance: string }).acceptance = 'object-result-or-null' + }).toThrow(TypeError) + expect(() => { + ;(workspaceListOrThrow as { barrier: string }).barrier = 'after-all-requests' + }).toThrow(TypeError) + }) +}) diff --git a/mobile/src/transport/rpc-operation.ts b/mobile/src/transport/rpc-operation.ts new file mode 100644 index 00000000000..def072dac81 --- /dev/null +++ b/mobile/src/transport/rpc-operation.ts @@ -0,0 +1,282 @@ +import type { UnvalidatedRpcRequestPort, SendRequestOptions } from './unvalidated-rpc-request-port' +import type { RpcMethodName, RpcSendParams } from './rpc-params-contract' +import type { RpcResponse } from './types' +import { + isMethodNotFoundRefusal, + isStreamingOpenerReply, + requireRpcResultOrThrowCodedError, + rpcObjectResultOrNull +} from './rpc-acceptance-policies' +import { RpcIncompatibleReplyError } from './rpc-incompatible-reply-error' +import type { + AnyRpcOperation, + CapabilityProbeRpcDefinition, + ObjectResultRpcDefinition, + RpcAcceptanceName, + RpcCompatibleReader, + RpcDecodeIssue, + RpcInterpretationBarrier, + RpcOperation, + RpcOperationSettlement, + RpcRequestOutcome, + RpcSalvageReport, + RequireResultRpcDefinition, + StreamOpenerRpcDefinition, + RpcVerdict +} from './rpc-operation-contract' + +const NOTHING_SALVAGED: RpcSalvageReport = { droppedPaths: [], droppedCount: 0 } + +type RpcOperationDefinitionInput = + | RequireResultRpcDefinition + | ObjectResultRpcDefinition + | CapabilityProbeRpcDefinition + | StreamOpenerRpcDefinition + +export function defineRpcOperation< + Method extends RpcMethodName, + Variant extends string, + Value, + Barrier extends RpcInterpretationBarrier +>( + definition: RequireResultRpcDefinition +): RpcOperation +export function defineRpcOperation< + Method extends RpcMethodName, + Variant extends string, + Value, + Barrier extends RpcInterpretationBarrier +>( + definition: ObjectResultRpcDefinition +): RpcOperation +export function defineRpcOperation< + Method extends RpcMethodName, + Barrier extends RpcInterpretationBarrier +>( + definition: CapabilityProbeRpcDefinition +): RpcOperation +export function defineRpcOperation< + Method extends RpcMethodName, + Barrier extends RpcInterpretationBarrier +>( + definition: StreamOpenerRpcDefinition +): RpcOperation +export function defineRpcOperation(definition: RpcOperationDefinitionInput): AnyRpcOperation { + // Why: frozen so no call site can swap the policy or the barrier on a shared descriptor. + return Object.freeze({ + name: definition.name, + method: definition.method, + acceptance: definition.acceptance, + barrier: definition.barrier, + // Why: classifyReply only ever hands a reader the payload its own policy admitted, so + // the object policy's narrower parameter is sound to store as unknown. + read: definition.read as RpcCompatibleReader | undefined + }) +} + +/** Sends the operation without interpreting it; transport rejection stays on the promise. */ +async function request( + client: UnvalidatedRpcRequestPort, + operation: AnyRpcOperation, + params: unknown, + options?: SendRequestOptions +): Promise> { + // Why: no try/catch here. A transport failure must reach the caller as the original error + // object — isLogicalClientCutoverError and isRpcDeliveryUnknown both die on a wrapper — + // and an always-settled send would make Promise.all wait for a peer where today the group + // fails immediately, letting a later policy surface a different error. + const response = await client.sendRequest(operation.method, params, options) + return classifyReply(operation, response) +} + +type AdmittedPayload = + | { readonly admitted: true; readonly value: unknown } + | { readonly admitted: false; readonly issues: readonly RpcDecodeIssue[] } + +// The payload the operation's own acceptance policy admits from a fulfilled success. +function admitPayload(operation: AnyRpcOperation, response: RpcResponse): AdmittedPayload { + switch (operation.acceptance) { + case 'object-result-or-null': { + const object = rpcObjectResultOrNull(response) + return object === null + ? { admitted: false, issues: [{ path: 'result', message: 'not a non-null object' }] } + : { admitted: true, value: object } + } + case 'streaming-opener': + return isStreamingOpenerReply(response) + ? { admitted: true, value: response } + : { admitted: false, issues: [{ path: 'streaming', message: 'reply opened no stream' }] } + default: + // Reuses the policy rather than reading `.result` again; a success never throws here. + return { admitted: true, value: requireRpcResultOrThrowCodedError(response) } + } +} + +const READERLESS_VARIANTS: Record = { + 'method-not-found-refusal': 'accepted', + 'streaming-opener': 'stream-opened' +} + +function classifyReply( + operation: AnyRpcOperation, + response: RpcResponse +): RpcRequestOutcome { + if (!response.ok) { + return { kind: 'outer-refused', error: response.error, raw: response } + } + const payload = admitPayload(operation, response) + if (!payload.admitted) { + return { kind: 'incompatible', raw: response, issues: payload.issues } + } + const read = operation.read + if (!read) { + return { + kind: 'decoded', + variant: READERLESS_VARIANTS[operation.acceptance] ?? 'accepted', + value: payload.value, + raw: response, + salvage: NOTHING_SALVAGED + } + } + let result: ReturnType + try { + result = read(payload.value) + } catch (error) { + // A reader that throws is an incompatible reply, never a transport failure. + return { + kind: 'incompatible', + raw: response, + issues: [{ path: '', message: error instanceof Error ? error.message : String(error) }] + } + } + if (!result.compatible) { + return { kind: 'incompatible', raw: response, issues: result.issues } + } + return { + kind: 'decoded', + variant: result.variant, + value: result.value, + raw: response, + salvage: result.salvage + } +} + +// Applies the operation's declared acceptance policy. Private on purpose: there is no +// free-standing callOrThrow, so no call site can pick a different rule for the same reply. +function interpret( + operation: AnyRpcOperation, + settled: RpcRequestOutcome +): unknown { + const acceptance: RpcAcceptanceName = operation.acceptance + switch (acceptance) { + case 'require-result-or-throw': + if (settled.kind === 'outer-refused') { + // Reuses the policy so the thrown `code: message` text cannot drift from main's. + return requireRpcResultOrThrowCodedError(settled.raw) + } + if (settled.kind === 'incompatible') { + throw new RpcIncompatibleReplyError(operation.name, operation.method, settled.issues) + } + return settled.value + case 'object-result-or-null': + return settled.kind === 'decoded' ? settled.value : null + case 'method-not-found-refusal': + return settled.kind === 'outer-refused' ? isMethodNotFoundRefusal(settled.raw) : false + case 'streaming-opener': + return settled.kind === 'decoded' && isStreamingOpenerReply(settled.raw) ? settled.raw : null + } +} + +function interpretSettlement( + operation: AnyRpcOperation, + settlement: RpcOperationSettlement +): unknown { + if (settlement.status === 'rejected') { + // Why: rethrow the original object — isRpcDeliveryUnknown is a WeakSet on identity and + // isLogicalClientCutoverError matches class or exact message; a wrapper loses both. + throw settlement.error + } + return interpret(operation, settlement.outcome) +} + +/** Sends and interprets at the operation's own barrier. Only for barrier 'on-settle'. */ +export async function runRpcOperation< + Method extends RpcMethodName, + Acceptance extends RpcAcceptanceName, + Variant extends string, + Value +>( + client: UnvalidatedRpcRequestPort, + operation: RpcOperation, + params: RpcSendParams, + options?: SendRequestOptions +): Promise> { + const outcome = await request(client, operation, params, options) + return interpret(operation, outcome) as RpcVerdict +} + +/** The named opt-in to all-settled semantics. Yields an outcome, never a verdict: the + * verdict still comes only from the declared policy, at the declared barrier. */ +export async function captureRpcOperationSettlement< + Method extends RpcMethodName, + Acceptance extends RpcAcceptanceName, + Variant extends string, + Value, + Barrier extends RpcInterpretationBarrier +>( + client: UnvalidatedRpcRequestPort, + operation: RpcOperation, + params: RpcSendParams, + options?: SendRequestOptions +): Promise> { + try { + const outcome = await request(client, operation, params, options) + return { status: 'fulfilled', outcome: outcome as RpcRequestOutcome } + } catch (error) { + return { status: 'rejected', error } + } +} + +export type PendingRpcOperation = { + readonly operation: Op + readonly settlement: Promise> +} + +/** Starts a request whose interpretation is deferred to the barrier it declared. */ +export function startRpcOperation< + Method extends RpcMethodName, + Acceptance extends RpcAcceptanceName, + Variant extends string, + Value +>( + client: UnvalidatedRpcRequestPort, + operation: RpcOperation, + params: RpcSendParams, + options?: SendRequestOptions +): PendingRpcOperation> { + return { + operation, + settlement: captureRpcOperationSettlement(client, operation, params, options) + } +} + +type RpcBarrierVerdicts[]> = { + [Index in keyof Pending]: Pending[Index] extends PendingRpcOperation< + RpcOperation + > + ? RpcVerdict + : never +} + +/** Awaits every raw request, then interprets in declared order. */ +export async function interpretAtRpcBarrier< + Pending extends readonly PendingRpcOperation[] +>(pending: Pending): Promise> { + // Why: interpreting as each request lands would let whichever peer failed first decide the + // error the user sees and how long the screen spins. Declared order makes that a property + // of the definition instead of a race. + const settlements = await Promise.all(pending.map((entry) => entry.settlement)) + return pending.map((entry, index) => + interpretSettlement(entry.operation, settlements[index]) + ) as RpcBarrierVerdicts +} diff --git a/mobile/src/transport/rpc-params-contract.ts b/mobile/src/transport/rpc-params-contract.ts index fcf3303e965..883a921c079 100644 --- a/mobile/src/transport/rpc-params-contract.ts +++ b/mobile/src/transport/rpc-params-contract.ts @@ -2,7 +2,11 @@ // The schemas behind these types must never reach the bundle: requiredString is // z.unknown().transform(...), so a client-side parse coerces a non-string to '' // instead of rejecting it, silently changing the bytes on the wire. +// +// RpcSendParams is the outgoing type; RpcParams is the shape the handler sees after +// parsing, which is not what a sender may write (see rpc-send-params.ts). export type { RpcMethodName, RpcParams } from '../../../src/shared/rpc-contract/rpc-params-catalog.generated' +export type { RpcSendParams } from '../../../src/shared/rpc-contract/rpc-send-params' diff --git a/mobile/src/transport/unvalidated-rpc-request-port-boundary.test.ts b/mobile/src/transport/unvalidated-rpc-request-port-boundary.test.ts new file mode 100644 index 00000000000..422b2925689 --- /dev/null +++ b/mobile/src/transport/unvalidated-rpc-request-port-boundary.test.ts @@ -0,0 +1,267 @@ +import { readFileSync, readdirSync } from 'node:fs' +import { fileURLToPath } from 'node:url' +import { extname, join, relative, resolve } from 'node:path' +import ts from 'typescript' +import { describe, expect, it } from 'vitest' +import { + UNVALIDATED_RPC_REQUEST_PORT_OWNERS, + UNVALIDATED_RPC_REQUEST_PORT_PENDING, + type UnvalidatedRpcRequestPortEntry +} from './unvalidated-rpc-request-port-inventory' + +/** + * Ratchet for the raw RPC request port. + * + * `sendRequest` takes an unchecked method string and returns an envelope whose `result` is + * `unknown`. Every screen that reaches it re-decides acceptance and decoding for itself, which + * is the drift the RpcOperation contract exists to end. The port cannot be made unreachable by + * the type system today: `RpcClient` structurally carries it, and ~190 files hold a client. So + * the boundary is held as an inventory instead, and this test is what makes the inventory bind. + * + * Three failures, all of which mean "edit the list": + * - a file reaches the port and is on neither list, + * - a listed file no longer reaches it (stale entry — how allow-lists rot), + * - a listed file's reference count went up. + * + * What this does NOT catch, all accepted: + * - Reach laundered through a function type. A listed file can hand `client.sendRequest` to an + * unlisted one as a bare `(method: string) => Promise` and the receiver never + * names the port. Only two senders are named here; a third wrapper needs adding by hand. + * - Computed access — `client['send' + 'Request']` is not a literal in the AST. + * - Which method a listed file sends, or what it does with the reply. The count is a ceiling + * on how many times it reaches, nothing more. + * - Test files. `*.test.ts(x)` is not scanned: faking the port is how these suites work, and a + * test does not ship. A non-test file that fakes it (tsconfig excludes tests, so some do) is + * scanned and listed. + * A compile-time fence would catch the first two. That needs `RpcClient` to stop carrying the + * port, which needs the call sites migrated first — the thing this list is counting down. + */ + +const mobileRoot = fileURLToPath(new URL('../..', import.meta.url)) +const scannedRoots = ['app', 'src'].map((directory) => join(mobileRoot, directory)) +const sourceExtensions = new Set(['.js', '.jsx', '.ts', '.tsx']) +const portModule = join(mobileRoot, 'src', 'transport', 'unvalidated-rpc-request-port') + +/** The port and its own inventory are not offenders; the ratchet does not police itself. */ +const SELF_FILES = new Set([ + 'src/transport/unvalidated-rpc-request-port.ts', + 'src/transport/unvalidated-rpc-request-port-inventory.ts' +]) + +/** The coalescing second sender: same unchecked string in, same unread envelope out. */ +const SECOND_SENDER = 'sendSingleFlightRequest' + +function sourceFiles(directory: string): string[] { + return readdirSync(directory, { withFileTypes: true }).flatMap((entry) => { + const path = join(directory, entry.name) + if (entry.isDirectory()) { + return entry.name === 'node_modules' ? [] : sourceFiles(path) + } + return [path] + }) +} + +function parse(path: string, source: string): ts.SourceFile { + const extension = extname(path) + return ts.createSourceFile( + path, + source, + ts.ScriptTarget.Latest, + true, + extension === '.tsx' || extension === '.jsx' ? ts.ScriptKind.TSX : ts.ScriptKind.TS + ) +} + +function targetsPortModule(path: string, node: ts.Node | undefined): boolean { + if (!node || !ts.isStringLiteral(node) || !node.text.startsWith('.')) { + return false + } + return resolve(path, '..', node.text) === portModule +} + +/** `client['sendRequest']` is one reach, not two: the element access already counted it. */ +function isCountedElementAccessArgument(node: ts.Node): boolean { + const parent: ts.Node | undefined = node.parent + return ( + parent !== undefined && + ts.isElementAccessExpression(parent) && + parent.argumentExpression === node + ) +} + +function declaresPortMember(node: ts.Node): boolean { + if ( + !ts.isPropertySignature(node) && + !ts.isMethodSignature(node) && + !ts.isMethodDeclaration(node) && + !ts.isPropertyDeclaration(node) && + !ts.isPropertyAssignment(node) && + !ts.isShorthandPropertyAssignment(node) + ) { + return false + } + const name = node.name + return (ts.isIdentifier(name) || ts.isStringLiteral(name)) && name.text === 'sendRequest' +} + +/** How many times this file reaches the raw port directly. Comments never count: this is AST. */ +export function rawRequestPortReferences(path: string, source: string): number { + let references = 0 + const visit = (node: ts.Node): void => { + if ( + (ts.isPropertyAccessExpression(node) && node.name.text === 'sendRequest') || + (ts.isElementAccessExpression(node) && + ts.isStringLiteral(node.argumentExpression) && + node.argumentExpression.text === 'sendRequest') || + declaresPortMember(node) || + (ts.isStringLiteral(node) && + node.text === 'sendRequest' && + !isCountedElementAccessArgument(node)) || + (ts.isIdentifier(node) && node.text === SECOND_SENDER) + ) { + references += 1 + } + if (ts.isImportDeclaration(node) || ts.isExportDeclaration(node)) { + references += targetsPortModule(path, node.moduleSpecifier) ? 1 : 0 + } + if ( + ts.isCallExpression(node) && + (node.expression.kind === ts.SyntaxKind.ImportKeyword || + (ts.isIdentifier(node.expression) && node.expression.text === 'require')) && + targetsPortModule(path, node.arguments[0]) + ) { + references += 1 + } + ts.forEachChild(node, visit) + } + visit(parse(path, source)) + return references +} + +const inventory: readonly UnvalidatedRpcRequestPortEntry[] = [ + ...UNVALIDATED_RPC_REQUEST_PORT_OWNERS, + ...UNVALIDATED_RPC_REQUEST_PORT_PENDING +] + +const scanned = scannedRoots + .flatMap(sourceFiles) + .filter((path) => sourceExtensions.has(extname(path))) + .filter((path) => !/\.test\.tsx?$/.test(path)) + .map((path) => relative(mobileRoot, path).split(/[/\\]/).join('/')) + .filter((file) => !SELF_FILES.has(file)) + +const observed = new Map( + scanned + .map( + (file) => + [ + file, + rawRequestPortReferences( + join(mobileRoot, file), + readFileSync(join(mobileRoot, file), 'utf8') + ) + ] as const + ) + .filter(([, references]) => references > 0) +) + +describe('unvalidated RPC request port boundary', () => { + const probe = join(mobileRoot, 'src', 'transport', 'probe.ts') + + it('counts every shape that reaches the port', () => { + expect(rawRequestPortReferences(probe, 'await client.sendRequest("worktree.ps", {})')).toBe(1) + expect(rawRequestPortReferences(probe, 'const send = client.sendRequest')).toBe(1) + expect(rawRequestPortReferences(probe, 'client["sendRequest"]("x")')).toBe(1) + expect(rawRequestPortReferences(probe, "type A = Pick")).toBe(1) + expect(rawRequestPortReferences(probe, "type A = RpcClient['sendRequest']")).toBe(1) + expect(rawRequestPortReferences(probe, 'const c = { sendRequest: async () => reply }')).toBe(1) + expect(rawRequestPortReferences(probe, 'interface C { sendRequest(m: string): void }')).toBe(1) + expect(rawRequestPortReferences(probe, "if (name === 'sendRequest') { }")).toBe(1) + expect( + rawRequestPortReferences(probe, 'await sendSingleFlightRequest(c, h, "worktree.ps")') + ).toBe(1) + expect( + rawRequestPortReferences( + probe, + "import { sendSingleFlightRequest } from './request-single-flight'" + ) + ).toBe(1) + expect( + rawRequestPortReferences( + probe, + "import type { UnvalidatedRpcRequestPort } from './unvalidated-rpc-request-port'" + ) + ).toBe(1) + expect( + rawRequestPortReferences( + probe, + "export type { SendRequestOptions } from './unvalidated-rpc-request-port'" + ) + ).toBe(1) + expect( + rawRequestPortReferences(probe, "const m = await import('./unvalidated-rpc-request-port')") + ).toBe(1) + expect(rawRequestPortReferences(probe, 'a.sendRequest(1); b.sendRequest(2)')).toBe(2) + }) + + it('does not count prose or an unrelated sender', () => { + expect(rawRequestPortReferences(probe, '// calls sendRequest under the hood')).toBe(0) + expect(rawRequestPortReferences(probe, '/* sendRequest */ export const x = 1')).toBe(0) + expect(rawRequestPortReferences(probe, 'await client.subscribe("terminal.stream", {})')).toBe(0) + expect(rawRequestPortReferences(probe, "import type { RpcClient } from './rpc-client'")).toBe(0) + expect(rawRequestPortReferences(probe, 'await runRpcOperation(client, op, {})')).toBe(0) + }) + + it('scans a plausible number of files', () => { + // A broken root or extension filter would make every check below vacuously pass. + expect(scanned.length).toBeGreaterThan(400) + expect(observed.size).toBeGreaterThan(50) + }) + + it('lists each file once', () => { + const seen = inventory.map((entry) => entry.file) + expect(seen.filter((file, index) => seen.indexOf(file) !== index)).toEqual([]) + }) + + it('has no unlisted file reaching the raw request port', () => { + const listed = new Set(inventory.map((entry) => entry.file)) + const unlisted = [...observed.keys()].filter((file) => !listed.has(file)) + expect( + unlisted, + 'New code must send through an RpcOperation. Nothing may be added to unvalidated-rpc-request-port-inventory.ts.' + ).toEqual([]) + }) + + it('has no stale inventory entry', () => { + const stale = inventory.filter((entry) => !observed.has(entry.file)) + expect( + stale.map((entry) => entry.file), + 'File no longer reaches the raw port — delete its line from unvalidated-rpc-request-port-inventory.ts.' + ).toEqual([]) + }) + + it('has no inventory entry whose file gained references', () => { + const grown = inventory + .filter((entry) => (observed.get(entry.file) ?? 0) > entry.references) + .map( + (entry) => `${entry.file}: listed ${entry.references}, found ${observed.get(entry.file)}` + ) + expect(grown, 'The counts are a ceiling. Send the new call through an RpcOperation.').toEqual( + [] + ) + }) + + it('reports a count that has fallen so the entry can be lowered', () => { + const overstated = inventory + .filter( + (entry) => observed.has(entry.file) && (observed.get(entry.file) ?? 0) < entry.references + ) + .map( + (entry) => `${entry.file}: listed ${entry.references}, found ${observed.get(entry.file)}` + ) + expect( + overstated, + 'Fewer references than listed — lower the count so the ratchet holds.' + ).toEqual([]) + }) +}) diff --git a/mobile/src/transport/unvalidated-rpc-request-port-inventory.ts b/mobile/src/transport/unvalidated-rpc-request-port-inventory.ts new file mode 100644 index 00000000000..82317f272fc --- /dev/null +++ b/mobile/src/transport/unvalidated-rpc-request-port-inventory.ts @@ -0,0 +1,229 @@ +/** + * Every file that still reaches mobile's raw RPC request port, held as data. + * + * A reference is any direct reach for the port: a `.sendRequest` access or declaration, a + * `'sendRequest'` selector such as `Pick`, a call to the coalescing + * second sender `sendSingleFlightRequest`, or an import of unvalidated-rpc-request-port.ts. + * The count is per file and is a ceiling, not a target: unvalidated-rpc-request-port-boundary.test.ts + * fails on a file that is not listed, on a listed file that no longer reaches the port, and on a + * listed file whose count went up. Both lists only shrink. + * + * The owners are permanent — they implement, route or validate the port. The pending list is the + * step-4 migration backlog and shares one reason, stated once here instead of 144 times: + * the call site predates the typed contract and still picks its own method string, its own + * acceptance rule and its own decoding. Replacing one with an RpcOperation deletes its line. + */ +export type UnvalidatedRpcRequestPortEntry = { + readonly file: string + readonly references: number +} + +/** Modules whose job is the port. These do not shrink to zero. */ +export const UNVALIDATED_RPC_REQUEST_PORT_OWNERS: readonly UnvalidatedRpcRequestPortEntry[] = [ + // Implements the port over the device-to-host websocket. + { file: 'src/transport/direct-rpc-client.ts', references: 3 }, + // Fakes the port for the supervisor suites; a non-test file only because tsconfig excludes tests. + { file: 'src/transport/mobile-endpoint-supervisor-test-fakes.ts', references: 2 }, + // Implements the port over a relay channel. + { file: 'src/transport/mobile-relay-physical-client.ts', references: 2 }, + // Supplies the port for one relay session. + { file: 'src/transport/mobile-relay-rpc-session.ts', references: 1 }, + // A second raw sender: string method in, unread envelope out. Its callers are fenced too. + { file: 'src/transport/request-single-flight.ts', references: 3 }, + // Owns connect-wait, timeout and replay bookkeeping for every raw request. + { file: 'src/transport/rpc-client-request-tracker.ts', references: 1 }, + // Composes the port into RpcClient, which is why every holder of a client still carries it. + { file: 'src/transport/rpc-client.ts', references: 2 }, + // The typed boundary itself — the one module that turns a reply into a declared type. + { file: 'src/transport/rpc-operation.ts', references: 2 }, + // Forwards the port across a physical-client cutover. + { file: 'src/transport/stable-logical-rpc-client.ts', references: 2 } +] + +/** Call sites awaiting migration to a typed operation. Grouped by the feature area that owns them. */ +export const UNVALIDATED_RPC_REQUEST_PORT_PENDING: readonly UnvalidatedRpcRequestPortEntry[] = [ + // app/h/[hostId]/ — Expo route screens + { file: 'app/h/[hostId]/accounts.tsx', references: 2 }, + + // app/ — Expo route screens + { file: 'app/terminal-settings.tsx', references: 3 }, + + // src/agent-history/ — agent history loads + { file: 'src/agent-history/MobileAgentSessionHistoryPanel.tsx', references: 7 }, + { file: 'src/agent-history/use-mobile-agent-history-state.ts', references: 2 }, + + // src/browser/ — hosted browser control + { file: 'src/browser/use-mobile-browser-commands.ts', references: 5 }, + { file: 'src/browser/use-mobile-browser-request.ts', references: 1 }, + + // src/components/ — shared widgets that fetch their own data + { file: 'src/components/codex-reset-credit-capability.ts', references: 2 }, + { file: 'src/components/codex-reset-credit.ts', references: 3 }, + { file: 'src/components/use-new-workspace-create-submit.ts', references: 1 }, + { file: 'src/components/use-new-workspace-execution-target.ts', references: 4 }, + { file: 'src/components/use-new-workspace-repositories.ts', references: 1 }, + { file: 'src/components/use-new-workspace-runtime-context.ts', references: 4 }, + { file: 'src/components/use-new-workspace-setup-script.ts', references: 1 }, + + // src/dictation/ — dictation session control + { file: 'src/dictation/mobile-dictation-setup.ts', references: 10 }, + + // src/files/ — file read, write and preview + { file: 'src/files/mobile-file-mutation-ownership.ts', references: 3 }, + { file: 'src/files/mobile-file-preview-request.ts', references: 6 }, + { file: 'src/files/mobile-file-tab-doc.ts', references: 4 }, + { file: 'src/files/mobile-terminal-artifact-grant-refresh.ts', references: 2 }, + { file: 'src/files/MobileFileExplorerPanel.tsx', references: 2 }, + + // src/home/ — home screen host reads + { file: 'src/home/mobile-home-host-requests.ts', references: 6 }, + + // src/hooks/ — cross-screen data hooks + { file: 'src/hooks/mobile-dictation-audio-chunk.ts', references: 1 }, + { file: 'src/hooks/mobile-dictation-desktop-start.ts', references: 4 }, + { file: 'src/hooks/use-mobile-dictation.ts', references: 4 }, + + // src/host-screen/ — host screen catalog and actions + { file: 'src/host-screen/host-screen-overlays.tsx', references: 1 }, + { file: 'src/host-screen/use-host-repo-metadata.ts', references: 2 }, + { file: 'src/host-screen/use-host-view-settings.ts', references: 2 }, + { file: 'src/host-screen/use-host-worktree-actions.ts', references: 3 }, + + // src/notifications/ — push registration and delivery + { file: 'src/notifications/mobile-notifications.ts', references: 1 }, + { file: 'src/notifications/push-dismissal-reconciliation.ts', references: 2 }, + { file: 'src/notifications/push-registration.ts', references: 3 }, + + // src/session/ — session screen: chat, diff review, PR actions, tabs + { file: 'src/session/ai-vault-resume-launch.ts', references: 3 }, + { file: 'src/session/ai-vault-resume-preparation.ts', references: 2 }, + { file: 'src/session/github-pr-mutations.ts', references: 16 }, + { file: 'src/session/github-pr-rpc.ts', references: 9 }, + { file: 'src/session/mobile-clipboard-image.ts', references: 7 }, + { file: 'src/session/mobile-diff-review-loaders.ts', references: 5 }, + { file: 'src/session/mobile-file-tap-open.ts', references: 3 }, + { file: 'src/session/mobile-image-attachment.ts', references: 2 }, + { file: 'src/session/mobile-native-chat-image-attachment.ts', references: 1 }, + { file: 'src/session/mobile-native-chat-image-send.ts', references: 2 }, + { file: 'src/session/mobile-native-chat-send.ts', references: 2 }, + { file: 'src/session/mobile-native-chat-session-option-persistence.ts', references: 1 }, + { file: 'src/session/mobile-native-chat-stale-input.ts', references: 1 }, + { file: 'src/session/mobile-new-tab-agent-loader.ts', references: 5 }, + { file: 'src/session/mobile-session-tab-activation.ts', references: 3 }, + { file: 'src/session/mobile-session-tabs-stream-health.ts', references: 1 }, + { file: 'src/session/mobile-structured-agent-session-launch.ts', references: 3 }, + { file: 'src/session/mobile-structured-agent-session-rpc.ts', references: 1 }, + { file: 'src/session/pr-ai-triage-launch.ts', references: 3 }, + { file: 'src/session/use-live-worktree-name.ts', references: 1 }, + { file: 'src/session/use-mobile-diff-review-comment-actions.ts', references: 1 }, + { file: 'src/session/use-mobile-diff-review-git-actions.ts', references: 2 }, + { file: 'src/session/use-mobile-diff-review-interactions.ts', references: 1 }, + { file: 'src/session/use-mobile-diff-review-send-actions.ts', references: 3 }, + { file: 'src/session/use-mobile-file-tap-handlers.ts', references: 1 }, + { file: 'src/session/use-mobile-native-chat-file-search.ts', references: 2 }, + { file: 'src/session/use-mobile-native-chat-readability.ts', references: 1 }, + { file: 'src/session/use-mobile-native-chat-session.ts', references: 1 }, + { file: 'src/session/use-mobile-native-chat-stop.ts', references: 1 }, + { file: 'src/session/use-mobile-pr-actions.ts', references: 1 }, + { file: 'src/session/use-mobile-pr-branch-context.ts', references: 2 }, + { file: 'src/session/use-mobile-pr-comment-actions.ts', references: 1 }, + { file: 'src/session/use-mobile-pr-title-action.ts', references: 1 }, + { file: 'src/session/use-mobile-session-accessory-selection.ts', references: 1 }, + { file: 'src/session/use-mobile-session-close-actions.ts', references: 3 }, + { file: 'src/session/use-mobile-session-content-create-actions.ts', references: 4 }, + { file: 'src/session/use-mobile-session-diff-comments.ts', references: 2 }, + { file: 'src/session/use-mobile-session-document-readers.ts', references: 2 }, + { file: 'src/session/use-mobile-session-markdown-actions.ts', references: 1 }, + { file: 'src/session/use-mobile-session-startup.ts', references: 2 }, + { file: 'src/session/use-mobile-session-terminal-create-actions.ts', references: 2 }, + { file: 'src/session/use-mobile-session-terminal-input.ts', references: 2 }, + { file: 'src/session/use-mobile-session-terminal-list.ts', references: 1 }, + { file: 'src/session/use-mobile-session-terminal-send-actions.ts', references: 2 }, + { file: 'src/session/use-mobile-session-terminal-stream-display.ts', references: 1 }, + { file: 'src/session/use-mobile-terminal-paste.ts', references: 1 }, + { file: 'src/session/use-pr-bot-author-overrides.ts', references: 1 }, + { file: 'src/session/use-quick-commands.ts', references: 2 }, + + // src/settings/ — settings screen actions + { file: 'src/settings/native-voice-settings-operations.ts', references: 1 }, + + // src/settings/ — notification display probe + { file: 'src/settings/notification-display-test.tsx', references: 1 }, + + // src/source-control/ — source control: review, commit, branch + { file: 'src/source-control/mobile-branch-base-ref.ts', references: 3 }, + { file: 'src/source-control/mobile-commit-message-ai.ts', references: 4 }, + { file: 'src/source-control/mobile-git-history.ts', references: 2 }, + { file: 'src/source-control/mobile-hosted-review-create-intent-runner.ts', references: 1 }, + { file: 'src/source-control/mobile-hosted-review-create-intent.ts', references: 3 }, + { file: 'src/source-control/mobile-hosted-review-git-preparation.ts', references: 6 }, + { file: 'src/source-control/mobile-hosted-review-remote-prerequisite.ts', references: 1 }, + { file: 'src/source-control/mobile-hosted-review-service.ts', references: 8 }, + { file: 'src/source-control/mobile-pr-link.ts', references: 8 }, + { file: 'src/source-control/MobileGitHistoryList.tsx', references: 1 }, + { file: 'src/source-control/reveal-mobile-source-control-session-diff.ts', references: 2 }, + { file: 'src/source-control/use-mobile-git-requests.ts', references: 1 }, + { file: 'src/source-control/use-mobile-source-control-loaders.ts', references: 2 }, + { file: 'src/source-control/use-mobile-source-control-openers.ts', references: 3 }, + + // src/tasks/ — task lists, filters and mutations + { file: 'src/tasks/composer-source-base-resolve.ts', references: 2 }, + { file: 'src/tasks/mobile-tasks-filter-pickers.tsx', references: 1 }, + { file: 'src/tasks/mobile-tasks-source-family.test-support.ts', references: 1 }, + { file: 'src/tasks/setup-hook-trust.ts', references: 1 }, + { file: 'src/tasks/smart-source-paste-intent.ts', references: 4 }, + { file: 'src/tasks/smart-source-search-requests.ts', references: 5 }, + { file: 'src/tasks/use-mobile-tasks-client-settings-actions.tsx', references: 6 }, + { file: 'src/tasks/use-mobile-tasks-github-check-file-actions.tsx', references: 5 }, + { file: 'src/tasks/use-mobile-tasks-github-reply-merge-actions.tsx', references: 5 }, + { file: 'src/tasks/use-mobile-tasks-gitlab-github-status-actions.tsx', references: 3 }, + { file: 'src/tasks/use-mobile-tasks-hosted-comment-review-actions.tsx', references: 4 }, + { file: 'src/tasks/use-mobile-tasks-hosted-metadata-actions.tsx', references: 2 }, + { file: 'src/tasks/use-mobile-tasks-item-detail-loading.tsx', references: 4 }, + { file: 'src/tasks/use-mobile-tasks-item-detail-metadata-effects.tsx', references: 2 }, + { file: 'src/tasks/use-mobile-tasks-linear-item-actions.tsx', references: 3 }, + { file: 'src/tasks/use-mobile-tasks-list-and-detail-effects.tsx', references: 2 }, + { file: 'src/tasks/use-mobile-tasks-project-detail-loading.tsx', references: 1 }, + { file: 'src/tasks/use-mobile-tasks-project-file-merge-actions.tsx', references: 4 }, + { file: 'src/tasks/use-mobile-tasks-project-loading-actions.tsx', references: 4 }, + { file: 'src/tasks/use-mobile-tasks-project-metadata-actions.tsx', references: 3 }, + { file: 'src/tasks/use-mobile-tasks-project-metadata-loading.tsx', references: 3 }, + { file: 'src/tasks/use-mobile-tasks-project-repository-resolution.tsx', references: 1 }, + { file: 'src/tasks/use-mobile-tasks-project-review-check-actions.tsx', references: 4 }, + { file: 'src/tasks/use-mobile-tasks-project-thread-reply-actions.tsx', references: 4 }, + { file: 'src/tasks/use-mobile-tasks-project-workspace-comment-actions.tsx', references: 3 }, + { file: 'src/tasks/use-mobile-tasks-provider-load-actions.tsx', references: 5 }, + { file: 'src/tasks/use-mobile-tasks-route-and-item-state.tsx', references: 1 }, + { file: 'src/tasks/use-mobile-tasks-runtime-hydration.tsx', references: 5 }, + { file: 'src/tasks/use-mobile-tasks-task-create-actions.tsx', references: 3 }, + { file: 'src/tasks/use-mobile-tasks-task-list-loading.tsx', references: 4 }, + { file: 'src/tasks/use-mobile-tasks-task-pagination-actions.tsx', references: 1 }, + { file: 'src/tasks/use-mobile-tasks-workspace-create-actions.tsx', references: 4 }, + { file: 'src/tasks/use-mobile-tasks-workspace-source-effects.tsx', references: 2 }, + { file: 'src/tasks/use-mobile-tasks-workspace-sparse-actions.tsx', references: 2 }, + { file: 'src/tasks/use-mobile-tasks-workspace-ssh-state.tsx', references: 5 }, + { file: 'src/tasks/worktree-create-capability.ts', references: 1 }, + { file: 'src/tasks/worktree-create-retry.ts', references: 1 }, + + // src/terminal/ — terminal input, viewport and queries + { file: 'src/terminal/mobile-terminal-query-reply.ts', references: 2 }, + { file: 'src/terminal/terminal-live-accessory-raw-send.ts', references: 2 }, + { file: 'src/terminal/terminal-viewport-refit.ts', references: 1 }, + { file: 'src/terminal/worker-terminal-takeover-report.ts', references: 2 }, + + // src/transport/ — pairing, endpoint probing and capability reads + { file: 'src/transport/host-status-gates.ts', references: 1 }, + { file: 'src/transport/mobile-relay-credential-rotation.ts', references: 2 }, + { file: 'src/transport/mobile-relay-direct-upgrade.ts', references: 2 }, + { file: 'src/transport/mobile-relay-pairing-recovery.ts', references: 2 }, + { file: 'src/transport/mobile-runtime-capability-negotiation.ts', references: 2 }, + { file: 'src/transport/pairing-candidate-race.ts', references: 1 }, + { file: 'src/transport/pairing-relay-candidate.ts', references: 4 }, + { file: 'src/transport/pre-profile-pairing-coordinator.ts', references: 2 }, + { file: 'src/transport/runtime-capability-probe.ts', references: 2 }, + + // src/worktree/ — worktree activation and resume + { file: 'src/worktree/home-host-worktree-fetch.ts', references: 2 }, + { file: 'src/worktree/use-retired-worktree-names.ts', references: 1 }, + { file: 'src/worktree/worktree-catalog-snapshot-client.ts', references: 1 } +] diff --git a/mobile/src/transport/unvalidated-rpc-request-port.ts b/mobile/src/transport/unvalidated-rpc-request-port.ts new file mode 100644 index 00000000000..b626b290da6 --- /dev/null +++ b/mobile/src/transport/unvalidated-rpc-request-port.ts @@ -0,0 +1,31 @@ +import type { RpcResponse } from './types' + +// The raw request port, kept in its own module so that reaching it is a visible act. +// +// Nothing on this path is checked against the host contract: `method` is an unconstrained +// string, `params` is `unknown`, and the reply's `result` stays `unknown`. A value that came +// back through here has been parsed as JSON and nothing more, so it is NOT validated and must +// not be annotated as though it were. The typed boundary — defineRpcOperation and the send +// helpers in rpc-operation.ts — is the only path that turns a reply into a declared type, and +// rpc-operation.ts is the only module here that should be importing this one for that purpose. +// +// Every other file that still reaches this port is inventoried in +// unvalidated-rpc-request-port-inventory.ts and fenced by +// unvalidated-rpc-request-port-boundary.test.ts. That list only shrinks. + +export type SendRequestOptions = { + timeoutMs?: number + /** Include the connect wait in the caller's timeout budget. */ + budgetSpansConnect?: boolean + /** Reject instead of replaying the request after reconnect. */ + failWhenDisconnected?: boolean +} + +/** Unvalidated: an arbitrary method name in, an unread envelope out. */ +export type UnvalidatedRpcRequestPort = { + sendRequest: ( + method: string, + params?: unknown, + options?: SendRequestOptions + ) => Promise +} diff --git a/src/shared/rpc-contract/rpc-params-catalog.generated.ts b/src/shared/rpc-contract/rpc-params-catalog.generated.ts index 6adf1851d2f..57ed4a5b5a5 100644 --- a/src/shared/rpc-contract/rpc-params-catalog.generated.ts +++ b/src/shared/rpc-contract/rpc-params-catalog.generated.ts @@ -1159,9 +1159,10 @@ export const RPC_METHODS_WITHOUT_SHARED_PARAMS: readonly string[] = [ export type RpcMethodName = keyof typeof RPC_PARAMS_BY_METHOD -// Why: z.output is the post-parse shape the handler receives. z.input is not a -// send-side type here — requiredString is z.unknown().transform(...), so its input -// admits any value and loses optional/default semantics. +// Why: z.output is the post-parse shape the handler receives, which is not what a +// client may send — a .default() field reads as required. z.input is not the answer +// either: requiredString is z.unknown().transform(...), so its input admits any value. +// Senders use RpcSendParams from ./rpc-send-params, which is derived from this map. export type RpcParams = (typeof RPC_PARAMS_BY_METHOD)[Method] extends z.ZodType ? z.output<(typeof RPC_PARAMS_BY_METHOD)[Method]> diff --git a/src/shared/rpc-contract/rpc-send-params.ts b/src/shared/rpc-contract/rpc-send-params.ts new file mode 100644 index 00000000000..fcb6d66a359 --- /dev/null +++ b/src/shared/rpc-contract/rpc-send-params.ts @@ -0,0 +1,69 @@ +import type { z } from 'zod' +import type { RPC_PARAMS_BY_METHOD, RpcMethodName } from './rpc-params-catalog.generated' + +// Why this exists: neither of zod's two inferred types describes an outgoing request. +// z.output is what the handler receives *after* parsing, so a `.default(x)` field reads as +// required and a sender that legitimately omits it fails to typecheck. z.input is worse here +// — the params builders parse with z.unknown() so a hostile client cannot crash the +// dispatcher, which collapses every requiredString/OptionalString field to `unknown`. +// +// So take each channel where it is honest: key optionality from zod's own `optin` marker +// (the z.input rule, which is the one that understands .default and .optional), and value +// types from z.output (the post-coercion contract the builders declare in their pipe target). +// Derived from the generated catalog, so it cannot drift from the dispatcher. +// +// Type-level only. Never import the schema *values* into a client: requiredString is +// z.unknown().transform(...), so a client-side parse coerces a non-string to '' instead of +// rejecting it, silently changing the bytes on the wire. + +type Prettify = { [K in keyof T]: T[K] } & {} + +/** zod's own input-side key-optionality rule, copied from $InferObjectInput. */ +type SendOptionalSchema = { _zod: { optin: 'optional' | 'defaulted' } } + +type SendShape = Prettify< + { + -readonly [K in keyof Shape as Shape[K] extends SendOptionalSchema ? never : K]: RpcSendInput< + Shape[K] + > + } & { + -readonly [K in keyof Shape as Shape[K] extends SendOptionalSchema ? K : never]?: RpcSendInput< + Shape[K] + > + } +> + +/** + * The value a sender may put on the wire for one schema. Wrappers not listed here (record, + * tuple, lazy, intersection) fall through to z.output, which is what shipped before. + */ +export type RpcSendInput = + Schema extends z.ZodOptional + ? RpcSendInput | undefined + : Schema extends z.ZodDefault + ? RpcSendInput | undefined + : Schema extends z.ZodPrefault + ? RpcSendInput | undefined + : Schema extends z.ZodNullable + ? RpcSendInput | null + : Schema extends z.ZodArray + ? RpcSendInput[] + : // ZodObject is the only schema carrying a `shape`, and matching on it keeps + // .strict()/.extend()/.superRefine() results in this branch. + Schema extends { shape: infer Shape } + ? keyof Shape extends never + ? // Mirrors $InferObjectOutput: a no-field object admits no properties. + Record + : SendShape + : // ZodDiscriminatedUnion extends ZodUnion, so both land here. + Schema extends z.ZodUnion + ? RpcSendInput + : Schema extends z.ZodType + ? z.output + : never + +/** The params a client may send for `Method`; `void` for the methods that take none. */ +export type RpcSendParams = + (typeof RPC_PARAMS_BY_METHOD)[Method] extends z.ZodType + ? RpcSendInput<(typeof RPC_PARAMS_BY_METHOD)[Method]> + : void