diff --git a/.changeset/violet-pears-repeat.md b/.changeset/violet-pears-repeat.md index 586cc7b..17e8016 100644 --- a/.changeset/violet-pears-repeat.md +++ b/.changeset/violet-pears-repeat.md @@ -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 routed — a 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. diff --git a/documentation.md b/documentation.md index 20b2501..5af43ee 100644 --- a/documentation.md +++ b/documentation.md @@ -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. diff --git a/packages/webactor/src/connectTransmitters.ts b/packages/webactor/src/connectTransmitters.ts index 350c50b..c9d6ad1 100644 --- a/packages/webactor/src/connectTransmitters.ts +++ b/packages/webactor/src/connectTransmitters.ts @@ -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 = @@ -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); } } diff --git a/packages/webactor/src/utils/route.ts b/packages/webactor/src/utils/route.ts index 0e83cb1..53ad893 100644 --- a/packages/webactor/src/utils/route.ts +++ b/packages/webactor/src/utils/route.ts @@ -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; +} diff --git a/packages/webactor/tests/envelope-emitter.test.ts b/packages/webactor/tests/envelope-emitter.test.ts index 98943a7..73ab2dd 100644 --- a/packages/webactor/tests/envelope-emitter.test.ts +++ b/packages/webactor/tests/envelope-emitter.test.ts @@ -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 = {