fix more race conditions, and apply changes from review

pull/28211/head
Hubert Chathi 2024-11-14 22:48:38 -05:00
parent 5615b9824a
commit 0ef342b9f0
2 changed files with 162 additions and 63 deletions

View File

@ -80,18 +80,28 @@ export const UserIdentityWarning: React.FC<UserIdentityWarningProps> = ({ room }
// with the newer value, so it will fix itself in the end. // with the newer value, so it will fix itself in the end.
const gotVerificationStatusUpdateRef = useRef<Map<string, boolean>>(new Map()); const gotVerificationStatusUpdateRef = useRef<Map<string, boolean>>(new Map());
// Select a new user to display a warning for. This is called after the // Update the current prompt. Select a new user if needed, or hide the
// current prompted user no longer needs their identity approved. // warning if we don't have anyone to warn about.
const selectCurrentPrompt = useCallback((): RoomMember | undefined => { const updateCurrentPrompt = useCallback((): undefined => {
const membersNeedingApproval = membersNeedingApprovalRef.current; const membersNeedingApproval = membersNeedingApprovalRef.current;
if (membersNeedingApproval.size === 0) { // We have to do this in a callback to `setCurrentPrompt`
return undefined; // because this function could have been called after an
} // `await`, and the `currentPrompt` that this function would
// have may be outdated.
setCurrentPrompt((currentPrompt) => {
// If we're already displaying a warning, and that user still needs
// approval, continue showing that user.
if (currentPrompt && membersNeedingApproval.has(currentPrompt.userId)) return currentPrompt;
// We pick the user with the smallest user ID. if (membersNeedingApproval.size === 0) {
const keys = Array.from(membersNeedingApproval.keys()).sort((a, b) => a.localeCompare(b)); return undefined;
const selection = membersNeedingApproval.get(keys[0]!); }
return selection;
// We pick the user with the smallest user ID.
const keys = Array.from(membersNeedingApproval.keys()).sort((a, b) => a.localeCompare(b));
const selection = membersNeedingApproval.get(keys[0]!);
return selection;
});
}, []); }, []);
// Add a user to the membersNeedingApproval map, and update the current // Add a user to the membersNeedingApproval map, and update the current
@ -105,54 +115,52 @@ export const UserIdentityWarning: React.FC<UserIdentityWarningProps> = ({ room }
return; return;
} }
member = member ?? room.getMember(userId) ?? undefined; member = member ?? room.getMember(userId) ?? undefined;
if (member) { if (!member) return;
membersNeedingApprovalRef.current.set(userId, member);
// We only select the prompt if we are done initialising, membersNeedingApprovalRef.current.set(userId, member);
// because we will select the prompt after we're done // We only select the prompt if we are done initialising,
// initialising, and we want to start by displaying a warning // because we will select the prompt after we're done
// for the user with the smallest ID. // initialising, and we want to start by displaying a warning
if (initialisedRef.current === InitialisationStatus.Completed) { // for the user with the smallest ID.
setCurrentPrompt((currentPrompt) => { if (initialisedRef.current === InitialisationStatus.Completed) {
// If we aren't currently displaying a warning, we pick updateCurrentPrompt();
// a new user to show a warning for. If we are already
// displaying a warning, don't change the display.
//
// We have to do this in a callback to
// `setCurrentPrompt` because this function could have
// been called after an `await`, and the `currentPrompt`
// that this function would have may be outdated.
if (!currentPrompt) {
return selectCurrentPrompt();
} else {
return currentPrompt;
}
});
}
} }
}, },
[cli, room, selectCurrentPrompt], [cli, room, updateCurrentPrompt],
); );
// Check if the user's identity needs approval, and if so, add them to the // Check if the user's identity needs approval, and if so, add them to the
// membersNeedingApproval map and update the prompt if needed. They will // membersNeedingApproval map and update the prompt if needed. They will
// only be added if they are a member of the room. // only be added if they are a member of the room.
const addMemberIfNeedsApproval = useCallback( const addMembersWhoNeedApproval = useCallback(
async (userId: string, member?: RoomMember): Promise<void> => { async (members: RoomMember[]): Promise<void> => {
const gotVerificationStatusUpdate = gotVerificationStatusUpdateRef.current; const gotVerificationStatusUpdate = gotVerificationStatusUpdateRef.current;
const membersNeedingApproval = membersNeedingApprovalRef.current;
if (gotVerificationStatusUpdate.has(userId)) { const promises: Promise<void>[] = [];
// We're already checking their verification status, so we don't
// need to do anything here. for (const member of members) {
return; const userId = member.userId;
} if (gotVerificationStatusUpdate.has(userId)) {
gotVerificationStatusUpdate.set(userId, false); // We're already checking their verification status, so we don't
if (await userNeedsApproval(crypto!, userId)) { // need to do anything here.
if (!membersNeedingApproval.has(userId) && gotVerificationStatusUpdate.get(userId) === false) { continue;
addMemberNeedingApproval(userId, member);
} }
gotVerificationStatusUpdate.set(userId, false);
promises.push(
userNeedsApproval(crypto!, userId).then((needsApproval) => {
if (needsApproval) {
// Only actually update the list if we haven't received a
// `UserTrustStatusChanged` for this user in the meantime.
if (gotVerificationStatusUpdate.get(userId) === false) {
addMemberNeedingApproval(userId, member);
}
}
gotVerificationStatusUpdate.delete(userId);
}),
);
} }
gotVerificationStatusUpdate.delete(userId);
await Promise.all(promises);
}, },
[crypto, addMemberNeedingApproval], [crypto, addMemberNeedingApproval],
); );
@ -162,20 +170,15 @@ export const UserIdentityWarning: React.FC<UserIdentityWarningProps> = ({ room }
const removeMemberNeedingApproval = useCallback( const removeMemberNeedingApproval = useCallback(
(userId: string): void => { (userId: string): void => {
membersNeedingApprovalRef.current.delete(userId); membersNeedingApprovalRef.current.delete(userId);
updateCurrentPrompt();
// If we removed the currently displayed user, we need to pick a new one
// to display.
if (currentPrompt?.userId === userId) {
setCurrentPrompt(selectCurrentPrompt());
}
}, },
[currentPrompt, selectCurrentPrompt], [updateCurrentPrompt],
); );
// Initialise the component. Get the room members, check which ones need // Initialise the component. Get the room members, check which ones need
// their identity approved, and pick one to display. // their identity approved, and pick one to display.
const loadMembers = useCallback(async (): Promise<void> => { const loadMembers = useCallback(async (): Promise<void> => {
if (!crypto || initialisedRef.current != InitialisationStatus.Uninitialised) { if (!crypto || initialisedRef.current !== InitialisationStatus.Uninitialised) {
return; return;
} }
// If encryption is not enabled in the room, we don't need to do // If encryption is not enabled in the room, we don't need to do
@ -187,14 +190,11 @@ export const UserIdentityWarning: React.FC<UserIdentityWarningProps> = ({ room }
initialisedRef.current = InitialisationStatus.Initialising; initialisedRef.current = InitialisationStatus.Initialising;
const members = await room.getEncryptionTargetMembers(); const members = await room.getEncryptionTargetMembers();
await addMembersWhoNeedApproval(members);
for (const member of members) { updateCurrentPrompt();
await addMemberIfNeedsApproval(member.userId, member);
}
setCurrentPrompt(selectCurrentPrompt());
initialisedRef.current = InitialisationStatus.Completed; initialisedRef.current = InitialisationStatus.Completed;
}, [crypto, room, addMemberIfNeedsApproval, selectCurrentPrompt]); }, [crypto, room, addMembersWhoNeedApproval, updateCurrentPrompt]);
loadMembers().catch((e) => { loadMembers().catch((e) => {
logger.error("Error initialising UserIdentityWarning:", e); logger.error("Error initialising UserIdentityWarning:", e);
@ -213,6 +213,9 @@ export const UserIdentityWarning: React.FC<UserIdentityWarningProps> = ({ room }
} }
if (gotVerificationStatusUpdate.has(userId)) { if (gotVerificationStatusUpdate.has(userId)) {
// There is an ongoing call to `addMembersWhoNeedApproval`. Flag
// to it that we've got a more up-to-date result so it should
// discard its result.
gotVerificationStatusUpdate.set(userId, true); gotVerificationStatusUpdate.set(userId, true);
} }
@ -259,14 +262,27 @@ export const UserIdentityWarning: React.FC<UserIdentityWarningProps> = ({ room }
) { ) {
// Someone's membership changed and we will now encrypt to them. If // Someone's membership changed and we will now encrypt to them. If
// their identity needs approval, show a warning. // their identity needs approval, show a warning.
await addMemberIfNeedsApproval(userId); const member = room.getMember(userId);
if (member) {
await addMembersWhoNeedApproval([member]).catch((e) => {
logger.error("Error adding member in UserIdentityWarning:", e);
});
}
} else { } else {
// Someone's membership changed and we no longer encrypt to them. // Someone's membership changed and we no longer encrypt to them.
// If we're showing a warning about them, we don't need to any more. // If we're showing a warning about them, we don't need to any more.
removeMemberNeedingApproval(userId); removeMemberNeedingApproval(userId);
const gotVerificationStatusUpdate = gotVerificationStatusUpdateRef.current;
if (gotVerificationStatusUpdate.has(userId)) {
// There is an ongoing call to `addMembersWhoNeedApproval`.
// Indicate that we've received a verification status update
// to prevent it from trying to add the user as needing
// verification.
gotVerificationStatusUpdate.set(userId, true);
}
} }
}, },
[crypto, room, addMemberIfNeedsApproval, removeMemberNeedingApproval, loadMembers], [crypto, room, addMembersWhoNeedApproval, removeMemberNeedingApproval, loadMembers],
); );
useTypedEventEmitter(cli, RoomStateEvent.Events, onRoomStateEvent); useTypedEventEmitter(cli, RoomStateEvent.Events, onRoomStateEvent);

View File

@ -350,6 +350,89 @@ describe("UserIdentityWarning", () => {
expect(() => getWarningByText("@bob:example.org's identity appears to have changed.")).toThrow(), expect(() => getWarningByText("@bob:example.org's identity appears to have changed.")).toThrow(),
); );
}); });
it("when member leaves immediately after component is loaded", async () => {
jest.spyOn(room, "getEncryptionTargetMembers").mockImplementation(async () => {
setTimeout(() => {
// Alice immediately leaves after we get the room
// membership, so we shouldn't show the warning any more
client.emit(
RoomStateEvent.Events,
new MatrixEvent({
event_id: "$event_id",
type: EventType.RoomMember,
state_key: "@alice:example.org",
content: {
membership: "leave",
},
room_id: ROOM_ID,
sender: "@alice:example.org",
}),
dummyRoomState(),
null,
);
});
return [mockRoomMember("@alice:example.org")];
});
jest.spyOn(room, "getMember").mockImplementation((userId) => mockRoomMember(userId));
jest.spyOn(room, "shouldEncryptForInvitedMembers").mockReturnValue(false);
const crypto = client.getCrypto()!;
jest.spyOn(crypto, "getUserVerificationStatus").mockResolvedValue(
new UserVerificationStatus(false, false, false, true),
);
renderComponent(client, room);
await sleep(10);
expect(() => getWarningByText("@alice:example.org's identity appears to have changed.")).toThrow();
});
it("when member leaves immediately after joining", async () => {
// Nobody in the room yet
jest.spyOn(room, "getEncryptionTargetMembers").mockResolvedValue([]);
jest.spyOn(room, "getMember").mockImplementation((userId) => mockRoomMember(userId));
jest.spyOn(room, "shouldEncryptForInvitedMembers").mockReturnValue(false);
const crypto = client.getCrypto()!;
jest.spyOn(crypto, "getUserVerificationStatus").mockResolvedValue(
new UserVerificationStatus(false, false, false, true),
);
renderComponent(client, room);
await sleep(10); // give it some time to finish initialising
// Alice joins. Her identity needs approval, so we should show a warning.
client.emit(
RoomStateEvent.Events,
new MatrixEvent({
event_id: "$event_id",
type: EventType.RoomMember,
state_key: "@alice:example.org",
content: {
membership: "join",
},
room_id: ROOM_ID,
sender: "@alice:example.org",
}),
dummyRoomState(),
null,
);
// ... but she immediately leaves, so we shouldn't show the warning any more
client.emit(
RoomStateEvent.Events,
new MatrixEvent({
event_id: "$event_id",
type: EventType.RoomMember,
state_key: "@alice:example.org",
content: {
membership: "leave",
},
room_id: ROOM_ID,
sender: "@alice:example.org",
}),
dummyRoomState(),
null,
);
await sleep(10); // give it some time to finish
expect(() => getWarningByText("@alice:example.org's identity appears to have changed.")).toThrow();
});
}); });
// When we have multiple users whose identity needs approval, one user's // When we have multiple users whose identity needs approval, one user's