Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/promise-stubhook-disposal.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"capnweb": patch
---

Fix RPC argument and capture leaks on failure paths — rejected or broken destination hooks and local call errors now dispose the arguments they own — and keep `PromiseStubHook` disposal ordered behind already-queued calls.
5 changes: 5 additions & 0 deletions .changeset/rpc-promise-from-promise.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"capnweb": minor
---

`RpcPromise` can now be constructed by the application from a `Promise` for the eventual resolution. Calls pipeline immediately and are queued, in order, until the promise settles, making it possible to publish a capability that doesn't exist yet, e.g. while re-establishing a broken session.
22 changes: 22 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -267,6 +267,28 @@ let profile = await api.getUserProfile(user.id);

Whenever an `RpcPromise` is passed in the parameters to an RPC, or returned as part of the result, the promise will be replaced with its resolution before delivery to the receiving application. So, you can use an `RpcPromise<T>` anywhere where a `T` is required!

#### Constructing `RpcPromise` from a `Promise`

You can construct an `RpcPromise<T>` directly from a regular `Promise<T>`, allowing you to perform promise pipelining on a regular local promise. Pipelined calls will wait until the inner promise resolves, then will be delivered, in-order, to the resolution. This is useful when you plan to obtain some stub in the future, but you want to allow code to start queuing calls on it immediately.

Wrapping a `Promise<T>` in this way is semantically identical to creating a local-loopback RPC and then invoking it. That is:

```ts
// this...
let rpcPromise = new RpcPromise(myPromise);

// is semantically the same as this...
let rpcFunc = new RpcStub(() => myPromise);
let rpcPromise = rpcFunc();
```

In other words, this means:
* The result of the promise must be serializable.
* If the promise resolution contains `RpcTarget`s or `Function`s, the `RpcPromise`'s resolution will replace them with stubs.
* Ownership of any stubs in the Promise result is transferred away. If you want to keep your own copies, you need to `dup()` them.
* If the promise rejects, the rejection propagates to all pipelined calls.
* etc.

### The magic `map()` method

Every RPC promise has a special method `.map()` which can be used to remotely transform a value, without pulling it back locally. Here's an example:
Expand Down
289 changes: 288 additions & 1 deletion __tests__/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,11 +4,13 @@

import { expect, it, describe, inject } from "vitest"
import { deserialize, serialize, RpcSession, type RpcSessionOptions, RpcTransport,
type RpcTransportWithCustomEncoding, RpcTarget, RpcStub, newWebSocketRpcSession,
type RpcTransportWithCustomEncoding, RpcTarget, RpcStub, RpcPromise, newWebSocketRpcSession,
newMessagePortRpcSession,
newHttpBatchRpcSession} from "../src/index.js"
import { swapByteOrder } from "../src/serialize.js"
import { MAX_CLOSE_REASON_BYTES } from "../src/websocket.js"
import { ErrorStubHook, PayloadStubHook, PromiseStubHook, RpcPayload, RpcStub as RawRpcStub,
unwrapStubTakingOwnership } from "../src/core.js"
import { Counter, TestTarget } from "./test-util.js";

type CustomEncodingLevel = RpcTransportWithCustomEncoding["encodingLevel"];
Expand Down Expand Up @@ -547,6 +549,7 @@ class TestTransport implements RpcTransport {
private waiter?: () => void;
private aborter?: (err: any) => void;
public log = false;
public sentLog: string[] = [];
private fenced = false;

send(message: string): void {
Expand All @@ -555,6 +558,7 @@ class TestTransport implements RpcTransport {
message = message.replaceAll("$remove$", "");

if (this.log) console.log(`${this.name}: ${message}`);
this.sentLog.push(message);
this.partner!.queue.push(message);
if (this.partner!.waiter && !this.partner!.fenced) {
this.partner!.waiter();
Expand Down Expand Up @@ -2178,6 +2182,289 @@ describe("onRpcBroken", () => {

// =======================================================================================

describe("PromiseStubHook", () => {
it("disposes copied call arguments when the backing promise rejects", async () => {
let disposed = false;
class Disposable extends RpcTarget {
[Symbol.dispose]() { disposed = true; }
}

let argument = new RpcStub(new Disposable());
let hook = new PromiseStubHook(Promise.reject(new Error("nope")));
let result = hook.call([], RpcPayload.fromAppParams([argument]));

await expect(result.pull()).rejects.toThrow("nope");
argument[Symbol.dispose]();
expect(disposed).toBe(true);
});

it("disposes copied stream arguments when the backing promise rejects", async () => {
let disposed = false;
class Disposable extends RpcTarget {
[Symbol.dispose]() { disposed = true; }
}

let argument = new RpcStub(new Disposable());
let hook = new PromiseStubHook(Promise.reject(new Error("nope")));
let result = hook.stream(["write"], RpcPayload.fromAppParams([argument]));

await expect(result.promise).rejects.toThrow("nope");
argument[Symbol.dispose]();
expect(disposed).toBe(true);
});

it("delivers a call initiated before disposal", async () => {
let disposed = false;
class DisposableCounter extends Counter {
[Symbol.dispose]() { disposed = true; }
}

let inner = new RpcStub(new DisposableCounter(1));
let hook = new PromiseStubHook(Promise.resolve(unwrapStubTakingOwnership(<any>inner)));
await pumpMicrotasks();

let stub: RpcStub<Counter> = <any>new RawRpcStub(hook);
let result = stub.increment(2);
stub[Symbol.dispose]();

expect(disposed).toBe(false);
expect(await result).toBe(3);
expect(disposed).toBe(true);
});

it("disposes copied call arguments when the destination hook is broken", async () => {
let disposed = false;
class Disposable extends RpcTarget {
[Symbol.dispose]() { disposed = true; }
}

let argument = new RpcStub(new Disposable());
let hook = new PromiseStubHook(Promise.resolve(new ErrorStubHook(new Error("broken"))));
let result = hook.call([], RpcPayload.fromAppParams([argument]));

await expect(result.pull()).rejects.toThrow("broken");
argument[Symbol.dispose]();
expect(disposed).toBe(true);
});

it("disposes copied stream arguments when the destination hook is broken", async () => {
let disposed = false;
class Disposable extends RpcTarget {
[Symbol.dispose]() { disposed = true; }
}

let argument = new RpcStub(new Disposable());
let hook = new PromiseStubHook(Promise.resolve(new ErrorStubHook(new Error("broken"))));
let result = hook.stream(["write"], RpcPayload.fromAppParams([argument]));

await expect(result.promise).rejects.toThrow("broken");
argument[Symbol.dispose]();
expect(disposed).toBe(true);
});

it("disposes copied call arguments when the local call path fails", async () => {
let disposed = false;
class Disposable extends RpcTarget {
[Symbol.dispose]() { disposed = true; }
}

let argument = new RpcStub(new Disposable());
let hook = new PromiseStubHook(
Promise.resolve(new PayloadStubHook(RpcPayload.fromAppReturn({}))));
let result = hook.call(["nope"], RpcPayload.fromAppParams([argument]));

await expect(result.pull()).rejects.toThrow("'nope' is not a function");
argument[Symbol.dispose]();
expect(disposed).toBe(true);
hook.dispose();
});

it("disposes map captures when the destination hook is broken", () => {
let disposed = false;
class Disposable extends RpcTarget {
[Symbol.dispose]() { disposed = true; }
}

let capture = unwrapStubTakingOwnership(<any>new RpcStub(new Disposable()));
let hook = new ErrorStubHook(new Error("broken"));
hook.map([], [capture], []);
expect(disposed).toBe(true);
});
});

// =======================================================================================

describe("constructing RpcPromise from a promise", () => {
it("pipelines through a pending promise without pulling the resolution", async () => {
await using harness = new TestHarness(new TestTarget());

let {promise, resolve} = Promise.withResolvers<RpcStub<TestTarget>>();
using stub = new RpcPromise<TestTarget>(promise);

using counter = stub.makeCounter(1);
let result = counter.increment(2);
resolve(harness.stub.dup());
expect(await result).toBe(3);

// Only the final result was pulled: neither the promise's resolution nor the intermediate
// counter was transmitted.
let sent = harness.clientTransport.sentLog;
expect(sent.some(msg => msg.startsWith('["push"'))).toBe(true);
expect(sent.filter(msg => msg.startsWith('["pull"'))).toHaveLength(1);
});

it("queues calls made before resolution and delivers them in order", async () => {
let calls: number[] = [];
class Recorder extends RpcTarget {
record(i: number) { calls.push(i); return i; }
}

let {promise, resolve} = Promise.withResolvers<Recorder>();
using stub = new RpcPromise<Recorder>(promise);

let results = [stub.record(1), stub.record(2), stub.record(3)];
expect(calls).toStrictEqual([]);

resolve(new Recorder());
expect(await Promise.all(results)).toStrictEqual([1, 2, 3]);
expect(calls).toStrictEqual([1, 2, 3]);
});

it("accepts a promise for a target, a remote stub, or a plain value", async () => {
await using harness = new TestHarness(new TestTarget());

using target = new RpcPromise<Counter>(Promise.resolve(new Counter(1)));
expect(await target.increment()).toBe(2);

using remote = new RpcPromise<TestTarget>(Promise.resolve(harness.stub.dup()));
expect(await remote.square(3)).toBe(9);

using value = new RpcPromise<{foo: number}>(Promise.resolve({foo: 123}));
expect(await value.foo).toBe(123);
});

it("awaiting the RpcPromise yields the resolution", async () => {
using plain = new RpcPromise<{foo: number}>(Promise.resolve({foo: 123}));
expect(await plain).toStrictEqual({foo: 123});

await using harness = new TestHarness(new TestTarget());
using remote = new RpcPromise<TestTarget>(Promise.resolve(harness.stub.dup()));
let resolved = await remote;
expect(await resolved.square(4)).toBe(16);
});

it("reports rejection to queued calls, await, and onRpcBroken", async () => {
let error = new Error("nope");
using stub = new RpcPromise<Counter>(Promise.reject(error));

let broken: any[] = [];
stub.onRpcBroken(err => { broken.push(err); });

await expect(() => stub.increment()).rejects.toThrow("nope");
await expect(Promise.resolve(stub)).rejects.toThrow("nope");
expect(broken).toStrictEqual([error]);
});

it("does not report an unhandled rejection for an unused promise", async () => {
new RpcPromise<Counter>(Promise.reject(new Error("ignored")));
await pumpMicrotasks();
});

it("does not report an unhandled rejection for a discarded queued call", async () => {
using stub = new RpcPromise<Counter>(Promise.reject(new Error("ignored")));
stub.increment(); // result intentionally discarded
await pumpMicrotasks();
});

it("does not report an unhandled rejection for a discarded map() result", async () => {
using stub = new RpcPromise<number>(Promise.reject(new Error("ignored")));
stub.map(i => i); // result intentionally discarded
await pumpMicrotasks();
});

it("disposes the eventual target when disposed before resolution", async () => {
let disposed = false;
class Disposable extends RpcTarget {
[Symbol.dispose]() { disposed = true; }
}

let {promise, resolve} = Promise.withResolvers<Disposable>();
let stub = new RpcPromise<Disposable>(promise);
stub[Symbol.dispose]();

resolve(new Disposable());
await pumpMicrotasks();
expect(disposed).toBe(true);
});

it("delivers a call initiated before disposal", async () => {
let disposed = false;
class DisposableCounter extends Counter {
[Symbol.dispose]() { disposed = true; }
}

let stub = new RpcPromise<DisposableCounter>(Promise.resolve(new DisposableCounter(1)));
await pumpMicrotasks();

let result = stub.increment(2);
stub[Symbol.dispose]();

expect(disposed).toBe(false);
expect(await result).toBe(3);
expect(disposed).toBe(true);
});

it("keeps an adopted RpcPromise lazy", async () => {
await using harness = new TestHarness(new TestTarget());

using counter = new RpcPromise<Counter>(harness.stub.makeCounter(1));
expect(await counter.increment(2)).toBe(3);

let sent = harness.clientTransport.sentLog;
expect(sent.filter(msg => msg.startsWith('["pull"'))).toHaveLength(1);
});

it("preserves brokenness of a bare stub it was constructed from", async () => {
await using harness = new TestHarness(new TestTarget());
using stub = new RpcPromise<TestTarget>(<any>harness.stub.dup());

let errors: any[] = [];
stub.onRpcBroken(error => { errors.push(error); });

harness.clientTransport.forceReceiveError(new Error("test disconnect"));
await pumpMicrotasks();
expect(errors).toStrictEqual([new Error("test disconnect")]);
});

it("keeps disposal idempotent when constructed from a bare stub", async () => {
let disposals = 0;
class Disposable extends RpcTarget {
[Symbol.dispose]() { ++disposals; }
}

let inner = new RpcStub(new Disposable());
let outer = new RpcPromise<Disposable>(<any>inner);
inner[Symbol.dispose]();
outer[Symbol.dispose]();

await pumpMicrotasks();
expect(disposals).toBe(1);
});

it("resolves when awaited after construction from a bare local stub", async () => {
// Regression test: the constructor previously adopted a bare stub's hook directly, producing
// a promise whose pipelined calls worked but whose await rejected, because non-promise hooks
// don't implement pull().
using stub = new RpcPromise<Counter>(<any>new RpcStub(new Counter(1)));

expect(await stub.increment(2)).toBe(3);
let resolved = await stub;
expect(await resolved.increment(3)).toBe(6);
});
});

// =======================================================================================

describe("HTTP requests", () => {
it("can perform a batch HTTP request", async () => {
let cap = newHttpBatchRpcSession<TestTarget>(`http://${inject("testServerHost")}`);
Expand Down
26 changes: 26 additions & 0 deletions __type-tests__/rpc-base-cases.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -150,3 +150,29 @@ api.invoke((name: string, attempt: number) => {

// @ts-expect-error headers argument must be Headers
api.roundTripHeaders(new Map([["x-id", "1"]]))

// An RpcPromise can be constructed from a promise for a plain value, an RpcTarget, or a stub,
// keeping the resolution's type in each case.
expectType<RpcPromise<number>>(new RpcPromise(Promise.resolve(42)))
expectType<RpcPromise<PointTarget>>(new RpcPromise(Promise.resolve(new PointTarget())))
expectType<RpcPromise<PointTarget>>(new RpcPromise<PointTarget>(Promise.reject(new Error("x"))))

// The target type is inferred exactly from a promise for a stub -- no explicit type argument.
const promisedFromStub = new RpcPromise(Promise.resolve(pointStub))
type _PromisedFromStubInfersTarget = Expect<Equal<typeof promisedFromStub, RpcPromise<PointTarget>>>

async function assertAwaitedConstructedPromiseShapes() {
const target = await new RpcPromise(Promise.resolve(new PointTarget()))
expectType<RpcStub<PointTarget>>(target)

const value = await new RpcPromise(Promise.resolve(42))
expectType<number>(value)
}

void assertAwaitedConstructedPromiseShapes

// @ts-expect-error a non-thenable value cannot back an RpcPromise
void new RpcPromise(42)

// @ts-expect-error a bare stub cannot back an RpcPromise; pass a promise for the stub instead
void new RpcPromise(pointStub)
Loading
Loading