Skip to content

Commit ef55f1d

Browse files
committed
Feed events to widgets as they are decrypted (even if out of order)
The code that feeds events to widgets tries to enforce that only events from the end of the timeline will be passed through. This is to prevent old, irrelevant events from being passed to widgets as the timeline is back-filled. However, since encrypted events need to be decrypted asynchronously, it's not possible to feed them to a widget in a strictly linear order without introducing some kind of blocking or unreliable delivery. This code has been dropping events when they're decrypted out of order, which we consider to be an undesirable behavior. The solution provided here is that, to reflect the asynchronous nature of decryption, encrypted events that arrive at the end of the timeline will be fed to a widget whenever they finish decrypting, even if this means feeding them out of order. For now we're not aware of any widgets that care about knowing the exact order of events in the timeline, but if such a need reveals itself later, we can explore adding ordering information to this part of the widget API.
1 parent 7b998dc commit ef55f1d

File tree

2 files changed

+65
-8
lines changed

2 files changed

+65
-8
lines changed

src/stores/widgets/StopGapWidget.ts

Lines changed: 17 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -154,7 +154,10 @@ export class StopGapWidget extends EventEmitter {
154154
private kind: WidgetKind;
155155
private readonly virtual: boolean;
156156
private readUpToMap: { [roomId: string]: string } = {}; // room ID to event ID
157-
private stickyPromise?: () => Promise<void>; // This promise will be called and needs to resolve before the widget will actually become sticky.
157+
// This promise will be called and needs to resolve before the widget will actually become sticky.
158+
private stickyPromise?: () => Promise<void>;
159+
// Holds events that should be fed to the widget once they finish decrypting
160+
private readonly eventsToFeed = new WeakSet<MatrixEvent>();
158161

159162
public constructor(private appTileProps: IAppTileProps) {
160163
super();
@@ -465,12 +468,10 @@ export class StopGapWidget extends EventEmitter {
465468

466469
private onEvent = (ev: MatrixEvent): void => {
467470
this.client.decryptEventIfNeeded(ev);
468-
if (ev.isBeingDecrypted() || ev.isDecryptionFailure()) return;
469471
this.feedEvent(ev);
470472
};
471473

472474
private onEventDecrypted = (ev: MatrixEvent): void => {
473-
if (ev.isDecryptionFailure()) return;
474475
this.feedEvent(ev);
475476
};
476477

@@ -546,6 +547,9 @@ export class StopGapWidget extends EventEmitter {
546547
private feedEvent(ev: MatrixEvent): void {
547548
if (this.messaging === null) return;
548549
if (
550+
// If we had decided earlier to feed this event to the widget, but
551+
// it just wasn't ready, give it another try
552+
this.eventsToFeed.delete(ev) ||
549553
// Skip marker timeline check for events with relations to unknown parent because these
550554
// events are not added to the timeline here and will be ignored otherwise:
551555
// https://github.com/matrix-org/matrix-js-sdk/blob/d3dfcd924201d71b434af3d77343b5229b6ed75e/src/models/room.ts#L2207-L2213
@@ -563,10 +567,16 @@ export class StopGapWidget extends EventEmitter {
563567
// receiving ancient events from backfill and such.
564568
this.advanceReadUpToMarker(ev)
565569
) {
566-
const raw = ev.getEffectiveEvent();
567-
this.messaging.feedEvent(raw as IRoomEvent, this.eventListenerRoomId!).catch((e) => {
568-
logger.error("Error sending event to widget: ", e);
569-
});
570+
// If the event is still being decrypted, remember that we want to
571+
// feed it to the widget (even if not strictly in the order given by
572+
// the timeline) and get back to it later
573+
if (ev.isBeingDecrypted() || ev.isDecryptionFailure()) this.eventsToFeed.add(ev);
574+
else {
575+
const raw = ev.getEffectiveEvent();
576+
this.messaging.feedEvent(raw as IRoomEvent, this.eventListenerRoomId!).catch((e) => {
577+
logger.error("Error sending event to widget: ", e);
578+
});
579+
}
570580
}
571581
}
572582
}

test/unit-tests/stores/widgets/StopGapWidget-test.ts

Lines changed: 48 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,14 @@ Please see LICENSE files in the repository root for full details.
88

99
import { mocked, MockedObject } from "jest-mock";
1010
import { last } from "lodash";
11-
import { MatrixEvent, MatrixClient, ClientEvent, EventTimeline } from "matrix-js-sdk/src/matrix";
11+
import {
12+
MatrixEvent,
13+
MatrixClient,
14+
ClientEvent,
15+
EventTimeline,
16+
EventType,
17+
MatrixEventEvent,
18+
} from "matrix-js-sdk/src/matrix";
1219
import { ClientWidgetApi, WidgetApiFromWidgetAction } from "matrix-widget-api";
1320
import { waitFor } from "jest-matrix-react";
1421

@@ -134,6 +141,46 @@ describe("StopGapWidget", () => {
134141
expect(messaging.feedEvent).toHaveBeenLastCalledWith(event2.getEffectiveEvent(), "!1:example.org");
135142
});
136143

144+
it("feeds decrypted events asynchronously", async () => {
145+
const event1Encrypted = new MatrixEvent({
146+
event_id: event1.getId(),
147+
type: EventType.RoomMessageEncrypted,
148+
sender: event1.sender?.userId,
149+
room_id: event1.getRoomId(),
150+
content: {},
151+
});
152+
const decryptingSpy1 = jest.spyOn(event1Encrypted, "isBeingDecrypted").mockReturnValue(true);
153+
client.emit(ClientEvent.Event, event1Encrypted);
154+
const event2Encrypted = new MatrixEvent({
155+
event_id: event2.getId(),
156+
type: EventType.RoomMessageEncrypted,
157+
sender: event2.sender?.userId,
158+
room_id: event2.getRoomId(),
159+
content: {},
160+
});
161+
const decryptingSpy2 = jest.spyOn(event2Encrypted, "isBeingDecrypted").mockReturnValue(true);
162+
client.emit(ClientEvent.Event, event2Encrypted);
163+
expect(messaging.feedEvent).not.toHaveBeenCalled();
164+
165+
// "Decrypt" the events, but in reverse order; first event 2…
166+
event2Encrypted.event.type = event2.getType();
167+
event2Encrypted.event.content = event2.getContent();
168+
decryptingSpy2.mockReturnValue(false);
169+
client.emit(MatrixEventEvent.Decrypted, event2Encrypted);
170+
expect(messaging.feedEvent).toHaveBeenCalledTimes(1);
171+
expect(messaging.feedEvent).toHaveBeenLastCalledWith(event2Encrypted.getEffectiveEvent(), "!1:example.org");
172+
// …then event 1
173+
event1Encrypted.event.type = event1.getType();
174+
event1Encrypted.event.content = event1.getContent();
175+
decryptingSpy1.mockReturnValue(false);
176+
client.emit(MatrixEventEvent.Decrypted, event1Encrypted);
177+
// The events should be fed in that same order so that event 2
178+
// doesn't have to be blocked on the decryption of event 1 (or
179+
// worse, dropped)
180+
expect(messaging.feedEvent).toHaveBeenCalledTimes(2);
181+
expect(messaging.feedEvent).toHaveBeenLastCalledWith(event1Encrypted.getEffectiveEvent(), "!1:example.org");
182+
});
183+
137184
it("should not feed incoming event if not in timeline", () => {
138185
const event = mkEvent({
139186
event: true,

0 commit comments

Comments
 (0)