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:
co-authored by
Claude Opus 5
parent
632b230c82
commit
aa8ac35600
@@ -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)) {
|
||||
|
||||
Reference in New Issue
Block a user