From 9ef1794491a6ba9adb5dd8877457cf62fd3f5d93 Mon Sep 17 00:00:00 2001 From: andrewmcgov Date: Mon, 29 Jun 2026 14:31:05 -0400 Subject: [PATCH] Throw specific error types when calling functions that are relesed or revoked. --- .changeset/rpc-named-function-errors.md | 5 ++ packages/rpc/src/encoding/basic.ts | 34 ++++++++---- packages/rpc/src/encoding/index.ts | 6 ++- packages/rpc/src/index.ts | 6 ++- packages/rpc/src/tests/endpoint.test.ts | 72 +++++++++++++++++++++++++ 5 files changed, 112 insertions(+), 11 deletions(-) create mode 100644 .changeset/rpc-named-function-errors.md diff --git a/.changeset/rpc-named-function-errors.md b/.changeset/rpc-named-function-errors.md new file mode 100644 index 00000000..f05741ad --- /dev/null +++ b/.changeset/rpc-named-function-errors.md @@ -0,0 +1,5 @@ +--- +'@remote-ui/rpc': patch +--- + +Throw exported `RemoteFunctionReleasedError` / `RemoteFunctionRevokedError` instead of a generic `Error` when calling a function proxy that was already released or revoked, so consumers can use `instanceof` (or the stable `error.name`) instead of matching the message string. The error messages are unchanged; this is a diagnostics-only change with no behavioral difference. diff --git a/packages/rpc/src/encoding/basic.ts b/packages/rpc/src/encoding/basic.ts index 0b042d61..97df5387 100644 --- a/packages/rpc/src/encoding/basic.ts +++ b/packages/rpc/src/encoding/basic.ts @@ -12,6 +12,28 @@ type AnyFunction = (...args: any[]) => any; const FUNCTION = '_@f'; +/** + * Thrown when a remote function proxy is called after its target has been + * released. + */ +export class RemoteFunctionReleasedError extends Error { + constructor() { + super('You attempted to call a function that was already released.'); + this.name = 'RemoteFunctionReleasedError'; + } +} + +/** + * Thrown when a remote function proxy is called after the RPC endpoint that + * vended it was terminated. + */ +export class RemoteFunctionRevokedError extends Error { + constructor() { + super('You attempted to call a function that was already revoked.'); + this.name = 'RemoteFunctionRevokedError'; + } +} + export function createBasicEncoder(api: EncodingStrategyApi): EncodingStrategy { const functionsToId = new Map(); const idsToFunction = new Map(); @@ -25,9 +47,7 @@ export function createBasicEncoder(api: EncodingStrategyApi): EncodingStrategy { const func = idsToFunction.get(id); if (func == null) { - throw new Error( - 'You attempted to call a function that was already released.', - ); + throw new RemoteFunctionReleasedError(); } try { @@ -176,15 +196,11 @@ export function createBasicEncoder(api: EncodingStrategyApi): EncodingStrategy { const proxy = (...args: any[]) => { if (released) { - throw new Error( - 'You attempted to call a function that was already released.', - ); + throw new RemoteFunctionReleasedError(); } if (!idsToProxy.has(id)) { - throw new Error( - 'You attempted to call a function that was already revoked.', - ); + throw new RemoteFunctionRevokedError(); } return api.call(id, args); diff --git a/packages/rpc/src/encoding/index.ts b/packages/rpc/src/encoding/index.ts index 826d8a00..5d695617 100644 --- a/packages/rpc/src/encoding/index.ts +++ b/packages/rpc/src/encoding/index.ts @@ -1 +1,5 @@ -export {createBasicEncoder} from './basic'; +export { + createBasicEncoder, + RemoteFunctionReleasedError, + RemoteFunctionRevokedError, +} from './basic'; diff --git a/packages/rpc/src/index.ts b/packages/rpc/src/index.ts index 4c4b2bd5..aab13da4 100644 --- a/packages/rpc/src/index.ts +++ b/packages/rpc/src/index.ts @@ -1,6 +1,10 @@ export {createEndpoint} from './endpoint'; export type {Endpoint, CreateEndpointOptions} from './endpoint'; -export {createBasicEncoder} from './encoding'; +export { + createBasicEncoder, + RemoteFunctionReleasedError, + RemoteFunctionRevokedError, +} from './encoding'; export { fromMessagePort, fromWebWorker, diff --git a/packages/rpc/src/tests/endpoint.test.ts b/packages/rpc/src/tests/endpoint.test.ts index 2c6bc930..5d3b3380 100644 --- a/packages/rpc/src/tests/endpoint.test.ts +++ b/packages/rpc/src/tests/endpoint.test.ts @@ -2,6 +2,10 @@ import {MessageEndpoint} from '../types'; import {createEndpoint, TERMINATE, MissingResolverError} from '../endpoint'; import {fromMessagePort} from '../adaptors'; import {release, retain} from '../memory'; +import { + RemoteFunctionReleasedError, + RemoteFunctionRevokedError, +} from '../encoding'; import {MessageChannel} from './utilities'; @@ -234,6 +238,74 @@ describe('createEndpoint()', () => { }); }); +describe('function proxies', () => { + it('throws a RemoteFunctionReleasedError when calling a released function proxy', async () => { + const {port1, port2} = new MessageChannel(); + port1.start(); + port2.start(); + + const endpoint1 = createEndpoint<{callMe(): () => void}>( + fromMessagePort(port1), + ); + const endpoint2 = createEndpoint(fromMessagePort(port2)); + endpoint2.expose({ + callMe() { + return () => {}; + }, + }); + + const callMeBack = await endpoint1.call.callMe(); + + // Retain then release so the proxy's retain count returns to zero and the + // proxy is marked as released (use-after-free). + retain(callMeBack); + release(callMeBack); + + let error: any; + try { + callMeBack(); + } catch (caught) { + error = caught; + } + + expect(error).toBeInstanceOf(RemoteFunctionReleasedError); + expect(error.name).toBe('RemoteFunctionReleasedError'); + }); + + it('throws a RemoteFunctionRevokedError when calling a function proxy after the endpoint is terminated', async () => { + const {port1, port2} = new MessageChannel(); + port1.start(); + port2.start(); + + const endpoint1 = createEndpoint<{callMe(): () => void}>( + fromMessagePort(port1), + ); + const endpoint2 = createEndpoint(fromMessagePort(port2)); + endpoint2.expose({ + callMe() { + return () => {}; + }, + }); + + const callMeBack = await endpoint1.call.callMe(); + + // Keep the proxy retained (so it is not "released"), then terminate the + // endpoint, which revokes the proxy by clearing the encoder's registry. + retain(callMeBack); + endpoint1.terminate(); + + let error: any; + try { + callMeBack(); + } catch (caught) { + error = caught; + } + + expect(error).toBeInstanceOf(RemoteFunctionRevokedError); + expect(error.name).toBe('RemoteFunctionRevokedError'); + }); +}); + function createCatchingMessageEndpoint( messagePort: MessagePort, ): MessageEndpoint {