From 1e4180a71ff4f4ef5218eae861035a4bc5ca6387 Mon Sep 17 00:00:00 2001 From: Bruno Windels <274386+bwindels@users.noreply.github.com> Date: Mon, 9 Jan 2023 14:03:52 +0100 Subject: [PATCH] add error boundary to GroupCall --- src/matrix/calls/group/GroupCall.ts | 321 +++++++++++++++------------- 1 file changed, 177 insertions(+), 144 deletions(-) diff --git a/src/matrix/calls/group/GroupCall.ts b/src/matrix/calls/group/GroupCall.ts index 01ff4d31..53a87d5f 100644 --- a/src/matrix/calls/group/GroupCall.ts +++ b/src/matrix/calls/group/GroupCall.ts @@ -21,6 +21,7 @@ import {MuteSettings, CALL_LOG_TYPE, CALL_MEMBER_VALIDITY_PERIOD_MS, mute} from import {MemberChange, RoomMember} from "../../room/members/RoomMember"; import {EventEmitter} from "../../../utils/EventEmitter"; import {EventType, CallIntent} from "../callEventTypes"; +import { ErrorBoundary } from "../../../utils/ErrorBoundary"; import type {Options as MemberOptions} from "./Member"; import type {TurnServerSource} from "../TurnServerSource"; @@ -92,6 +93,9 @@ export class GroupCall extends EventEmitter<{change: never}> { private bufferedDeviceMessages = new Map>>(); /** Set between calling join and leave. */ private joinedData?: JoinedData; + private errorBoundary = new ErrorBoundary(err => { + this.emitChange(); + }); constructor( public readonly id: string, @@ -154,6 +158,10 @@ export class GroupCall extends EventEmitter<{change: never}> { return this.joinedData?.logItem; } + get error(): Error | undefined { + return this.errorBoundary.error; + } + async join(localMedia: LocalMedia): Promise { if (this._state !== GroupCallState.Created || this.joinedData) { return; @@ -206,6 +214,9 @@ export class GroupCall extends EventEmitter<{change: never}> { // and update the track info so PeerCall can use it to send up to date metadata, this.joinedData.localMuteSettings.updateTrackInfo(localMedia.userMedia); this.emitChange(); //allow listeners to see new media/mute settings + // TODO: if setMedia fails on one of the members, we should revert to the old media + // on the members processed so far, and show an error that we could not set the new media + // for this, we will need to remove the usage of the errorBoundary in member.setMedia. await Promise.all(Array.from(this._members.values()).map(m => { return m.setMedia(localMedia, oldMedia); })); @@ -234,6 +245,9 @@ export class GroupCall extends EventEmitter<{change: never}> { if (this.localMedia) { mute(this.localMedia, muteSettings, this.joinedData!.logItem); } + // TODO: if setMuted fails on one of the members, we should revert to the old media + // on the members processed so far, and show an error that we could not set the new media + // for this, we will need to remove the usage of the errorBoundary in member.setMuted. await Promise.all(Array.from(this._members.values()).map(m => { return m.setMuted(joinedData.localMuteSettings); })); @@ -271,7 +285,14 @@ export class GroupCall extends EventEmitter<{change: never}> { log.set("already_left", true); } } finally { - this.disconnect(log); + // disconnect is called both from the sync loop and from methods like this one that + // are called from the view model. We want errors during the sync loop being caught + // by the errorboundary, but since leave is called from the view model, we want + // the error to be thrown. So here we check if disconnect succeeded, and if not + // we rethrow the error put into the errorBoundary. + if(!this.disconnect(log)) { + throw this.errorBoundary.error; + } } }); } @@ -308,126 +329,134 @@ export class GroupCall extends EventEmitter<{change: never}> { /** @internal */ updateCallEvent(callContent: Record, syncLog: ILogItem) { - syncLog.wrap({l: "update call", t: CALL_LOG_TYPE, id: this.id}, log => { - this.callContent = callContent; - if (this._state === GroupCallState.Creating) { - this._state = GroupCallState.Created; - } - log.set("status", this._state); - this.emitChange(); + this.errorBoundary.try(() => { + syncLog.wrap({l: "update call", t: CALL_LOG_TYPE, id: this.id}, log => { + this.callContent = callContent; + if (this._state === GroupCallState.Creating) { + this._state = GroupCallState.Created; + } + log.set("status", this._state); + this.emitChange(); + }); }); } /** @internal */ updateRoomMembers(memberChanges: Map) { - for (const change of memberChanges.values()) { - const {member} = change; - for (const callMember of this._members.values()) { - // find all call members for a room member (can be multiple, for every device) - if (callMember.userId === member.userId) { - callMember.updateRoomMember(member); + this.errorBoundary.try(() => { + for (const change of memberChanges.values()) { + const {member} = change; + for (const callMember of this._members.values()) { + // find all call members for a room member (can be multiple, for every device) + if (callMember.userId === member.userId) { + callMember.updateRoomMember(member); + } } } - } - } - - /** @internal */ - updateMembership(userId: string, roomMember: RoomMember, callMembership: CallMembership, syncLog: ILogItem) { - syncLog.wrap({l: "update call membership", t: CALL_LOG_TYPE, id: this.id, userId}, log => { - const now = this.options.clock.now(); - const devices = callMembership["m.devices"]; - const previousDeviceIds = this.getDeviceIdsForUserId(userId); - for (const device of devices) { - const deviceId = device.device_id; - const memberKey = getMemberKey(userId, deviceId); - if (userId === this.options.ownUserId && deviceId === this.options.ownDeviceId) { - log.wrap("update own membership", log => { - if (this.hasJoined) { - if (this.joinedData) { - this.joinedData.logItem.refDetached(log); - } - this._setupRenewMembershipTimeout(device, log); - } - if (this._state === GroupCallState.Joining) { - log.set("joined", true); - this._state = GroupCallState.Joined; - this.emitChange(); - } - }); - } else { - log.wrap({l: "update device membership", id: memberKey, sessionId: device.session_id}, log => { - if (isMemberExpired(device, now)) { - log.set("expired", true); - const member = this._members.get(memberKey); - if (member) { - member.dispose(); - this._members.remove(memberKey); - log.set("removed", true); - } - return; - } - let member = this._members.get(memberKey); - const sessionIdChanged = member && member.sessionId !== device.session_id; - if (member && !sessionIdChanged) { - log.set("update", true); - member.updateCallInfo(device, log); - } else { - if (member && sessionIdChanged) { - log.set("removedSessionId", member.sessionId); - const disconnectLogItem = member.disconnect(false); - if (disconnectLogItem) { - log.refDetached(disconnectLogItem); - } - member.dispose(); - this._members.remove(memberKey); - member = undefined; - } - log.set("add", true); - member = new Member( - roomMember, - device, this._memberOptions, - log - ); - this._members.add(memberKey, member); - if (this.joinedData) { - this.connectToMember(member, this.joinedData, log); - } - } - // flush pending messages, either after having created the member, - // or updated the session id with updateCallInfo - this.flushPendingIncomingDeviceMessages(member, log); - }); - } - } - - const newDeviceIds = new Set(devices.map(call => call.device_id)); - // remove user as member of any calls not present anymore - for (const previousDeviceId of previousDeviceIds) { - if (!newDeviceIds.has(previousDeviceId)) { - this.removeMemberDevice(userId, previousDeviceId, log); - } - } - if (userId === this.options.ownUserId && !newDeviceIds.has(this.options.ownDeviceId)) { - this.removeOwnDevice(log); - } + }); + } + + /** @internal */ + updateMembership(userId: string, roomMember: RoomMember, callMembership: CallMembership, syncLog: ILogItem) { + this.errorBoundary.try(() => { + syncLog.wrap({l: "update call membership", t: CALL_LOG_TYPE, id: this.id, userId}, log => { + const now = this.options.clock.now(); + const devices = callMembership["m.devices"]; + const previousDeviceIds = this.getDeviceIdsForUserId(userId); + for (const device of devices) { + const deviceId = device.device_id; + const memberKey = getMemberKey(userId, deviceId); + if (userId === this.options.ownUserId && deviceId === this.options.ownDeviceId) { + log.wrap("update own membership", log => { + if (this.hasJoined) { + if (this.joinedData) { + this.joinedData.logItem.refDetached(log); + } + this._setupRenewMembershipTimeout(device, log); + } + if (this._state === GroupCallState.Joining) { + log.set("joined", true); + this._state = GroupCallState.Joined; + this.emitChange(); + } + }); + } else { + log.wrap({l: "update device membership", id: memberKey, sessionId: device.session_id}, log => { + if (isMemberExpired(device, now)) { + log.set("expired", true); + const member = this._members.get(memberKey); + if (member) { + member.dispose(); + this._members.remove(memberKey); + log.set("removed", true); + } + return; + } + let member = this._members.get(memberKey); + const sessionIdChanged = member && member.sessionId !== device.session_id; + if (member && !sessionIdChanged) { + log.set("update", true); + member.updateCallInfo(device, log); + } else { + if (member && sessionIdChanged) { + log.set("removedSessionId", member.sessionId); + const disconnectLogItem = member.disconnect(false); + if (disconnectLogItem) { + log.refDetached(disconnectLogItem); + } + member.dispose(); + this._members.remove(memberKey); + member = undefined; + } + log.set("add", true); + member = new Member( + roomMember, + device, this._memberOptions, + log + ); + this._members.add(memberKey, member); + if (this.joinedData) { + this.connectToMember(member, this.joinedData, log); + } + } + // flush pending messages, either after having created the member, + // or updated the session id with updateCallInfo + this.flushPendingIncomingDeviceMessages(member, log); + }); + } + } + + const newDeviceIds = new Set(devices.map(call => call.device_id)); + // remove user as member of any calls not present anymore + for (const previousDeviceId of previousDeviceIds) { + if (!newDeviceIds.has(previousDeviceId)) { + this.removeMemberDevice(userId, previousDeviceId, log); + } + } + if (userId === this.options.ownUserId && !newDeviceIds.has(this.options.ownDeviceId)) { + this.removeOwnDevice(log); + } + }); }); } /** @internal */ removeMembership(userId: string, syncLog: ILogItem) { - const deviceIds = this.getDeviceIdsForUserId(userId); - syncLog.wrap({ - l: "remove call member", - t: CALL_LOG_TYPE, - id: this.id, - userId - }, log => { - for (const deviceId of deviceIds) { - this.removeMemberDevice(userId, deviceId, log); - } - if (userId === this.options.ownUserId) { - this.removeOwnDevice(log); - } + this.errorBoundary.try(() => { + const deviceIds = this.getDeviceIdsForUserId(userId); + syncLog.wrap({ + l: "remove call member", + t: CALL_LOG_TYPE, + id: this.id, + userId + }, log => { + for (const deviceId of deviceIds) { + this.removeMemberDevice(userId, deviceId, log); + } + if (userId === this.options.ownUserId) { + this.removeOwnDevice(log); + } + }); }); } @@ -465,19 +494,21 @@ export class GroupCall extends EventEmitter<{change: never}> { } /** @internal */ - disconnect(log: ILogItem) { - if (this.hasJoined) { - for (const [,member] of this._members) { - const disconnectLogItem = member.disconnect(true); - if (disconnectLogItem) { - log.refDetached(disconnectLogItem); + disconnect(log: ILogItem): boolean { + return this.errorBoundary.try(() => { + if (this.hasJoined) { + for (const [,member] of this._members) { + const disconnectLogItem = member.disconnect(true); + if (disconnectLogItem) { + log.refDetached(disconnectLogItem); + } } + this._state = GroupCallState.Created; } - this._state = GroupCallState.Created; - } - this.joinedData?.dispose(); - this.joinedData = undefined; - this.emitChange(); + this.joinedData?.dispose(); + this.joinedData = undefined; + this.emitChange(); + }, false) || true; } /** @internal */ @@ -500,31 +531,33 @@ export class GroupCall extends EventEmitter<{change: never}> { /** @internal */ handleDeviceMessage(message: SignallingMessage, userId: string, deviceId: string, syncLog: ILogItem) { - // TODO: return if we are not membering to the call - const key = getMemberKey(userId, deviceId); - let member = this._members.get(key); - if (member && message.content.sender_session_id === member.sessionId) { - member.handleDeviceMessage(message, syncLog); - } else { - const item = syncLog.log({ - l: "call: buffering to_device message, member not found", - t: CALL_LOG_TYPE, - id: this.id, - userId, - deviceId, - sessionId: message.content.sender_session_id, - type: message.type - }); - syncLog.refDetached(item); - // we haven't received the m.call.member yet for this caller (or with this session id). - // buffer the device messages or create the member/call as it should arrive in a moment - let messages = this.bufferedDeviceMessages.get(key); - if (!messages) { - messages = new Set(); - this.bufferedDeviceMessages.set(key, messages); + this.errorBoundary.try(() => { + // TODO: return if we are not membering to the call + const key = getMemberKey(userId, deviceId); + let member = this._members.get(key); + if (member && message.content.sender_session_id === member.sessionId) { + member.handleDeviceMessage(message, syncLog); + } else { + const item = syncLog.log({ + l: "call: buffering to_device message, member not found", + t: CALL_LOG_TYPE, + id: this.id, + userId, + deviceId, + sessionId: message.content.sender_session_id, + type: message.type + }); + syncLog.refDetached(item); + // we haven't received the m.call.member yet for this caller (or with this session id). + // buffer the device messages or create the member/call as it should arrive in a moment + let messages = this.bufferedDeviceMessages.get(key); + if (!messages) { + messages = new Set(); + this.bufferedDeviceMessages.set(key, messages); + } + messages.add(message); } - messages.add(message); - } + }); } private async _createMemberPayload(includeOwn: boolean): Promise {