Skip to content
Merged
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
2 changes: 1 addition & 1 deletion .changeset/violet-pears-repeat.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@

Report a lost message instead of dropping it silently.

A message that a transport refuses now produces a `messageerror` envelope carrying the transport's own error, so it stops being invisible. When the failed envelope was routeda response, a channel handshake — the report inherits that route and travels to whoever was waiting, and a pending `request` rejects with the real cause instead of retrying until its `abortSignal` fires. Transports implementing the platform `messageerror` event now feed it into the same channel, so a failed deserialization reaches the endpoint as well.
A message that a transport refuses now produces a `messageerror` envelope carrying the transport's own error, so it stops being invisible. The report is routed to whoever was waiting for the message that failed: forward along its route when the envelope was already routed, such as a response or a channel handshake, and back along its checkpoints when it was still on its way out. Either way a pending `request` rejects with the real cause instead of retrying until its `abortSignal` fires. Transports implementing the platform `messageerror` event now feed it into the same channel, so a failed deserialization reaches the endpoint as well.

Keep it distinct from `error`: an `error` envelope still means the endpoint itself died and is what supervisors act on, while `messageerror` says one message was lost and the endpoint is fine.

Expand Down
21 changes: 13 additions & 8 deletions documentation.md
Original file line number Diff line number Diff line change
Expand Up @@ -244,14 +244,19 @@ Two causes produce it:
silently lost.

**Where the report goes.** A refused payload does not mean a broken link: an `Error` travels where a
transferable could not. So when the failed envelope was **routed** — a response, a channel handshake, in
short something with a party waiting on the far side — the report inherits that route and continues in the
same direction until it reaches them. A pending [`request`](#7-request--response) matching that route rejects
immediately with the original error instead of retrying into a wall.

Everything else is only meaningful locally, so it is delivered to the nearest endpoint and relayed no
further: a report with no route, and every `Undeserializable`. If even the report cannot be handed over, it
goes to `loggerProvider.error` — at that point there is nobody left to tell.
transferable could not, so the report is routed to whoever was waiting for the message that failed.

- The failed envelope was **routed** — a response, a channel handshake — so someone is waiting beyond the
target. The report inherits that route and continues in the same direction.
- The failed envelope was **on its way out** and carries checkpoints, so anyone waiting sits behind the
source. The report travels back along those checkpoints minus the hop that just failed.

Either way a pending [`request`](#7-request--response) whose route matches rejects immediately with the
original error instead of retrying into a wall.

What is left is only meaningful locally and is relayed no further: a report for an envelope with no
checkpoints at all, and every `Undeserializable`. If even the report cannot be handed over, it goes to
`loggerProvider.error` — at that point there is nobody left to tell.

A throwing listener is a separate matter: it never reaches the peer at all. It is rethrown as an uncaught
error, exactly like a throwing DOM event listener, and the remaining listeners still receive the envelope.
Expand Down
37 changes: 25 additions & 12 deletions packages/webactor/src/connectTransmitters.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,14 @@ import { loggerProvider } from './providers';
import { Reasons } from './reason';
import { AnyData, EventType, Transmitter } from './types';
import { reasonToError } from './utils/common';
import { createRoute, extendRoute, isRoutedEnvelope, reduceRoute, routeEndsWith } from './utils/route';
import {
createRoute,
extendRoute,
isCheckpointedEnvelope,
isRoutedEnvelope,
reduceRoute,
routeEndsWith,
} from './utils/route';
import { getTransmitterName, on, post } from './utils/transmitter';

type Type =
Expand Down Expand Up @@ -95,19 +102,25 @@ function reportUndelivered(
return;
}

const route = isRoutedEnvelope(failed) ? failed.__route : undefined;
const report = createEnvelope(EnvelopeType.MessageError, reasonToError(error, Reasons.Undeliverable), undefined, {
route,
});
const failure = reasonToError(error, Reasons.Undeliverable);

if (route === undefined) {
try {
post(source, EnvelopeType.MessageError, report);
} catch (reportError) {
loggerProvider.error(reportError);
}
} else {
// A routed envelope has someone waiting beyond the target, so the report keeps going that way
if (isRoutedEnvelope(failed)) {
const report = createEnvelope(EnvelopeType.MessageError, failure, undefined, { route: failed.__route });
safePost(source, target, EnvelopeType.MessageError, report);
return;
}

// Anyone waiting sits behind the source, and the checkpoints minus this hop are the way back to them
const route = isCheckpointedEnvelope(failed)
? reduceRoute(failed.__checkpoints, getTransmitterName(source), getTransmitterName(target))
: undefined;
const report = createEnvelope(EnvelopeType.MessageError, failure, undefined, { route });

try {
post(source, EnvelopeType.MessageError, report);
} catch (reportError) {
loggerProvider.error(reportError);
}
}

Expand Down
4 changes: 4 additions & 0 deletions packages/webactor/src/utils/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,3 +33,7 @@ export function routeEndsWith(route: Route, ...checkpoints: [Checkpoint, Checkpo
export function isRoutedEnvelope(envelope: AnyEnvelope): envelope is AnyEnvelope & { __route: Route } {
return envelope.__route !== undefined;
}

export function isCheckpointedEnvelope(envelope: AnyEnvelope): envelope is AnyEnvelope & { __checkpoints: Route } {
return envelope.__checkpoints !== undefined;
}
27 changes: 27 additions & 0 deletions packages/webactor/tests/envelope-emitter.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -166,6 +166,33 @@ describe('undelivered messages and failing listeners', () => {
disconnect();
});

it('should walk the report back to a caller sitting behind an intermediate hop', async () => {
const caller = createEnvelopeChannel();
const middle = createEnvelopeChannel();
const broken = {
name: 'broken',
postMessage: () => {
throw new Error('An object could not be cloned.');
},
addEventListener: () => {},
removeEventListener: () => {},
};
const first = connectTransmitters(
caller.port1 as unknown as Transmitter,
middle.port1 as unknown as Transmitter,
);
const second = connectTransmitters(middle.port2 as unknown as Transmitter, broken as Transmitter);

await expect(request(caller.port2 as unknown as Transmitter, { hello: 'world' })).rejects.toThrow(
'An object could not be cloned.',
);
expect(uncaught).toHaveLength(0);
expect(logged).toHaveLength(0);

first();
second();
});

it('should log when even the report cannot be handed over', async () => {
const native = new MockMessageChannel();
const broken = {
Expand Down