From 745b0a1aebe4d18080d1abe1c3ebf644ebea2aff Mon Sep 17 00:00:00 2001 From: Joywayer Date: Sat, 5 Sep 2026 18:32:09 +0800 Subject: [PATCH] fix: report combined local transport failures once --- .../local-login-transport.ts | 31 +++++++----- .../local-platform-login/transport.test.ts | 50 +++++++++++++++++++ 2 files changed, 69 insertions(+), 12 deletions(-) diff --git a/cocoscreator_projects/YouleNexus/assets/scripts/local-platform-login/local-login-transport.ts b/cocoscreator_projects/YouleNexus/assets/scripts/local-platform-login/local-login-transport.ts index 03571b8..444026d 100644 --- a/cocoscreator_projects/YouleNexus/assets/scripts/local-platform-login/local-login-transport.ts +++ b/cocoscreator_projects/YouleNexus/assets/scripts/local-platform-login/local-login-transport.ts @@ -14,6 +14,16 @@ interface LocalLoginTransportOptions { 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. */ export class LocalLoginTransport implements Transport { private socket: WebSocket | null = null; @@ -53,8 +63,7 @@ export class LocalLoginTransport implements Transport { try { this.options.record({ kind: 'transport-receive', data: event.data }); } catch (error) { - this.stopWithFault(error); - throw error; + throw this.stopWithFault(error); } if (this.socket === socket) this.messageCallback?.(event.data); }; @@ -68,8 +77,7 @@ export class LocalLoginTransport implements Transport { try { this.options.record({ kind: 'transport-error', data: event }); } catch (error) { - this.stopWithFault(error); - throw error; + throw this.stopWithFault(error); } if (this.socket !== socket || this.generation !== generation) return; // Invalidate before close: browsers may dispatch close after error. @@ -93,8 +101,7 @@ export class LocalLoginTransport implements Transport { socket.send(frame); this.options.record({ kind: 'transport-send', data: frame }); } catch (error) { - this.stopWithFault(error); - throw error; + throw this.stopWithFault(error); } } @@ -115,17 +122,17 @@ export class LocalLoginTransport implements Transport { onMessage(callback: (frame: string) => void): void { this.messageCallback = callback; } onClose(callback: () => void): void { this.closeCallback = callback; } - private stopWithFault(error: unknown): void { + private stopWithFault(error: unknown): unknown { const socket = this.socket; this.socket = null; - const cleanupErrors: unknown[] = []; + const errors: unknown[] = [error]; try { socket?.close(); } catch (cleanupError) { - cleanupErrors.push(cleanupError); + errors.push(cleanupError); } - this.options.onFault(error); - // Preserve both independent failures, including non-Error thrown values. - for (const cleanupError of cleanupErrors) this.options.onFault(cleanupError); + const fault = errors.length === 1 ? error : new LocalLoginTransportFault(errors); + this.options.onFault(fault); + return fault; } } diff --git a/cocoscreator_projects/framework-tests/local-platform-login/transport.test.ts b/cocoscreator_projects/framework-tests/local-platform-login/transport.test.ts index ab53751..320dda4 100644 --- a/cocoscreator_projects/framework-tests/local-platform-login/transport.test.ts +++ b/cocoscreator_projects/framework-tests/local-platform-login/transport.test.ts @@ -271,3 +271,53 @@ for (const failure of ['record', 'close']) { 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); + }); +}