fix: keep Browser RPC collaborator input and output inside the contract

RPC-RR-02. The server-stream path captured the generation fence outside its
protected boundary and raceWithin invoked clock.sleep outside a promise
boundary, so a synchronous throw from either escaped the Result contract and
skipped the listener and timer release. Both now run inside the boundary, and
release moved to finally.

RPC-RR-03. The runtime snapshotted its transports only after validating the
caller's raw objects, which ran their accessors first. It now decodes the
registry from own data descriptors before anything reads it — refusing an
accessor without invoking it and rejecting extra, inherited and symbol-keyed
fields — and validates that snapshot. Every installed binding registry is a
read facade over a private store instead of a frozen Map whose set, delete and
clear still worked.

RPC-RR-04. Transport results and stream frames are decoded per union variant
from own data descriptors into new frozen values. A throwing getter, an
inherited or extra field, a symbol key, an unknown failure code and an
out-of-range retryAfterMs all close as protocol failures instead of escaping.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
DongHyeonka
2026-08-14 14:06:00 +09:00
co-authored by Claude Opus 5
parent 6a8281a941
commit bd90e0c983
3 changed files with 575 additions and 59 deletions
+17 -8
View File
@@ -1,3 +1,7 @@
import {
createReadOnlyRegistry,
type ReadOnlyRegistry,
} from "./read-only-registry.ts";
import type { InstalledBoundaryMapper } from "./boundary-mapper.ts";
import type { RuntimeSchemaCodec } from "./schema-registry.ts";
@@ -327,13 +331,18 @@ export function composeBrowserRpcRequestEncoderRegistry(
);
}
/**
* RPC-RR-03. Read facades, never `Map`s. `Object.freeze(new Map(...))` leaves
* `set`, `delete` and `clear` working, so an installed registry could still be
* emptied or re-pointed after the snapshot was validated.
*/
export type InstalledBrowserRpcContractBindings = Readonly<{
operations: ReadonlyMap<string, BrowserRpcOperationV3>;
profiles: ReadonlyMap<string, BrowserRpcProviderProfile>;
schemaCodecs: ReadonlyMap<string, RuntimeSchemaCodec>;
mappers: ReadonlyMap<string, InstalledBoundaryMapper>;
requestEncoders: ReadonlyMap<string, BrowserRpcRequestEncoder>;
runtimeBindings: ReadonlyMap<string, BrowserRpcRuntimeBindingIdentity>;
operations: ReadOnlyRegistry<string, BrowserRpcOperationV3>;
profiles: ReadOnlyRegistry<string, BrowserRpcProviderProfile>;
schemaCodecs: ReadOnlyRegistry<string, RuntimeSchemaCodec>;
mappers: ReadOnlyRegistry<string, InstalledBoundaryMapper>;
requestEncoders: ReadOnlyRegistry<string, BrowserRpcRequestEncoder>;
runtimeBindings: ReadOnlyRegistry<string, BrowserRpcRuntimeBindingIdentity>;
}>;
/**
@@ -351,7 +360,7 @@ function installRegistrySnapshot<Value extends object>(
source: Readonly<Record<string, Value>>,
label: string,
allowedKeys: readonly string[],
): ReadonlyMap<string, Value> {
): ReadOnlyRegistry<string, Value> {
let ownKeys: string[];
let symbols: readonly symbol[];
try {
@@ -376,7 +385,7 @@ function installRegistrySnapshot<Value extends object>(
installRowSnapshot(descriptor.value as Value, `${label}.${key}`, allowedKeys),
);
}
return Object.freeze(installed) as ReadonlyMap<string, Value>;
return createReadOnlyRegistry(installed);
}
function installRowSnapshot<Value extends object>(