Skip to content

Commit 70656e9

Browse files
authored
Merge pull request #3035 from clarkf/megolm-cancellation
Reduce Megolm blocking and add cancellation
2 parents 7ed787b + 40a4c8d commit 70656e9

File tree

4 files changed

+187
-20
lines changed

4 files changed

+187
-20
lines changed

spec/unit/crypto/algorithms/megolm.spec.ts

+90-5
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
/*
2-
Copyright 2022 The Matrix.org Foundation C.I.C.
2+
Copyright 2022 - 2023 The Matrix.org Foundation C.I.C.
33
44
Licensed under the Apache License, Version 2.0 (the "License");
55
you may not use this file except in compliance with the License.
@@ -16,6 +16,7 @@ limitations under the License.
1616

1717
import { mocked, MockedObject } from "jest-mock";
1818

19+
import type { DeviceInfoMap } from "../../../../src/crypto/DeviceList";
1920
import "../../../olm-loader";
2021
import type { OutboundGroupSession } from "@matrix-org/olm";
2122
import * as algorithms from "../../../../src/crypto/algorithms";
@@ -33,6 +34,7 @@ import { ClientEvent, MatrixClient, RoomMember } from "../../../../src";
3334
import { DeviceInfo, IDevice } from "../../../../src/crypto/deviceinfo";
3435
import { DeviceTrustLevel } from "../../../../src/crypto/CrossSigning";
3536
import { MegolmEncryption as MegolmEncryptionClass } from "../../../../src/crypto/algorithms/megolm";
37+
import { sleep } from "../../../../src/utils";
3638

3739
const MegolmDecryption = algorithms.DECRYPTION_CLASSES.get("m.megolm.v1.aes-sha2")!;
3840
const MegolmEncryption = algorithms.ENCRYPTION_CLASSES.get("m.megolm.v1.aes-sha2")!;
@@ -58,6 +60,12 @@ describe("MegolmDecryption", function () {
5860

5961
beforeEach(async function () {
6062
mockCrypto = testUtils.mock(Crypto, "Crypto") as MockedObject<Crypto>;
63+
64+
// @ts-ignore assigning to readonly prop
65+
mockCrypto.backupManager = {
66+
backupGroupSession: () => {},
67+
};
68+
6169
mockBaseApis = {
6270
claimOneTimeKeys: jest.fn(),
6371
sendToDevice: jest.fn(),
@@ -314,10 +322,6 @@ describe("MegolmDecryption", function () {
314322
let olmDevice: OlmDevice;
315323

316324
beforeEach(async () => {
317-
// @ts-ignore assigning to readonly prop
318-
mockCrypto.backupManager = {
319-
backupGroupSession: () => {},
320-
};
321325
const cryptoStore = new MemoryCryptoStore();
322326

323327
olmDevice = new OlmDevice(cryptoStore);
@@ -515,6 +519,87 @@ describe("MegolmDecryption", function () {
515519
});
516520
});
517521

522+
describe("prepareToEncrypt", () => {
523+
let megolm: MegolmEncryptionClass;
524+
let room: jest.Mocked<Room>;
525+
526+
const deviceMap: DeviceInfoMap = {
527+
"user-a": {
528+
"device-a": new DeviceInfo("device-a"),
529+
"device-b": new DeviceInfo("device-b"),
530+
"device-c": new DeviceInfo("device-c"),
531+
},
532+
"user-b": {
533+
"device-d": new DeviceInfo("device-d"),
534+
"device-e": new DeviceInfo("device-e"),
535+
"device-f": new DeviceInfo("device-f"),
536+
},
537+
"user-c": {
538+
"device-g": new DeviceInfo("device-g"),
539+
"device-h": new DeviceInfo("device-h"),
540+
"device-i": new DeviceInfo("device-i"),
541+
},
542+
};
543+
544+
beforeEach(() => {
545+
room = testUtils.mock(Room, "Room") as jest.Mocked<Room>;
546+
room.getEncryptionTargetMembers.mockImplementation(async () => [
547+
new RoomMember(room.roomId, "@user:example.org"),
548+
]);
549+
room.getBlacklistUnverifiedDevices.mockReturnValue(false);
550+
551+
mockCrypto.downloadKeys.mockImplementation(async () => deviceMap);
552+
553+
mockCrypto.checkDeviceTrust.mockImplementation(() => new DeviceTrustLevel(true, true, true, true));
554+
555+
const olmDevice = new OlmDevice(new MemoryCryptoStore());
556+
megolm = new MegolmEncryptionClass({
557+
userId: "@user:id",
558+
deviceId: "12345",
559+
crypto: mockCrypto,
560+
olmDevice,
561+
baseApis: mockBaseApis,
562+
roomId: room.roomId,
563+
config: {
564+
algorithm: "m.megolm.v1.aes-sha2",
565+
rotation_period_ms: 9_999_999,
566+
},
567+
});
568+
});
569+
570+
it("checks each device", async () => {
571+
megolm.prepareToEncrypt(room);
572+
//@ts-ignore private member access, gross
573+
await megolm.encryptionPreparation?.promise;
574+
575+
for (const userId in deviceMap) {
576+
for (const deviceId in deviceMap[userId]) {
577+
expect(mockCrypto.checkDeviceTrust).toHaveBeenCalledWith(userId, deviceId);
578+
}
579+
}
580+
});
581+
582+
it("defers before completing", async () => {
583+
megolm.prepareToEncrypt(room);
584+
// Ensure that `Crypto#checkDeviceTrust` has been called *fewer*
585+
// than the full nine times, after yielding once.
586+
await sleep(0);
587+
const callCount = mockCrypto.checkDeviceTrust.mock.calls.length;
588+
expect(callCount).toBeLessThan(9);
589+
});
590+
591+
it("is cancellable", async () => {
592+
const stop = megolm.prepareToEncrypt(room);
593+
594+
const before = mockCrypto.checkDeviceTrust.mock.calls.length;
595+
stop();
596+
597+
// Ensure that no more devices were checked after cancellation.
598+
await sleep(10);
599+
expect(mockCrypto.checkDeviceTrust).toHaveBeenCalledTimes(before);
600+
});
601+
});
602+
518603
it("notifies devices that have been blocked", async function () {
519604
const aliceClient = new TestClient("@alice:example.com", "alicedevice").client;
520605
const bobClient1 = new TestClient("@bob:example.com", "bobdevice1").client;

spec/unit/utils.spec.ts

+34
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,19 @@
1+
/*
2+
Copyright 2023 The Matrix.org Foundation C.I.C.
3+
4+
Licensed under the Apache License, Version 2.0 (the "License");
5+
you may not use this file except in compliance with the License.
6+
You may obtain a copy of the License at
7+
8+
http://www.apache.org/licenses/LICENSE-2.0
9+
10+
Unless required by applicable law or agreed to in writing, software
11+
distributed under the License is distributed on an "AS IS" BASIS,
12+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
See the License for the specific language governing permissions and
14+
limitations under the License.
15+
*/
16+
117
import * as utils from "../../src/utils";
218
import {
319
alphabetPad,
@@ -587,4 +603,22 @@ describe("utils", function () {
587603
expect(utils.isSupportedReceiptType("this is a receipt type")).toBeFalsy();
588604
});
589605
});
606+
607+
describe("sleep", () => {
608+
it("resolves", async () => {
609+
await utils.sleep(0);
610+
});
611+
612+
it("resolves with the provided value", async () => {
613+
const expected = Symbol("hi");
614+
const result = await utils.sleep(0, expected);
615+
expect(result).toBe(expected);
616+
});
617+
});
618+
619+
describe("immediate", () => {
620+
it("resolves", async () => {
621+
await utils.immediate();
622+
});
623+
});
590624
});

src/crypto/algorithms/megolm.ts

+52-12
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
/*
2-
Copyright 2015 - 2021 The Matrix.org Foundation C.I.C.
2+
Copyright 2015 - 2021, 2023 The Matrix.org Foundation C.I.C.
33
44
Licensed under the Apache License, Version 2.0 (the "License");
55
you may not use this file except in compliance with the License.
@@ -43,6 +43,7 @@ import { IMegolmEncryptedContent, IncomingRoomKeyRequest, IEncryptedContent } fr
4343
import { RoomKeyRequestState } from "../OutgoingRoomKeyRequestManager";
4444
import { OlmGroupSessionExtraData } from "../../@types/crypto";
4545
import { MatrixError } from "../../http-api";
46+
import { immediate } from "../../utils";
4647

4748
// determine whether the key can be shared with invitees
4849
export function isRoomSharedHistory(room: Room): boolean {
@@ -73,7 +74,6 @@ export interface IOlmDevice<T = DeviceInfo> {
7374
deviceInfo: T;
7475
}
7576

76-
/* eslint-disable camelcase */
7777
export interface IOutboundGroupSessionKey {
7878
chain_index: number;
7979
key: string;
@@ -106,7 +106,6 @@ interface IPayload extends Partial<IMessage> {
106106
algorithm?: string;
107107
sender_key?: string;
108108
}
109-
/* eslint-enable camelcase */
110109

111110
interface SharedWithData {
112111
// The identity key of the device we shared with
@@ -223,6 +222,7 @@ export class MegolmEncryption extends EncryptionAlgorithm {
223222
private encryptionPreparation?: {
224223
promise: Promise<void>;
225224
startTime: number;
225+
cancel: () => void;
226226
};
227227

228228
protected readonly roomId: string;
@@ -974,30 +974,36 @@ export class MegolmEncryption extends EncryptionAlgorithm {
974974
* send, in order to speed up sending of the message.
975975
*
976976
* @param room - the room the event is in
977+
* @returns A function that, when called, will stop the preparation
977978
*/
978-
public prepareToEncrypt(room: Room): void {
979+
public prepareToEncrypt(room: Room): () => void {
979980
if (room.roomId !== this.roomId) {
980981
throw new Error("MegolmEncryption.prepareToEncrypt called on unexpected room");
981982
}
982983

983984
if (this.encryptionPreparation != null) {
984985
// We're already preparing something, so don't do anything else.
985-
// FIXME: check if we need to restart
986-
// (https://github.com/matrix-org/matrix-js-sdk/issues/1255)
987986
const elapsedTime = Date.now() - this.encryptionPreparation.startTime;
988987
this.prefixedLogger.debug(
989988
`Already started preparing to encrypt for this room ${elapsedTime}ms ago, skipping`,
990989
);
991-
return;
990+
return this.encryptionPreparation.cancel;
992991
}
993992

994993
this.prefixedLogger.debug("Preparing to encrypt events");
995994

995+
let cancelled = false;
996+
const isCancelled = (): boolean => cancelled;
997+
996998
this.encryptionPreparation = {
997999
startTime: Date.now(),
9981000
promise: (async (): Promise<void> => {
9991001
try {
1000-
const [devicesInRoom, blocked] = await this.getDevicesInRoom(room);
1002+
// Attempt to enumerate the devices in room, and gracefully
1003+
// handle cancellation if it occurs.
1004+
const getDevicesResult = await this.getDevicesInRoom(room, false, isCancelled);
1005+
if (getDevicesResult === null) return;
1006+
const [devicesInRoom, blocked] = getDevicesResult;
10011007

10021008
if (this.crypto.globalErrorOnUnknownDevices) {
10031009
// Drop unknown devices for now. When the message gets sent, we'll
@@ -1016,7 +1022,16 @@ export class MegolmEncryption extends EncryptionAlgorithm {
10161022
delete this.encryptionPreparation;
10171023
}
10181024
})(),
1025+
1026+
cancel: (): void => {
1027+
// The caller has indicated that the process should be cancelled,
1028+
// so tell the promise that we'd like to halt, and reset the preparation state.
1029+
cancelled = true;
1030+
delete this.encryptionPreparation;
1031+
},
10191032
};
1033+
1034+
return this.encryptionPreparation.cancel;
10201035
}
10211036

10221037
/**
@@ -1165,17 +1180,32 @@ export class MegolmEncryption extends EncryptionAlgorithm {
11651180
*
11661181
* @param forceDistributeToUnverified - if set to true will include the unverified devices
11671182
* even if setting is set to block them (useful for verification)
1183+
* @param isCancelled - will cause the procedure to abort early if and when it starts
1184+
* returning `true`. If omitted, cancellation won't happen.
11681185
*
1169-
* @returns Promise which resolves to an array whose
1170-
* first element is a map from userId to deviceId to deviceInfo indicating
1186+
* @returns Promise which resolves to `null`, or an array whose
1187+
* first element is a {@link DeviceInfoMap} indicating
11711188
* the devices that messages should be encrypted to, and whose second
11721189
* element is a map from userId to deviceId to data indicating the devices
1173-
* that are in the room but that have been blocked
1190+
* that are in the room but that have been blocked.
1191+
* If `isCancelled` is provided and returns `true` while processing, `null`
1192+
* will be returned.
1193+
* If `isCancelled` is not provided, the Promise will never resolve to `null`.
11741194
*/
1195+
private async getDevicesInRoom(
1196+
room: Room,
1197+
forceDistributeToUnverified?: boolean,
1198+
): Promise<[DeviceInfoMap, IBlockedMap]>;
1199+
private async getDevicesInRoom(
1200+
room: Room,
1201+
forceDistributeToUnverified?: boolean,
1202+
isCancelled?: () => boolean,
1203+
): Promise<null | [DeviceInfoMap, IBlockedMap]>;
11751204
private async getDevicesInRoom(
11761205
room: Room,
11771206
forceDistributeToUnverified = false,
1178-
): Promise<[DeviceInfoMap, IBlockedMap]> {
1207+
isCancelled?: () => boolean,
1208+
): Promise<null | [DeviceInfoMap, IBlockedMap]> {
11791209
const members = await room.getEncryptionTargetMembers();
11801210
this.prefixedLogger.debug(
11811211
`Encrypting for users (shouldEncryptForInvitedMembers: ${room.shouldEncryptForInvitedMembers()}):`,
@@ -1201,6 +1231,11 @@ export class MegolmEncryption extends EncryptionAlgorithm {
12011231
// See https://github.com/vector-im/element-web/issues/2305 for details.
12021232
const devices = await this.crypto.downloadKeys(roomMembers, false);
12031233
const blocked: IBlockedMap = {};
1234+
1235+
if (isCancelled?.() === true) {
1236+
return null;
1237+
}
1238+
12041239
// remove any blocked devices
12051240
for (const userId in devices) {
12061241
if (!devices.hasOwnProperty(userId)) {
@@ -1213,6 +1248,11 @@ export class MegolmEncryption extends EncryptionAlgorithm {
12131248
continue;
12141249
}
12151250

1251+
// Yield prior to checking each device so that we don't block
1252+
// updating/rendering for too long.
1253+
// See https://github.com/vector-im/element-web/issues/21612
1254+
if (isCancelled !== undefined) await immediate();
1255+
if (isCancelled?.() === true) return null;
12161256
const deviceTrust = this.crypto.checkDeviceTrust(userId, deviceId);
12171257

12181258
if (

src/utils.ts

+11-3
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,5 @@
11
/*
2-
Copyright 2015, 2016 OpenMarket Ltd
3-
Copyright 2019 The Matrix.org Foundation C.I.C.
2+
Copyright 2015, 2016, 2019, 2023 The Matrix.org Foundation C.I.C.
43
54
Licensed under the Apache License, Version 2.0 (the "License");
65
you may not use this file except in compliance with the License.
@@ -392,13 +391,22 @@ export function ensureNoTrailingSlash(url?: string): string | undefined {
392391
}
393392
}
394393

395-
// Returns a promise which resolves with a given value after the given number of ms
394+
/**
395+
* Returns a promise which resolves with a given value after the given number of ms
396+
*/
396397
export function sleep<T>(ms: number, value?: T): Promise<T> {
397398
return new Promise((resolve) => {
398399
setTimeout(resolve, ms, value);
399400
});
400401
}
401402

403+
/**
404+
* Promise/async version of {@link setImmediate}.
405+
*/
406+
export function immediate(): Promise<void> {
407+
return new Promise(setImmediate);
408+
}
409+
402410
export function isNullOrUndefined(val: any): boolean {
403411
return val === null || val === undefined;
404412
}

0 commit comments

Comments
 (0)