Unhandled rejection when a call fails before delivery and an argument is an imported promise

Open Beginner friendly
#293 0 comments 0 reactions 0 assignees View on GitHub

Maintainers usually reply within 2 days

Nobody has claimed this yet.

Assessment

Difficulty
2/5
Estimated time
1-3 hours
Newbie friendliness
81/100
Issue type
Bug
Clarity
Clearly specified
Activity status
Active
Tech stack
typescript

Research direction

Start in src/rpc.ts at ImportTableEntry's constructor, where activePull is created, and read how RpcPayload.dispose() handles imported promise arguments. The report suggests a no-op catch on activePull.promise; use the included Vitest reproductions as the first check. Done when both failed-call cases reject as expected without producing an unhandled rejection.

Written by the indexing model from the issue text.

Description

This issue was found using AI tooling and confirmed on capnweb 0.12.0.

ImportTableEntry's constructor creates this.activePull = Promise.withResolvers(), and nothing awaits it. If the call then fails before it is delivered, RpcPayload.dispose() disposes the argument, which calls activePull.reject(...). The result is an unhandled rejection ("RPC was canceled because the RpcPromise was disposed."), which kills a Node server. Repro: start sessions A and B over MessagePorts. On A's client, call stubFromA.nope(sessionB.someCall()). Suggested fix: attach a no-op .catch to activePull.promise when it is created.

Repro:

import { newMessagePortRpcSession, RpcTarget } from "capnweb";
import { afterEach, beforeEach, describe, expect, it } from "vitest";

// A call that fails before reaching the target (not a function, missing
// method) disposes its arguments. When an argument is a promise the caller
// is still resolving, that dispose must not leave an unhandled rejection
// behind: in a server process it would crash every session.

class HostObject extends RpcTarget {
	hello() {
		return "hi";
	}
}

class Root extends RpcTarget {
	object() {
		return new HostObject();
	}
}

function connect() {
	const { port1, port2 } = new MessageChannel();
	newMessagePortRpcSession(port1, new Root());
	return newMessagePortRpcSession<Root>(port2);
}

describe("capnweb failed calls with promise arguments", () => {
	let unhandled: unknown[];
	const onUnhandled = (reason: unknown) => unhandled.push(reason);

	beforeEach(() => {
		unhandled = [];
		process.on("unhandledRejection", onUnhandled);
	});
	afterEach(() => {
		process.off("unhandledRejection", onUnhandled);
	});

	async function settle() {
		await new Promise((resolve) => setTimeout(resolve, 50));
	}

	it("rejects calling a host object as a function", async () => {
		const client = connect();
		const other = connect();
		const object = client.object() as unknown as (arg: unknown) => Promise<unknown>;
		await expect(object(other.object())).rejects.toThrow("is not a function");
		await settle();
		expect(unhandled).toEqual([]);
	});

	it("rejects calling a missing method", async () => {
		const client = connect();
		const other = connect();
		const object = client.object() as unknown as { nope(arg: unknown): Promise<unknown> };
		await expect(object.nope(other.object())).rejects.toThrow("'nope' is not a function");
		await settle();
		expect(unhandled).toEqual([]);
	});
});
Dominant language
TypeScript
Stars
4k
Forks
151
Avg merge
16h 46m
Merged PRs (30d)
2

Getting set up

First steps

  1. Read the whole issue, then the project's contributing guide.
  2. Comment on the issue to say you are picking it up — it saves two people doing the same work.
  3. Fork the repository and make your change on a branch.
  4. Open a pull request that references the issue number.

More from cloudflare/capnweb

All issues in cloudflare/capnweb

Similar issues

More TypeScript issues

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.