Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

fix: setupControllerConnection outstream end event listener #26141

Merged
merged 17 commits into from
Jul 29, 2024
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
43 changes: 34 additions & 9 deletions app/scripts/metamask-controller.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import EventEmitter from 'events';
import { pipeline } from 'readable-stream';
import { finished, pipeline } from 'readable-stream';
import {
AssetsContractController,
CurrencyRateController,
Expand Down Expand Up @@ -5072,14 +5072,39 @@ export default class MetamaskController extends EventEmitter {
this.once('startUISync', startUISync);
}

outStream.on('end', () => {
this.activeControllerConnections -= 1;
this.emit(
'controllerConnectionChanged',
this.activeControllerConnections,
);
this.removeListener('update', handleUpdate);
});
const outstreamEndHandler = () => {
if (!outStream.mmFinished) {
this.activeControllerConnections -= 1;
this.emit(
'controllerConnectionChanged',
this.activeControllerConnections,
);
outStream.mmFinished = true;
this.removeListener('update', handleUpdate);
}
};

// The presence of both of the below handlers may be redundant.
// After upgrading metamask/object-multiples to v2.0.0, which included
// an upgrade of readable-streams from v2 to v3, we saw that the
// `outStream.on('end'` handler was almost never being called. This seems to
// related to how v3 handles errors vs how v2 handles errors; there
// are "premature close" errors in both cases, although in the case
// of v2 they don't prevent `outStream.on('end'` from being called.
// At the time that this comment was committed, it was known that we
// need to investigate and resolve the underlying error, however,
// for expediency, we are not addressing them at this time. Instead, we
// can observe that `readableStream.finished` preserves the same
// functionality as we had when we relied on readable-stream v2. Meanwhile,
// the `outStream.on('end')` handler was observed to have been called at least once.
// In an abundance of caution to prevent against unexpected future behavioral changes in
// streams implementations, we redundantly use multiple paths to attach the same event handler.
// The outstreamEndHandler therefore needs to be idempotent, which introduces the `mmFinished` property.

outStream.mmFinished = false;
finished(outStream, outstreamEndHandler);
outStream.once('close', outstreamEndHandler);
outStream.once('end', outstreamEndHandler);
}

/**
Expand Down
280 changes: 280 additions & 0 deletions app/scripts/metamask-controller.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ import {
RatesController,
TokenListController,
} from '@metamask/assets-controllers';
import ObjectMultiplex from '@metamask/object-multiplex';
import { TrezorKeyring } from '@metamask/eth-trezor-keyring';
import { LedgerKeyring } from '@metamask/eth-ledger-bridge-keyring';
import { NETWORK_TYPES } from '../../shared/constants/network';
Expand Down Expand Up @@ -1406,9 +1407,288 @@ describe('MetaMaskController', () => {
});

metamaskController.setupTrustedCommunication(streamTest, messageSender);

await promise;
streamTest.end();
});

it('uses a new multiplex to set up a connection', () => {
jest.spyOn(metamaskController, 'setupControllerConnection');

const streamTest = createThroughStream((chunk, _, cb) => {
cb(chunk);
});

metamaskController.setupTrustedCommunication(streamTest, {});

expect(metamaskController.setupControllerConnection).toHaveBeenCalled();
expect(
metamaskController.setupControllerConnection,
).toHaveBeenCalledWith(
expect.objectContaining({
_name: 'controller',
_parent: expect.any(ObjectMultiplex),
}),
);
});

const createTestStream = () => {
const {
promise: onFinishedCallbackPromise,
resolve: onFinishedCallbackResolve,
} = deferredPromise();
const { promise: onStreamEndPromise, resolve: onStreamEndResolve } =
deferredPromise();
const testStream = createThroughStream((chunk, _, cb) => {
expect(chunk.name).toStrictEqual('controller');
onStreamEndResolve();
cb();
});

return {
onFinishedCallbackPromise,
onStreamEndPromise,
onFinishedCallbackResolve,
testStream,
};
};

it('sets up a controller connection which emits a controllerConnectionChanged event when the controller connection is created and ended, and activeControllerConnections are updated accordingly', async () => {
const mockControllerConnectionChangedHandler = jest.fn();

const {
onStreamEndPromise,
onFinishedCallbackPromise,
onFinishedCallbackResolve,
testStream,
} = createTestStream();

metamaskController.on(
'controllerConnectionChanged',
(activeControllerConnections) => {
mockControllerConnectionChangedHandler(activeControllerConnections);
if (
mockControllerConnectionChangedHandler.mock.calls.length === 2
) {
onFinishedCallbackResolve();
}
},
);

expect(metamaskController.activeControllerConnections).toBe(0);

metamaskController.setupTrustedCommunication(testStream, {});

expect(mockControllerConnectionChangedHandler).toHaveBeenCalledTimes(1);
expect(mockControllerConnectionChangedHandler).toHaveBeenLastCalledWith(
1,
);

expect(metamaskController.activeControllerConnections).toBe(1);

await onStreamEndPromise;
testStream.end();

await onFinishedCallbackPromise;

expect(metamaskController.activeControllerConnections).toBe(0);
expect(mockControllerConnectionChangedHandler).toHaveBeenCalledTimes(2);
expect(mockControllerConnectionChangedHandler).toHaveBeenLastCalledWith(
0,
);
});

it('can be called multiple times to set up multiple controller connections, which can be ended independently', async () => {
const mockControllerConnectionChangedHandler = jest.fn();

const testStreams = [
createTestStream(),
createTestStream(),
createTestStream(),
createTestStream(),
createTestStream(),
];
metamaskController.on(
'controllerConnectionChanged',
(activeControllerConnections) => {
const initialChangeHandlerCallCount =
mockControllerConnectionChangedHandler.mock.calls.length;
mockControllerConnectionChangedHandler(activeControllerConnections);

if (
initialChangeHandlerCallCount === 5 &&
activeControllerConnections === 4
) {
testStreams[1].onFinishedCallbackResolve();
}
if (
initialChangeHandlerCallCount === 7 &&
activeControllerConnections === 2
) {
testStreams[3].onFinishedCallbackResolve();
testStreams[4].onFinishedCallbackResolve();
}
if (
initialChangeHandlerCallCount === 9 &&
activeControllerConnections === 0
) {
testStreams[2].onFinishedCallbackResolve();
testStreams[0].onFinishedCallbackResolve();
}
},
);

metamaskController.setupTrustedCommunication(
testStreams[0].testStream,
{},
);
metamaskController.setupTrustedCommunication(
testStreams[1].testStream,
{},
);
metamaskController.setupTrustedCommunication(
testStreams[2].testStream,
{},
);
metamaskController.setupTrustedCommunication(
testStreams[3].testStream,
{},
);
metamaskController.setupTrustedCommunication(
testStreams[4].testStream,
{},
);

expect(metamaskController.activeControllerConnections).toBe(5);

await testStreams[1].promise;
testStreams[1].testStream.end();

await testStreams[1].onFinishedCallbackPromise;

expect(metamaskController.activeControllerConnections).toBe(4);

await testStreams[3].promise;
testStreams[3].testStream.end();

await testStreams[4].promise;
testStreams[4].testStream.end();

await testStreams[3].onFinishedCallbackPromise;
await testStreams[4].onFinishedCallbackPromise;

expect(metamaskController.activeControllerConnections).toBe(2);

await testStreams[2].promise;
testStreams[2].testStream.end();

await testStreams[0].promise;
testStreams[0].testStream.end();

await testStreams[2].onFinishedCallbackPromise;
await testStreams[0].onFinishedCallbackPromise;

expect(metamaskController.activeControllerConnections).toBe(0);
});

// this test could be improved by testing for actual behavior of handlers,
// without touching rawListeners from test
it('attaches listeners for trusted communication streams and removes them as streams close', async () => {
jest
.spyOn(metamaskController, 'triggerNetworkrequests')
.mockImplementation();
jest
.spyOn(metamaskController.onboardingController.store, 'getState')
.mockReturnValue({ completedOnboarding: true });
const mockControllerConnectionChangedHandler = jest.fn();

const testStreams = [
createTestStream(),
createTestStream(2),
createTestStream(3),
createTestStream(4),
createTestStream(5),
];
const baseUpdateListenerCount =
metamaskController.rawListeners('update').length;

metamaskController.on(
'controllerConnectionChanged',
(activeControllerConnections) => {
const initialChangeHandlerCallCount =
mockControllerConnectionChangedHandler.mock.calls.length;
mockControllerConnectionChangedHandler(activeControllerConnections);
if (
initialChangeHandlerCallCount === 8 &&
activeControllerConnections === 1
) {
testStreams[1].onFinishedCallbackResolve();
testStreams[3].onFinishedCallbackResolve();
testStreams[4].onFinishedCallbackResolve();
testStreams[2].onFinishedCallbackResolve();
}
if (
initialChangeHandlerCallCount === 9 &&
activeControllerConnections === 0
) {
testStreams[0].onFinishedCallbackResolve();
}
},
);

metamaskController.setupTrustedCommunication(
testStreams[0].testStream,
{},
);
metamaskController.setupTrustedCommunication(
testStreams[1].testStream,
{},
);
metamaskController.setupTrustedCommunication(
testStreams[2].testStream,
{},
);
metamaskController.setupTrustedCommunication(
testStreams[3].testStream,
{},
);
metamaskController.setupTrustedCommunication(
testStreams[4].testStream,
{},
);

await testStreams[1].promise;

expect(metamaskController.rawListeners('update')).toHaveLength(
baseUpdateListenerCount + 5,
);

testStreams[1].testStream.end();
await testStreams[3].promise;
testStreams[3].testStream.end();
testStreams[3].testStream.end();

await testStreams[4].promise;
testStreams[4].testStream.end();
await testStreams[2].promise;
testStreams[2].testStream.end();
await testStreams[1].onFinishedCallbackPromise;
await testStreams[3].onFinishedCallbackPromise;
await testStreams[4].onFinishedCallbackPromise;
await testStreams[2].onFinishedCallbackPromise;
expect(metamaskController.rawListeners('update')).toHaveLength(
baseUpdateListenerCount + 1,
);

await testStreams[0].promise;
testStreams[0].testStream.end();

await testStreams[0].onFinishedCallbackPromise;

expect(metamaskController.rawListeners('update')).toHaveLength(
baseUpdateListenerCount,
);
});
});

describe('#markPasswordForgotten', () => {
Expand Down
Loading