fix: make Browser RPC and Realtime own the physical work they report on

A server stream's registration was pruned on any settled close receipt.
`waitClosed()` rejecting, throwing synchronously, or not returning a
promise at all was absorbed into a fulfilled `undefined`, so the runtime
opened a second physical stream for the same operation while the first was
still running against the server. Only a fulfilled, contract-shaped
receipt confirms closure now; every negative receipt keeps the operation
DRAINING.

Cleanup also read foreign state outside the result boundary. A throwing
iterator `return` accessor replaced the already selected timeout with a
native `TypeError` and skipped the rest of the teardown, and the exported
lease decoder threw on a hostile `Symbol.asyncIterator`. Both reads move
inside their own boundaries, and the positive-close subscription is
installed before any fallible cleanup.

Composition validated the caller's registries before snapshotting them, so
a hostile accessor ran twice during validation, and rows hiding fields
behind a prototype or a non-enumerable key installed. Transport results
were checked for allowed own keys only, so own `{ok,message,encodedBytes}`
plus a prototype `injected` was a success and a missing `message` reached
a permissive schema as `undefined`.

In Realtime the tracked task was registered after the collaborator
returned. An authority that re-entered `close()` from inside its own
invocation saw an empty registry and got `{ok:true}` while its effect was
pending. The task is now registered first and the collaborator is invoked
a microtask later. A `scheduleTimeout` that threw was worse: the caller's
own catch treated it as an apply failure and started a recovery beside the
still-running effect, and `close()` rejected with a native `TypeError`. An
uninstallable deadline now fails closed as an expired one.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
DongHyeonka
2026-08-15 01:25:48 +09:00
co-authored by Claude Opus 5
parent 632b230c82
commit aa8ac35600
6 changed files with 698 additions and 54 deletions
+18 -2
View File
@@ -363,15 +363,23 @@ function installRegistrySnapshot<Value extends object>(
): ReadOnlyRegistry<string, Value> {
let ownKeys: string[];
let symbols: readonly symbol[];
let prototype: object | null;
try {
ownKeys = Object.keys(source);
// RPC-03. Own *names*, not just enumerable keys: a non-enumerable own entry
// is as much a smuggled row as an inherited one, and `Object.keys` never
// saw either.
ownKeys = Object.getOwnPropertyNames(source);
symbols = Object.getOwnPropertySymbols(source);
prototype = Reflect.getPrototypeOf(source);
} catch {
throw new TypeError(`Browser RPC ${label} registry is unreadable.`);
}
if (symbols.length > 0) {
throw new TypeError(`Browser RPC ${label} registry has symbol keys.`);
}
if (prototype !== Object.prototype && prototype !== null) {
throw new TypeError(`Browser RPC ${label} registry has a custom prototype.`);
}
const installed = new Map<string, Value>();
for (const key of ownKeys) {
const descriptor = Object.getOwnPropertyDescriptor(source, key);
@@ -398,15 +406,23 @@ function installRowSnapshot<Value extends object>(
}
let ownKeys: string[];
let symbols: readonly symbol[];
let prototype: object | null;
try {
ownKeys = Object.keys(row);
ownKeys = Object.getOwnPropertyNames(row);
symbols = Object.getOwnPropertySymbols(row);
prototype = Reflect.getPrototypeOf(row);
} catch {
throw new TypeError(`Browser RPC ${label} row is unreadable.`);
}
if (symbols.length > 0) {
throw new TypeError(`Browser RPC ${label} row has symbol keys.`);
}
// RPC-03. A custom prototype carries fields the name sweep never sees and
// stays live after installation, so the installed row would not be the row
// that was checked.
if (prototype !== Object.prototype && prototype !== null) {
throw new TypeError(`Browser RPC ${label} row has a custom prototype.`);
}
const snapshot = Object.create(null) as Record<string, unknown>;
for (const key of ownKeys) {
if (!allowedKeys.includes(key)) {