Skip to content

Commit e1e0996

Browse files
committed
Making IgnoredInvites mostly sync.
Experience and feedback from matrix-org/matrix-react-sdk#9255 indicates that we cannot integrate an async `IgnoredInvites.getRuleForInvite`. This PR: - rewrites `getRuleForInvite` to be sync; - adds some locking within `IgnoredInvites` to avoid race conditions noticed by @t3chguy.
1 parent 8490f72 commit e1e0996

File tree

4 files changed

+79
-51
lines changed

4 files changed

+79
-51
lines changed

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,7 @@
5555
"dependencies": {
5656
"@babel/runtime": "^7.12.5",
5757
"another-json": "^0.2.0",
58+
"await-lock": "^2.2.2",
5859
"browser-request": "^0.3.3",
5960
"bs58": "^5.0.0",
6061
"content-type": "^1.0.4",

spec/unit/matrix-client.spec.ts

Lines changed: 19 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1509,12 +1509,25 @@ describe("MatrixClient", function() {
15091509
expect(target1).toBe(target2);
15101510
});
15111511

1512-
it("should initialize and return the same `sources` consistently", async () => {
1513-
const sources1 = await client.ignoredInvites.getOrCreateSourceRooms();
1514-
const sources2 = await client.ignoredInvites.getOrCreateSourceRooms();
1515-
expect(sources1).toBeTruthy();
1512+
it("if there are no source rooms, it should return an empty list consistently", async () => {
1513+
const sources1 = client.ignoredInvites.getSourceRooms();
1514+
const sources2 = client.ignoredInvites.getSourceRooms();
1515+
expect(sources1).toHaveLength(0);
1516+
expect(sources1).toEqual(sources2);
1517+
});
1518+
1519+
it("if there are any source rooms, it should the same list consistently", async () => {
1520+
const NEW_SOURCE_ROOM_ID = "!another-source:example.org";
1521+
1522+
// Make sure that everything is initialized.
1523+
await client.joinRoom(NEW_SOURCE_ROOM_ID);
1524+
await client.ignoredInvites.addSource(NEW_SOURCE_ROOM_ID);
1525+
1526+
const sources1 = client.ignoredInvites.getSourceRooms();
1527+
const sources2 = client.ignoredInvites.getSourceRooms();
15161528
expect(sources1).toHaveLength(1);
15171529
expect(sources1).toEqual(sources2);
1530+
expect(sources1.map(room => room.roomId)).toContain(NEW_SOURCE_ROOM_ID);
15181531
});
15191532

15201533
it("should initially not reject any invite", async () => {
@@ -1526,6 +1539,7 @@ describe("MatrixClient", function() {
15261539
});
15271540

15281541
it("should reject invites once we have added a matching rule in the target room (scope: user)", async () => {
1542+
await client.ignoredInvites.getOrCreateTargetRoom();
15291543
await client.ignoredInvites.addRule(PolicyScope.User, "*:example.org", "just a test");
15301544

15311545
// We should reject this invite.
@@ -1615,7 +1629,6 @@ describe("MatrixClient", function() {
16151629
const NEW_SOURCE_ROOM_ID = "!another-source:example.org";
16161630

16171631
// Make sure that everything is initialized.
1618-
await client.ignoredInvites.getOrCreateSourceRooms();
16191632
await client.joinRoom(NEW_SOURCE_ROOM_ID);
16201633
await client.ignoredInvites.addSource(NEW_SOURCE_ROOM_ID);
16211634

@@ -1678,8 +1691,8 @@ describe("MatrixClient", function() {
16781691
const NEW_SOURCE_ROOM_ID = "!another-source:example.org";
16791692

16801693
// Make sure that everything is initialized.
1681-
await client.ignoredInvites.getOrCreateSourceRooms();
16821694
await client.joinRoom(NEW_SOURCE_ROOM_ID);
1695+
await client.ignoredInvites.getOrCreateTargetRoom();
16831696
const newSourceRoom = client.getRoom(NEW_SOURCE_ROOM_ID);
16841697

16851698
// Fetch the list of sources and check that we do not have the new room yet.

src/models/invites-ignorer.ts

Lines changed: 54 additions & 44 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ See the License for the specific language governing permissions and
1414
limitations under the License.
1515
*/
1616

17+
import AwaitLock from "await-lock";
1718
import { UnstableValue } from "matrix-events-sdk";
1819

1920
import { MatrixClient } from "../client";
@@ -71,6 +72,16 @@ export enum PolicyScope {
7172
* our data structures.
7273
*/
7374
export class IgnoredInvites {
75+
// A lock around method `getOrCreateTargetRoom`.
76+
// Used to ensure that only one async task of this class
77+
// is creating a new target room and modifying the
78+
// `target` property of account key `IGNORE_INVITES_POLICIES`.
79+
private _getOrCreateTargetRoomLock = new AwaitLock();
80+
81+
// A lock around method `withIgnoreInvitesPoliciesLock`.
82+
// Used to ensure that only one async task of this class is
83+
// modifying `IGNORE_INVITES_POLICIES` at any point in time.
84+
private _withIgnoreInvitesPoliciesLock = new AwaitLock();
7485
constructor(
7586
private readonly client: MatrixClient,
7687
) {
@@ -83,6 +94,12 @@ export class IgnoredInvites {
8394
* @param entity The entity covered by this rule. Globs are supported.
8495
* @param reason A human-readable reason for introducing this new rule.
8596
* @return The event id for the new rule.
97+
*
98+
* # Safety
99+
*
100+
* This method will rewrite the `Policies` object in the user's account data.
101+
* This rewrite is inherently racy and could overwrite or be overwritten by
102+
* other concurrent rewrites of the same object.
86103
*/
87104
public async addRule(scope: PolicyScope, entity: string, reason: string): Promise<string> {
88105
const target = await this.getOrCreateTargetRoom();
@@ -119,11 +136,10 @@ export class IgnoredInvites {
119136
*/
120137
public async addSource(roomId: string): Promise<boolean> {
121138
// We attempt to join the room *before* calling
122-
// `await this.getOrCreateSourceRooms()` to decrease the duration
139+
// `await this.getSourceRooms()` to decrease the duration
123140
// of the racy section.
124141
await this.client.joinRoom(roomId);
125-
// Race starts.
126-
const sources = (await this.getOrCreateSourceRooms())
142+
const sources = this.getSourceRooms()
127143
.map(room => room.roomId);
128144
if (sources.includes(roomId)) {
129145
return false;
@@ -133,7 +149,6 @@ export class IgnoredInvites {
133149
ignoreInvitesPolicies.sources = sources;
134150
});
135151

136-
// Race ends.
137152
return true;
138153
}
139154

@@ -144,10 +159,10 @@ export class IgnoredInvites {
144159
* @param roomId The room to which the user is invited.
145160
* @returns A rule matching the entity, if any was found, `null` otherwise.
146161
*/
147-
public async getRuleForInvite({ sender, roomId }: {
162+
public getRuleForInvite({ sender, roomId }: {
148163
sender: string;
149164
roomId: string;
150-
}): Promise<Readonly<MatrixEvent | null>> {
165+
}): Readonly<MatrixEvent | null> {
151166
// In this implementation, we perform a very naive lookup:
152167
// - search in each policy room;
153168
// - turn each (potentially glob) rule entity into a regexp.
@@ -158,7 +173,7 @@ export class IgnoredInvites {
158173
// - match several entities per go;
159174
// - pre-compile each rule entity into a regexp;
160175
// - pre-compile entire rooms into a single regexp.
161-
const policyRooms = await this.getOrCreateSourceRooms();
176+
const policyRooms = this.getSourceRooms();
162177
const senderServer = sender.split(":")[1];
163178
const roomServer = roomId.split(":")[1];
164179
for (const room of policyRooms) {
@@ -227,21 +242,30 @@ export class IgnoredInvites {
227242
const room = this.client.getRoom(target);
228243
if (room) {
229244
return room;
230-
} else {
231-
target = null;
232245
}
233246
}
234-
// We need to create our own policy room for ignoring invites.
235-
target = (await this.client.createRoom({
236-
name: "Individual Policy Room",
237-
preset: Preset.PrivateChat,
238-
})).room_id;
239-
await this.withIgnoreInvitesPolicies(ignoreInvitesPolicies => {
240-
ignoreInvitesPolicies.target = target;
241-
});
247+
try {
248+
// We need to create our own policy room for ignoring invites.
249+
await this._getOrCreateTargetRoomLock.acquireAsync();
250+
target = (await this.client.createRoom({
251+
name: "Individual Policy Room",
252+
preset: Preset.PrivateChat,
253+
})).room_id;
254+
await this.withIgnoreInvitesPolicies(ignoreInvitesPolicies => {
255+
ignoreInvitesPolicies.target = target;
256+
if (!("sources" in ignoreInvitesPolicies)) {
257+
// `[target]` is a reasonable default for `sources`.
258+
ignoreInvitesPolicies.sources = [target];
259+
}
260+
});
242261

243-
// Since we have just called `createRoom`, `getRoom` should not be `null`.
244-
return this.client.getRoom(target)!;
262+
// Since we have just called `createRoom`, `getRoom` should not be `null`.
263+
// Note that this is unavoidably racy, e.g. another client could have left
264+
// the room during the call to `this.withIgnoreInvitesPolicies`.
265+
return this.client.getRoom(target)!;
266+
} finally {
267+
this._getOrCreateTargetRoomLock.release();
268+
}
245269
}
246270

247271
/**
@@ -258,40 +282,21 @@ export class IgnoredInvites {
258282
* This rewrite is inherently racy and could overwrite or be overwritten by
259283
* other concurrent rewrites of the same object.
260284
*/
261-
public async getOrCreateSourceRooms(): Promise<Room[]> {
285+
public getSourceRooms(): Room[] {
262286
const ignoreInvitesPolicies = this.getIgnoreInvitesPolicies();
263287
let sources = ignoreInvitesPolicies.sources;
264288

265289
// Validate `sources`. If it is invalid, trash out the current `sources`
266290
// and create a new list of sources from `target`.
267-
let hasChanges = false;
268291
if (!Array.isArray(sources)) {
269292
// `sources` could not be an array.
270-
hasChanges = true;
271293
sources = [];
272294
}
273-
let sourceRooms: Room[] = sources
295+
const sourceRooms: Room[] = sources
274296
// `sources` could contain non-string / invalid room ids
275297
.filter(roomId => typeof roomId === "string")
276298
.map(roomId => this.client.getRoom(roomId))
277299
.filter(room => !!room);
278-
if (sourceRooms.length != sources.length) {
279-
hasChanges = true;
280-
}
281-
if (sourceRooms.length == 0) {
282-
// `sources` could be empty (possibly because we've removed
283-
// invalid content)
284-
const target = await this.getOrCreateTargetRoom();
285-
hasChanges = true;
286-
sourceRooms = [target];
287-
}
288-
if (hasChanges) {
289-
// Reload `policies`/`ignoreInvitesPolicies` in case it has been changed
290-
// during or by our call to `this.getTargetRoom()`.
291-
await this.withIgnoreInvitesPolicies(ignoreInvitesPolicies => {
292-
ignoreInvitesPolicies.sources = sources;
293-
});
294-
}
295300
return sourceRooms;
296301
}
297302

@@ -313,10 +318,15 @@ export class IgnoredInvites {
313318
* Modify in place the `IGNORE_INVITES_POLICIES` object from account data.
314319
*/
315320
private async withIgnoreInvitesPolicies(cb: (ignoreInvitesPolicies: {[key: string]: any}) => void) {
316-
const { policies, ignoreInvitesPolicies } = this.getPoliciesAndIgnoreInvitesPolicies();
317-
cb(ignoreInvitesPolicies);
318-
policies[IGNORE_INVITES_ACCOUNT_EVENT_KEY.name] = ignoreInvitesPolicies;
319-
await this.client.setAccountData(POLICIES_ACCOUNT_EVENT_TYPE.name, policies);
321+
await this._withIgnoreInvitesPoliciesLock.acquireAsync();
322+
try {
323+
const { policies, ignoreInvitesPolicies } = this.getPoliciesAndIgnoreInvitesPolicies();
324+
cb(ignoreInvitesPolicies);
325+
policies[IGNORE_INVITES_ACCOUNT_EVENT_KEY.name] = ignoreInvitesPolicies;
326+
await this.client.setAccountData(POLICIES_ACCOUNT_EVENT_TYPE.name, policies);
327+
} finally {
328+
this._withIgnoreInvitesPoliciesLock.release();
329+
}
320330
}
321331

322332
/**

yarn.lock

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1410,7 +1410,6 @@
14101410

14111411
"@matrix-org/olm@https://gitlab.matrix.org/api/v4/projects/27/packages/npm/@matrix-org/olm/-/@matrix-org/olm-3.2.12.tgz":
14121412
version "3.2.12"
1413-
uid "0bce3c86f9d36a4984d3c3e07df1c3fb4c679bd9"
14141413
resolved "https://gitlab.matrix.org/api/v4/projects/27/packages/npm/@matrix-org/olm/-/@matrix-org/olm-3.2.12.tgz#0bce3c86f9d36a4984d3c3e07df1c3fb4c679bd9"
14151414

14161415
"@nicolo-ribaudo/chokidar-2@2.1.8-no-fsevents.3":
@@ -2052,6 +2051,11 @@ available-typed-arrays@^1.0.5:
20522051
resolved "https://registry.yarnpkg.com/available-typed-arrays/-/available-typed-arrays-1.0.5.tgz#92f95616501069d07d10edb2fc37d3e1c65123b7"
20532052
integrity sha512-DMD0KiN46eipeziST1LPP/STfDU0sufISXmjSgvVsoU2tqxctQeASejWcfNtxYKqETM1UxQ8sp2OrSBWpHY6sw==
20542053

2054+
await-lock@^2.2.2:
2055+
version "2.2.2"
2056+
resolved "https://registry.yarnpkg.com/await-lock/-/await-lock-2.2.2.tgz#a95a9b269bfd2f69d22b17a321686f551152bcef"
2057+
integrity sha512-aDczADvlvTGajTDjcjpJMqRkOF6Qdz3YbPZm/PyW6tKPkx2hlYBzxMhEywM/tU72HrVZjgl5VCdRuMlA7pZ8Gw==
2058+
20552059
aws-sign2@~0.7.0:
20562060
version "0.7.0"
20572061
resolved "https://registry.yarnpkg.com/aws-sign2/-/aws-sign2-0.7.0.tgz#b46e890934a9591f2d2f6f86d7e6a9f1b3fe76a8"

0 commit comments

Comments
 (0)