Skip to content
Draft
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/rpc-named-function-errors.md
Original file line number Diff line number Diff line change
@@ -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.
34 changes: 25 additions & 9 deletions packages/rpc/src/encoding/basic.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<AnyFunction, string>();
const idsToFunction = new Map<string, AnyFunction>();
Expand All @@ -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 {
Expand Down Expand Up @@ -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);
Expand Down
6 changes: 5 additions & 1 deletion packages/rpc/src/encoding/index.ts
Original file line number Diff line number Diff line change
@@ -1 +1,5 @@
export {createBasicEncoder} from './basic';
export {
createBasicEncoder,
RemoteFunctionReleasedError,
RemoteFunctionRevokedError,
} from './basic';
6 changes: 5 additions & 1 deletion packages/rpc/src/index.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand Down
72 changes: 72 additions & 0 deletions packages/rpc/src/tests/endpoint.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -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 {
Expand Down
Loading