fix: report combined local transport failures once
This commit is contained in:
+19
-12
@@ -14,6 +14,16 @@ interface LocalLoginTransportOptions {
|
|||||||
record: EvidenceSink;
|
record: EvidenceSink;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** ES2020 aggregate: retain originals before notifying a terminal observer once. */
|
||||||
|
class LocalLoginTransportFault extends Error {
|
||||||
|
readonly errors: readonly unknown[];
|
||||||
|
constructor(errors: readonly unknown[]) {
|
||||||
|
super('Local login transport failed with an additional cleanup error');
|
||||||
|
this.name = 'LocalLoginTransportFault';
|
||||||
|
this.errors = Object.freeze([...errors]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/** Local-only socket boundary; raw evidence precedes protocol decoding. */
|
/** Local-only socket boundary; raw evidence precedes protocol decoding. */
|
||||||
export class LocalLoginTransport implements Transport {
|
export class LocalLoginTransport implements Transport {
|
||||||
private socket: WebSocket | null = null;
|
private socket: WebSocket | null = null;
|
||||||
@@ -53,8 +63,7 @@ export class LocalLoginTransport implements Transport {
|
|||||||
try {
|
try {
|
||||||
this.options.record({ kind: 'transport-receive', data: event.data });
|
this.options.record({ kind: 'transport-receive', data: event.data });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
this.stopWithFault(error);
|
throw this.stopWithFault(error);
|
||||||
throw error;
|
|
||||||
}
|
}
|
||||||
if (this.socket === socket) this.messageCallback?.(event.data);
|
if (this.socket === socket) this.messageCallback?.(event.data);
|
||||||
};
|
};
|
||||||
@@ -68,8 +77,7 @@ export class LocalLoginTransport implements Transport {
|
|||||||
try {
|
try {
|
||||||
this.options.record({ kind: 'transport-error', data: event });
|
this.options.record({ kind: 'transport-error', data: event });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
this.stopWithFault(error);
|
throw this.stopWithFault(error);
|
||||||
throw error;
|
|
||||||
}
|
}
|
||||||
if (this.socket !== socket || this.generation !== generation) return;
|
if (this.socket !== socket || this.generation !== generation) return;
|
||||||
// Invalidate before close: browsers may dispatch close after error.
|
// Invalidate before close: browsers may dispatch close after error.
|
||||||
@@ -93,8 +101,7 @@ export class LocalLoginTransport implements Transport {
|
|||||||
socket.send(frame);
|
socket.send(frame);
|
||||||
this.options.record({ kind: 'transport-send', data: frame });
|
this.options.record({ kind: 'transport-send', data: frame });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
this.stopWithFault(error);
|
throw this.stopWithFault(error);
|
||||||
throw error;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -115,17 +122,17 @@ export class LocalLoginTransport implements Transport {
|
|||||||
onMessage(callback: (frame: string) => void): void { this.messageCallback = callback; }
|
onMessage(callback: (frame: string) => void): void { this.messageCallback = callback; }
|
||||||
onClose(callback: () => void): void { this.closeCallback = callback; }
|
onClose(callback: () => void): void { this.closeCallback = callback; }
|
||||||
|
|
||||||
private stopWithFault(error: unknown): void {
|
private stopWithFault(error: unknown): unknown {
|
||||||
const socket = this.socket;
|
const socket = this.socket;
|
||||||
this.socket = null;
|
this.socket = null;
|
||||||
const cleanupErrors: unknown[] = [];
|
const errors: unknown[] = [error];
|
||||||
try {
|
try {
|
||||||
socket?.close();
|
socket?.close();
|
||||||
} catch (cleanupError) {
|
} catch (cleanupError) {
|
||||||
cleanupErrors.push(cleanupError);
|
errors.push(cleanupError);
|
||||||
}
|
}
|
||||||
this.options.onFault(error);
|
const fault = errors.length === 1 ? error : new LocalLoginTransportFault(errors);
|
||||||
// Preserve both independent failures, including non-Error thrown values.
|
this.options.onFault(fault);
|
||||||
for (const cleanupError of cleanupErrors) this.options.onFault(cleanupError);
|
return fault;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -271,3 +271,53 @@ for (const failure of ['record', 'close']) {
|
|||||||
assert.throws(() => h.transport.send('late'), /open/i);
|
assert.throws(() => h.transport.send('late'), /open/i);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
test('non-text and cleanup failures reach a single terminal observer together', async () => {
|
||||||
|
const observed: unknown[] = [];
|
||||||
|
const h = await setup({ onFault: (error) => { if (observed.length === 0) observed.push(error); } });
|
||||||
|
h.transport.connect(h.url);
|
||||||
|
const socket = h.sockets[0]!;
|
||||||
|
const cleanupError = new Error('close failed');
|
||||||
|
socket.closeError = cleanupError;
|
||||||
|
socket.message(new ArrayBuffer(1));
|
||||||
|
assert.equal(h.faults.length, 1);
|
||||||
|
assert.equal(observed[0], h.faults[0]);
|
||||||
|
const fault = observed[0] as Error & { errors: unknown[] };
|
||||||
|
assert.ok(fault instanceof Error);
|
||||||
|
assert.equal(fault.errors.length, 2);
|
||||||
|
assert.ok(fault.errors[0] instanceof TypeError);
|
||||||
|
assert.equal(fault.errors[1], cleanupError);
|
||||||
|
assert.throws(() => h.transport.send('late'), /open/i);
|
||||||
|
});
|
||||||
|
|
||||||
|
for (const operation of ['receive-record', 'error-record', 'send-record', 'send']) {
|
||||||
|
test(`${operation} and close failures are reported once and thrown as the same combined fault`, async () => {
|
||||||
|
const primary = new Error('primary failed');
|
||||||
|
const cleanup = new Error('close failed');
|
||||||
|
const observed: unknown[] = [];
|
||||||
|
const h = await setup({
|
||||||
|
...(operation === 'send' ? {} : { record: () => { throw primary; } }),
|
||||||
|
onFault: (error) => { if (observed.length === 0) observed.push(error); },
|
||||||
|
});
|
||||||
|
h.transport.connect(h.url);
|
||||||
|
const socket = h.sockets[0]!;
|
||||||
|
socket.open();
|
||||||
|
socket.closeError = cleanup;
|
||||||
|
if (operation === 'send') socket.sendError = primary;
|
||||||
|
let thrown: unknown;
|
||||||
|
try {
|
||||||
|
if (operation === 'receive-record') socket.message('frame');
|
||||||
|
else if (operation === 'error-record') socket.onerror?.({} as Event);
|
||||||
|
else h.transport.send('frame');
|
||||||
|
} catch (error) { thrown = error; }
|
||||||
|
assert.equal(h.faults.length, 1);
|
||||||
|
assert.equal(thrown, observed[0]);
|
||||||
|
assert.equal(thrown, h.faults[0]);
|
||||||
|
assert.ok(thrown instanceof Error);
|
||||||
|
const errors = (thrown as Error & { errors: unknown[] }).errors;
|
||||||
|
assert.equal(errors.length, 2);
|
||||||
|
assert.equal(errors[0], primary);
|
||||||
|
assert.equal(errors[1], cleanup);
|
||||||
|
assert.throws(() => h.transport.send('late'), /open/i);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user