1
0
mirror of https://github.com/matrix-org/matrix-js-sdk.git synced 2025-08-09 10:22:46 +03:00

Refactor MatrixClient.encryptAndSendEvent (#4031)

* Replace `pendingEventEncryption` with a Set

We don't actually need the promise, so no need to save it.

This also fixes a resource leak, where we would leak a Promise and a HashMap
entry on each encrypted event.

* Convert `encryptEventIfNeeded` to async function

This means that it will always return a promise, so `encryptAndSendEvent` can't
tell if we are actually encrypting or not. Hence, also move the
`updatePendingEventStatus` into `encryptEventIfNeeded`.

* Simplify `encryptAndSendEvent`

Rewrite this as async.

* Factor out `MatrixClient.shouldEncryptEventForRoom`

* Inline a call to `isRoomEncrypted`

I want to deprecate this thing
This commit is contained in:
Richard van der Hoff
2024-01-26 10:21:33 +00:00
committed by GitHub
parent 35ea144bca
commit 869576747c
4 changed files with 129 additions and 94 deletions

View File

@@ -65,7 +65,7 @@ import {
PolicyScope, PolicyScope,
} from "../../src/models/invites-ignorer"; } from "../../src/models/invites-ignorer";
import { IOlmDevice } from "../../src/crypto/algorithms/megolm"; import { IOlmDevice } from "../../src/crypto/algorithms/megolm";
import { QueryDict } from "../../src/utils"; import { defer, QueryDict } from "../../src/utils";
import { SyncState } from "../../src/sync"; import { SyncState } from "../../src/sync";
import * as featureUtils from "../../src/feature"; import * as featureUtils from "../../src/feature";
import { StubStore } from "../../src/store/stub"; import { StubStore } from "../../src/store/stub";
@@ -1453,6 +1453,8 @@ describe("MatrixClient", function () {
hasEncryptionStateEvent: jest.fn().mockReturnValue(true), hasEncryptionStateEvent: jest.fn().mockReturnValue(true),
} as unknown as Room; } as unknown as Room;
let mockCrypto: Mocked<Crypto>;
let event: MatrixEvent; let event: MatrixEvent;
beforeEach(async () => { beforeEach(async () => {
event = new MatrixEvent({ event = new MatrixEvent({
@@ -1467,11 +1469,12 @@ describe("MatrixClient", function () {
expect(getRoomId).toEqual(roomId); expect(getRoomId).toEqual(roomId);
return mockRoom; return mockRoom;
}; };
client.crypto = client["cryptoBackend"] = {
// mock crypto mockCrypto = {
encryptEvent: () => new Promise(() => {}), encryptEvent: jest.fn(),
stop: jest.fn(), stop: jest.fn(),
} as unknown as Crypto; } as unknown as Mocked<Crypto>;
client.crypto = client["cryptoBackend"] = mockCrypto;
}); });
function assertCancelled() { function assertCancelled() {
@@ -1488,12 +1491,21 @@ describe("MatrixClient", function () {
}); });
it("should cancel an event which is encrypting", async () => { it("should cancel an event which is encrypting", async () => {
const encryptEventDefer = defer();
mockCrypto.encryptEvent.mockReturnValue(encryptEventDefer.promise);
const statusPromise = testUtils.emitPromise(event, "Event.status");
// @ts-ignore protected method access // @ts-ignore protected method access
client.encryptAndSendEvent(mockRoom, event); const encryptAndSendPromise = client.encryptAndSendEvent(mockRoom, event);
await testUtils.emitPromise(event, "Event.status"); await statusPromise;
expect(event.status).toBe(EventStatus.ENCRYPTING); expect(event.status).toBe(EventStatus.ENCRYPTING);
client.cancelPendingEvent(event); client.cancelPendingEvent(event);
assertCancelled(); assertCancelled();
// now let the encryption complete, and check that the message is not sent.
encryptEventDefer.resolve();
await encryptAndSendPromise;
assertCancelled();
}); });
it("should cancel an event which is not sent", () => { it("should cancel an event which is not sent", () => {

View File

@@ -265,6 +265,7 @@ describe.each([[StoreType.Memory], [StoreType.IndexedDB]])("queueToDevice (%s st
}); });
const mockRoom = { const mockRoom = {
updatePendingEvent: jest.fn(), updatePendingEvent: jest.fn(),
hasEncryptionStateEvent: jest.fn().mockReturnValue(false),
} as unknown as Room; } as unknown as Room;
client.resendEvent(dummyEvent, mockRoom); client.resendEvent(dummyEvent, mockRoom);

View File

@@ -1305,7 +1305,13 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
protected txnCtr = 0; protected txnCtr = 0;
protected mediaHandler = new MediaHandler(this); protected mediaHandler = new MediaHandler(this);
protected sessionId: string; protected sessionId: string;
protected pendingEventEncryption = new Map<string, Promise<void>>();
/** IDs of events which are currently being encrypted.
*
* This is part of the cancellation mechanism: if the event is no longer listed here when encryption completes,
* that tells us that it has been cancelled, and we should not send it.
*/
private eventsBeingEncrypted = new Set<string>();
private useE2eForGroupCall = true; private useE2eForGroupCall = true;
private toDeviceMessageQueue: ToDeviceMessageQueue; private toDeviceMessageQueue: ToDeviceMessageQueue;
@@ -4448,9 +4454,10 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
throw new Error("cannot cancel an event with status " + event.status); throw new Error("cannot cancel an event with status " + event.status);
} }
// if the event is currently being encrypted then // If the event is currently being encrypted then remove it from the pending list, to indicate that it should
// not be sent.
if (event.status === EventStatus.ENCRYPTING) { if (event.status === EventStatus.ENCRYPTING) {
this.pendingEventEncryption.delete(event.getId()!); this.eventsBeingEncrypted.delete(event.getId()!);
} else if (this.scheduler && event.status === EventStatus.QUEUED) { } else if (this.scheduler && event.status === EventStatus.QUEUED) {
// tell the scheduler to forget about it, if it's queued // tell the scheduler to forget about it, if it's queued
this.scheduler.removeEventFromQueue(event); this.scheduler.removeEventFromQueue(event);
@@ -4749,96 +4756,102 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
* encrypts the event if necessary; adds the event to the queue, or sends it; marks the event as sent/unsent * encrypts the event if necessary; adds the event to the queue, or sends it; marks the event as sent/unsent
* @returns returns a promise which resolves with the result of the send request * @returns returns a promise which resolves with the result of the send request
*/ */
protected encryptAndSendEvent(room: Room | null, event: MatrixEvent): Promise<ISendEventResponse> { protected async encryptAndSendEvent(room: Room | null, event: MatrixEvent): Promise<ISendEventResponse> {
let cancelled = false; try {
// Add an extra Promise.resolve() to turn synchronous exceptions into promise rejections, let cancelled: boolean;
// so that we can handle synchronous and asynchronous exceptions with the this.eventsBeingEncrypted.add(event.getId()!);
// same code path. try {
return Promise.resolve() await this.encryptEventIfNeeded(event, room ?? undefined);
.then(() => { } finally {
const encryptionPromise = this.encryptEventIfNeeded(event, room ?? undefined); cancelled = !this.eventsBeingEncrypted.delete(event.getId()!);
if (!encryptionPromise) return null; // doesn't need encryption }
this.pendingEventEncryption.set(event.getId()!, encryptionPromise); if (cancelled) {
this.updatePendingEventStatus(room, event, EventStatus.ENCRYPTING); // cancelled via MatrixClient::cancelPendingEvent
return encryptionPromise.then(() => { return {} as ISendEventResponse;
if (!this.pendingEventEncryption.has(event.getId()!)) { }
// cancelled via MatrixClient::cancelPendingEvent
cancelled = true;
return;
}
this.updatePendingEventStatus(room, event, EventStatus.SENDING);
});
})
.then(() => {
if (cancelled) return {} as ISendEventResponse;
let promise: Promise<ISendEventResponse> | null = null;
if (this.scheduler) {
// if this returns a promise then the scheduler has control now and will
// resolve/reject when it is done. Internally, the scheduler will invoke
// processFn which is set to this._sendEventHttpRequest so the same code
// path is executed regardless.
promise = this.scheduler.queueEvent(event);
if (promise && this.scheduler.getQueueForEvent(event)!.length > 1) {
// event is processed FIFO so if the length is 2 or more we know
// this event is stuck behind an earlier event.
this.updatePendingEventStatus(room, event, EventStatus.QUEUED);
}
}
if (!promise) { // encryptEventIfNeeded may have updated the status from SENDING to ENCRYPTING. If so, we need
promise = this.sendEventHttpRequest(event); // to put it back.
if (room) { if (event.status === EventStatus.ENCRYPTING) {
promise = promise.then((res) => { this.updatePendingEventStatus(room, event, EventStatus.SENDING);
room.updatePendingEvent(event, EventStatus.SENT, res["event_id"]); }
return res;
});
}
}
return promise; let promise: Promise<ISendEventResponse> | null = null;
}) if (this.scheduler) {
.catch((err) => { // if this returns a promise then the scheduler has control now and will
this.logger.error("Error sending event", err.stack || err); // resolve/reject when it is done. Internally, the scheduler will invoke
try { // processFn which is set to this._sendEventHttpRequest so the same code
// set the error on the event before we update the status: // path is executed regardless.
// updating the status emits the event, so the state should be promise = this.scheduler.queueEvent(event);
// consistent at that point. if (promise && this.scheduler.getQueueForEvent(event)!.length > 1) {
event.error = err; // event is processed FIFO so if the length is 2 or more we know
this.updatePendingEventStatus(room, event, EventStatus.NOT_SENT); // this event is stuck behind an earlier event.
} catch (e) { this.updatePendingEventStatus(room, event, EventStatus.QUEUED);
this.logger.error("Exception in error handler!", (<Error>e).stack || err);
} }
if (err instanceof MatrixError) { }
err.event = event;
if (!promise) {
promise = this.sendEventHttpRequest(event);
if (room) {
promise = promise.then((res) => {
room.updatePendingEvent(event, EventStatus.SENT, res["event_id"]);
return res;
});
} }
throw err; }
});
return await promise;
} catch (err) {
this.logger.error("Error sending event", err);
try {
// set the error on the event before we update the status:
// updating the status emits the event, so the state should be
// consistent at that point.
event.error = <MatrixError>err;
this.updatePendingEventStatus(room, event, EventStatus.NOT_SENT);
} catch (e) {
this.logger.error("Exception in error handler!", e);
}
if (err instanceof MatrixError) {
err.event = event;
}
throw err;
}
} }
private encryptEventIfNeeded(event: MatrixEvent, room?: Room): Promise<void> | null { private async encryptEventIfNeeded(event: MatrixEvent, room?: Room): Promise<void> {
// If the room is unknown, we cannot encrypt for it
if (!room) return;
if (!this.shouldEncryptEventForRoom(event, room)) return;
if (!this.cryptoBackend && this.usingExternalCrypto) {
// The client has opted to allow sending messages to encrypted
// rooms even if the room is encrypted, and we haven't set up
// crypto. This is useful for users of matrix-org/pantalaimon
return;
}
if (!this.cryptoBackend) {
throw new Error("This room is configured to use encryption, but your client does not support encryption.");
}
this.updatePendingEventStatus(room, event, EventStatus.ENCRYPTING);
await this.cryptoBackend.encryptEvent(event, room);
}
/**
* Determine whether a given event should be encrypted when we send it to the given room.
*
* This takes into account event type and room configuration.
*/
private shouldEncryptEventForRoom(event: MatrixEvent, room: Room): boolean {
if (event.isEncrypted()) { if (event.isEncrypted()) {
// this event has already been encrypted; this happens if the // this event has already been encrypted; this happens if the
// encryption step succeeded, but the send step failed on the first // encryption step succeeded, but the send step failed on the first
// attempt. // attempt.
return null; return false;
}
if (event.isRedaction()) {
// Redactions do not support encryption in the spec at this time,
// whilst it mostly worked in some clients, it wasn't compliant.
return null;
}
if (!room || !this.isRoomEncrypted(event.getRoomId()!)) {
return null;
}
if (!this.cryptoBackend && this.usingExternalCrypto) {
// The client has opted to allow sending messages to encrypted
// rooms even if the room is encrypted, and we haven't setup
// crypto. This is useful for users of matrix-org/pantalaimon
return null;
} }
if (event.getType() === EventType.Reaction) { if (event.getType() === EventType.Reaction) {
@@ -4852,14 +4865,23 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
// The reaction key / content / emoji value does warrant encrypting, but // The reaction key / content / emoji value does warrant encrypting, but
// this will be handled separately by encrypting just this value. // this will be handled separately by encrypting just this value.
// See https://github.com/matrix-org/matrix-doc/pull/1849#pullrequestreview-248763642 // See https://github.com/matrix-org/matrix-doc/pull/1849#pullrequestreview-248763642
return null; return false;
} }
if (!this.cryptoBackend) { if (event.isRedaction()) {
throw new Error("This room is configured to use encryption, but your client does not support encryption."); // Redactions do not support encryption in the spec at this time.
// Whilst it mostly worked in some clients, it wasn't compliant.
return false;
} }
return this.cryptoBackend.encryptEvent(event, room); // If the room has an m.room.encryption event, we should encrypt.
if (room.hasEncryptionStateEvent()) return true;
// If we have a crypto impl, and *it* thinks we should encrypt, then we should.
if (this.crypto?.isRoomEncrypted(room.roomId)) return true;
// Otherwise, no need to encrypt.
return false;
} }
/** /**

View File

@@ -350,7 +350,7 @@ export class MatrixEvent extends TypedEventEmitter<MatrixEventEmittedEvents, Mat
/** /**
* most recent error associated with sending the event, if any * most recent error associated with sending the event, if any
* @privateRemarks * @privateRemarks
* Should be read-only * Should be read-only. May not be a MatrixError.
*/ */
public error: MatrixError | null = null; public error: MatrixError | null = null;
/** /**