diff --git a/shared/modules/caip-stream.test.ts b/shared/modules/caip-stream.test.ts index a9c737959db1..42610d05f4d5 100644 --- a/shared/modules/caip-stream.test.ts +++ b/shared/modules/caip-stream.test.ts @@ -40,6 +40,18 @@ class MockStream extends Duplex { } } +const createMockPort = () => { + // Minimal chrome.runtime.Port‐like stub + const listeners: (() => void)[] = []; + return { + onDisconnect: { + addListener: (fn: () => void) => listeners.push(fn), + // helper used by tests + _trigger: () => listeners.forEach((fn) => fn()), + }, + }; +}; + describe('CAIP Stream', () => { describe('createCaipStream', () => { it('pipes and unwraps a caip-348 message from source stream to the substream', async () => { @@ -92,5 +104,26 @@ describe('CAIP Stream', () => { await expect(promise).resolves.toBe(undefined); }); + + it('does not emit an error on “Premature close” after graceful shutdown', async () => { + const sourceStream = new MockStream(); + const mockPort = createMockPort(); + (sourceStream as unknown as { _port: typeof mockPort })._port = mockPort; + + const providerStream = createCaipStream(sourceStream); + + const errorSpy = jest.fn(); + providerStream.on('error', errorSpy); + + // Trigger disconnect + ( + mockPort as unknown as { onDisconnect: { _trigger: () => void } } + ).onDisconnect._trigger(); + + // Give the event loop a tick to propagate potential errors + await new Promise((r) => setImmediate(r)); + + expect(errorSpy).not.toHaveBeenCalled(); + }); }); }); diff --git a/shared/modules/caip-stream.ts b/shared/modules/caip-stream.ts index 7bc8f902625d..8e97738f7662 100644 --- a/shared/modules/caip-stream.ts +++ b/shared/modules/caip-stream.ts @@ -65,9 +65,35 @@ export class CaipStream extends Duplex { export const createCaipStream = (portStream: Duplex): Duplex => { const caipStream = new CaipStream(); - pipeline(portStream, caipStream, portStream, (err: Error) => { + /** Cleanly end the CAIP side if the port goes away. */ + const handlePortGone = () => { + // End only once + if ( + !caipStream.substream.destroyed && + !caipStream.substream.writableEnded + ) { + caipStream.substream.end(); + } + }; + + // 1. Listen for tab/iframe shutdown signals + // a. Node-style streams emit 'close' and/or 'end' + portStream.once?.('close', handlePortGone); + portStream.once?.('end', handlePortGone); + + // b. chrome.runtime.Port exposes onDisconnect + // (ExtensionPortStream exposes the raw Port at `._port`) + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const rawPort: chrome.runtime.Port | undefined = (portStream as any)?._port; + rawPort?.onDisconnect?.addListener(handlePortGone); + + // 2. Wire up the full duplex pipeline + pipeline(portStream, caipStream, portStream, (err: Error | null) => { caipStream.substream.destroy(); - console.log('MetaMask CAIP stream', err); + + if (err && err.message !== 'Premature close') { + console.error('MetaMask CAIP stream error:', err); + } }); return caipStream.substream;