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:
DongHyeonka
2026-08-14 16:52:57 +09:00
co-authored by Claude Opus 5
parent 250531aa43
commit a7390e3b3a
6 changed files with 412 additions and 34 deletions
@@ -3,6 +3,7 @@ import { describe, expect, it } from "vitest";
import {
createBrowserRpcRuntime,
defineBrowserRpcTransport,
type BrowserRpcServerStreamLease,
type BrowserRpcStreamFrame,
type BrowserRpcTransport,
} from "../../../src/adapters/browser-rpc/index.ts";
@@ -61,6 +62,60 @@ function unaryTransport(
});
}
type LeaseProbe = Readonly<{
transport: BrowserRpcTransport;
cancels: string[];
readonly opened: number;
close(): void;
}>;
/**
* RPC-RR-01. A transport whose physical stream does not close on its own. The
* runtime must cancel it exactly once and must not admit a second stream for
* the same operation until `waitClosed()` settles.
*/
function nonCooperativeStreamTransport(): LeaseProbe {
const cancels: string[] = [];
const counter = { opened: 0 };
let release: (() => void) | undefined;
const closed = new Promise<void>((resolve) => {
release = resolve;
});
const openServerStream = (): BrowserRpcServerStreamLease => {
counter.opened += 1;
return {
streamId: `physical-${counter.opened}`,
frames: {
[Symbol.asyncIterator]: () =>
({
// Never yields and never settles: the runtime's own deadline is the
// only thing that can end the call.
next: () => new Promise<never>(() => {}),
}) as AsyncIterator<BrowserRpcStreamFrame>,
},
cancel(reason: string) {
cancels.push(reason);
},
waitClosed: () => closed,
};
};
const transport = defineBrowserRpcTransport({
runtimeProfileId: "CONNECT_REFERENCE_STREAM",
providerId: "REFERENCE_RPC",
protocol: "CONNECT_HTTP",
rpcKind: "SERVER_STREAM",
openServerStream,
});
return Object.freeze({
transport,
cancels,
get opened() {
return counter.opened;
},
close: () => release?.(),
}) as LeaseProbe;
}
function streamTransport(
frames: readonly BrowserRpcStreamFrame[],
): BrowserRpcTransport {
@@ -70,9 +125,14 @@ function streamTransport(
protocol: "CONNECT_HTTP",
rpcKind: "SERVER_STREAM",
openServerStream: () => ({
async *[Symbol.asyncIterator]() {
for (const frame of frames) yield frame;
streamId: "remediation-stream",
frames: {
async *[Symbol.asyncIterator]() {
for (const frame of frames) yield frame;
},
},
cancel() {},
async waitClosed() {},
}),
});
}
@@ -124,7 +184,7 @@ describe("RPC-RR-02 synchronous collaborator throws stay inside Result", () => {
const results = await collect(
runtime
.bindServerStream("WATCH_RPC_RESOURCES", isResourceView)
.open({ topic: "resources" }),
.open({ resourceId: "scope-1" }),
);
expect(results.length).toBeGreaterThan(0);
expect(results.every((value) => (value as { ok: boolean }).ok === false)).toBe(
@@ -306,10 +366,111 @@ describe("RPC-RR-04 transport results and frames are exactly decoded", () => {
const results = await collect(
runtime
.bindServerStream("WATCH_RPC_RESOURCES", isResourceView)
.open({ topic: "resources" }),
.open({ resourceId: "scope-1" }),
);
expect(results.every((value) => (value as { ok: boolean }).ok === false)).toBe(
true,
);
});
});
/**
* RPC-RR-01. A bare `AsyncIterable` gave the runtime no way to cancel the
* physical stream or to learn when it actually closed, so a timed-out call left
* the first stream running against the server while a second was admitted.
*/
describe("RPC-RR-01 server stream leases and the DRAINING fence", () => {
const shortDeadline = () =>
streamOperation({ totalDeadlineMs: 25, idleDeadlineMs: 25 });
it("cancels the physical stream exactly once after a timeout", async () => {
const probe = nonCooperativeStreamTransport();
const runtime = createBrowserRpcRuntime({
operations: { WATCH_RPC_RESOURCES: shortDeadline() },
profiles: { CONNECT_REFERENCE_STREAM: streamProfile() },
schemaCodecs: SCHEMA_CODECS,
mappers: MAPPERS,
requestEncoders: { RpcResourceStreamRequestEncoder: STREAM_ENCODER },
transports: { CONNECT_REFERENCE_STREAM: probe.transport },
});
const results = await collect(
runtime
.bindServerStream("WATCH_RPC_RESOURCES", isResourceView)
.open({ resourceId: "scope-1" }),
);
expect(results.every((value) => (value as { ok: boolean }).ok === false)).toBe(
true,
);
expect(probe.cancels.length).toBe(1);
});
it("refuses a second stream while the first has not confirmed closure", async () => {
const probe = nonCooperativeStreamTransport();
const runtime = createBrowserRpcRuntime({
operations: { WATCH_RPC_RESOURCES: shortDeadline() },
profiles: { CONNECT_REFERENCE_STREAM: streamProfile() },
schemaCodecs: SCHEMA_CODECS,
mappers: MAPPERS,
requestEncoders: { RpcResourceStreamRequestEncoder: STREAM_ENCODER },
transports: { CONNECT_REFERENCE_STREAM: probe.transport },
});
const stream = runtime.bindServerStream(
"WATCH_RPC_RESOURCES",
isResourceView,
);
await collect(stream.open({ resourceId: "scope-1" }));
expect(probe.opened).toBe(1);
const second = await collect(stream.open({ resourceId: "scope-1" }));
expect(second).toHaveLength(1);
expect(second[0]).toMatchObject({
ok: false,
error: { code: "RPC_STREAM_DRAINING" },
});
// The refused call never reached the transport.
expect(probe.opened).toBe(1);
// Once the transport confirms closure the operation admits work again.
probe.close();
await new Promise((resolve) => setTimeout(resolve, 5));
await collect(stream.open({ resourceId: "scope-1" }));
expect(probe.opened).toBe(2);
});
it("refuses a malformed lease as a protocol failure", async () => {
for (const malformed of [
{ frames: { [Symbol.asyncIterator]: () => ({ next: async () => ({ done: true, value: undefined }) }) }, cancel() {}, async waitClosed() {} },
{ streamId: "", frames: { [Symbol.asyncIterator]: () => ({ next: async () => ({ done: true, value: undefined }) }) }, cancel() {}, async waitClosed() {} },
{ streamId: "s1", frames: {}, cancel() {}, async waitClosed() {} },
{ streamId: "s1", frames: { [Symbol.asyncIterator]: () => ({ next: async () => ({ done: true, value: undefined }) }) }, cancel: 1, async waitClosed() {} },
]) {
const runtime = createBrowserRpcRuntime({
operations: { WATCH_RPC_RESOURCES: streamOperation() },
profiles: { CONNECT_REFERENCE_STREAM: streamProfile() },
schemaCodecs: SCHEMA_CODECS,
mappers: MAPPERS,
requestEncoders: { RpcResourceStreamRequestEncoder: STREAM_ENCODER },
transports: {
CONNECT_REFERENCE_STREAM: defineBrowserRpcTransport({
runtimeProfileId: "CONNECT_REFERENCE_STREAM",
providerId: "REFERENCE_RPC",
protocol: "CONNECT_HTTP",
rpcKind: "SERVER_STREAM",
openServerStream: () => malformed as never,
}),
},
});
const results = await collect(
runtime
.bindServerStream("WATCH_RPC_RESOURCES", isResourceView)
.open({ resourceId: "scope-1" }),
);
expect(
results.every((value) => (value as { ok: boolean }).ok === false),
).toBe(true);
}
});
});
@@ -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,