fix: give Browser RPC server streams a cancellable lease and a DRAINING fence
RPC-RR-01. openServerStream returned a bare AsyncIterable, which gave the runtime no way to stop the physical stream or to learn when it actually closed: iterator.return() is a request a non-cooperative implementation may ignore. The runtime could therefore time out, report the call finished, and admit a second stream for the same operation while the first was still running against the server. The transport now returns a lease — streamId, frames, cancel(reason) and waitClosed() — decoded from own data descriptors before the runtime registers it, so an accessor cannot hand the registry one object and the cancellation path another. The runtime registers the lease the moment the physical stream exists, cancels exactly once on exit, and keeps the entry until waitClosed() settles. A second stream for the same operation is refused as CONFLICT / RPC_STREAM_DRAINING while that entry stands, and the refusal never reaches the transport. While updating the suites this also corrected two RPC-RR-02/RPC-RR-04 stream tests that were passing for the wrong reason: they sent an input the request schema rejects, so they never reached the fence or the frame decoder. With the correct input the frame test fails on a permissive decoder, as it should. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
250531aa43
commit
a7390e3b3a
@@ -5,6 +5,7 @@ import {
|
||||
createUnavailableBrowserRpcTransport,
|
||||
defineBrowserRpcTransport,
|
||||
type BrowserRpcObservation,
|
||||
type BrowserRpcServerStreamLease,
|
||||
type BrowserRpcStreamFrame,
|
||||
type BrowserRpcTransport,
|
||||
} from "../../../src/adapters/browser-rpc/index.ts";
|
||||
@@ -257,8 +258,12 @@ describe("Browser RPC provider-neutral runtime", () => {
|
||||
failure: { code: "UNAVAILABLE" },
|
||||
};
|
||||
},
|
||||
async *openServerStream() {
|
||||
yield Object.freeze({ kind: "TERMINAL", ok: true });
|
||||
openServerStream() {
|
||||
return serverStreamLease(
|
||||
(async function* () {
|
||||
yield Object.freeze({ kind: "TERMINAL" as const, ok: true as const });
|
||||
})(),
|
||||
);
|
||||
},
|
||||
}),
|
||||
).toThrow("transport is invalid");
|
||||
@@ -425,11 +430,41 @@ function streamTransport(
|
||||
protocol: "CONNECT_HTTP",
|
||||
rpcKind: "SERVER_STREAM",
|
||||
openServerStream(call) {
|
||||
return source(call.signal);
|
||||
return serverStreamLease(source(call.signal));
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* RPC-RR-01. Wraps a plain frame sequence in the lease the transport contract
|
||||
* requires. `cancel` resolves `waitClosed`, which is what a cooperative
|
||||
* transport does.
|
||||
*/
|
||||
let leaseSequence = 0;
|
||||
|
||||
function serverStreamLease(
|
||||
frames: AsyncIterable<BrowserRpcStreamFrame>,
|
||||
options: Readonly<{
|
||||
onCancel?: (reason: string) => void;
|
||||
closeOnCancel?: boolean;
|
||||
}> = {},
|
||||
): BrowserRpcServerStreamLease {
|
||||
leaseSequence += 1;
|
||||
let release: (() => void) | undefined;
|
||||
const closed = new Promise<void>((resolve) => {
|
||||
release = resolve;
|
||||
});
|
||||
return Object.freeze({
|
||||
streamId: `test-stream-${leaseSequence}`,
|
||||
frames,
|
||||
cancel(reason: string) {
|
||||
options.onCancel?.(reason);
|
||||
if (options.closeOnCancel !== false) release?.();
|
||||
},
|
||||
waitClosed: () => closed,
|
||||
});
|
||||
}
|
||||
|
||||
function message(
|
||||
id: string,
|
||||
name: string,
|
||||
|
||||
Reference in New Issue
Block a user