Merge pull request #1005 from robintown/parallel-calls
Detect split-brains caused by parallel calls
This commit is contained in:
commit
8eafb1ae4a
3 changed files with 246 additions and 4 deletions
65
src/room/checkForParallelCalls.ts
Normal file
65
src/room/checkForParallelCalls.ts
Normal file
|
@ -0,0 +1,65 @@
|
||||||
|
/*
|
||||||
|
Copyright 2023 New Vector Ltd
|
||||||
|
|
||||||
|
Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
you may not use this file except in compliance with the License.
|
||||||
|
You may obtain a copy of the License at
|
||||||
|
|
||||||
|
http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
|
||||||
|
Unless required by applicable law or agreed to in writing, software
|
||||||
|
distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
See the License for the specific language governing permissions and
|
||||||
|
limitations under the License.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { EventType } from "matrix-js-sdk/src/@types/event";
|
||||||
|
import { RoomState } from "matrix-js-sdk/src/models/room-state";
|
||||||
|
|
||||||
|
import { PosthogAnalytics } from "../analytics/PosthogAnalytics";
|
||||||
|
|
||||||
|
function isObject(x: unknown): x is Record<string, unknown> {
|
||||||
|
return typeof x === "object" && x !== null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Checks the state of a room for multiple calls happening in parallel, sending
|
||||||
|
* the details to PostHog if that is indeed what's happening. (This is unwanted
|
||||||
|
* as it indicates a split-brain scenario.)
|
||||||
|
*/
|
||||||
|
export function checkForParallelCalls(state: RoomState): void {
|
||||||
|
const now = Date.now();
|
||||||
|
const participantsPerCall = new Map<string, number>();
|
||||||
|
|
||||||
|
// For each participant in each call, increment the participant count
|
||||||
|
for (const e of state.getStateEvents(EventType.GroupCallMemberPrefix)) {
|
||||||
|
const content = e.getContent<Record<string, unknown>>();
|
||||||
|
const calls: unknown[] = Array.isArray(content["m.calls"])
|
||||||
|
? content["m.calls"]
|
||||||
|
: [];
|
||||||
|
|
||||||
|
for (const call of calls) {
|
||||||
|
if (isObject(call) && typeof call["m.call_id"] === "string") {
|
||||||
|
const devices: unknown[] = Array.isArray(call["m.devices"])
|
||||||
|
? call["m.devices"]
|
||||||
|
: [];
|
||||||
|
|
||||||
|
for (const device of devices) {
|
||||||
|
if (isObject(device) && (device["expires_ts"] as number) > now) {
|
||||||
|
const participantCount =
|
||||||
|
participantsPerCall.get(call["m.call_id"]) ?? 0;
|
||||||
|
participantsPerCall.set(call["m.call_id"], participantCount + 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (participantsPerCall.size > 1) {
|
||||||
|
PosthogAnalytics.instance.trackEvent({
|
||||||
|
eventName: "ParallelCalls",
|
||||||
|
participantsPerCall: Object.fromEntries(participantsPerCall),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
|
@ -29,7 +29,7 @@ import { CallFeed, CallFeedEvent } from "matrix-js-sdk/src/webrtc/callFeed";
|
||||||
import { RoomMember } from "matrix-js-sdk/src/models/room-member";
|
import { RoomMember } from "matrix-js-sdk/src/models/room-member";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { IWidgetApiRequest } from "matrix-widget-api";
|
import { IWidgetApiRequest } from "matrix-widget-api";
|
||||||
import { MatrixClient } from "matrix-js-sdk";
|
import { MatrixClient, RoomStateEvent } from "matrix-js-sdk";
|
||||||
import {
|
import {
|
||||||
ByteSentStatsReport,
|
ByteSentStatsReport,
|
||||||
ConnectionStatsReport,
|
ConnectionStatsReport,
|
||||||
|
@ -42,6 +42,7 @@ import { TranslatedError, translatedError } from "../TranslatedError";
|
||||||
import { ElementWidgetActions, ScreenshareStartData, widget } from "../widget";
|
import { ElementWidgetActions, ScreenshareStartData, widget } from "../widget";
|
||||||
import { OTelGroupCallMembership } from "../otel/OTelGroupCallMembership";
|
import { OTelGroupCallMembership } from "../otel/OTelGroupCallMembership";
|
||||||
import { ElementCallOpenTelemetry } from "../otel/otel";
|
import { ElementCallOpenTelemetry } from "../otel/otel";
|
||||||
|
import { checkForParallelCalls } from "./checkForParallelCalls";
|
||||||
|
|
||||||
export enum ConnectionState {
|
export enum ConnectionState {
|
||||||
EstablishingCall = "establishing call", // call hasn't been established yet
|
EstablishingCall = "establishing call", // call hasn't been established yet
|
||||||
|
@ -377,18 +378,19 @@ export function useGroupCall(
|
||||||
groupCall.on(GroupCallEvent.CallsChanged, onCallsChanged);
|
groupCall.on(GroupCallEvent.CallsChanged, onCallsChanged);
|
||||||
groupCall.on(GroupCallEvent.ParticipantsChanged, onParticipantsChanged);
|
groupCall.on(GroupCallEvent.ParticipantsChanged, onParticipantsChanged);
|
||||||
groupCall.on(GroupCallEvent.Error, onError);
|
groupCall.on(GroupCallEvent.Error, onError);
|
||||||
|
|
||||||
groupCall.on(
|
groupCall.on(
|
||||||
GroupCallStatsReportEvent.ConnectionStats,
|
GroupCallStatsReportEvent.ConnectionStats,
|
||||||
onConnectionStatsReport
|
onConnectionStatsReport
|
||||||
);
|
);
|
||||||
|
|
||||||
groupCall.on(
|
groupCall.on(
|
||||||
GroupCallStatsReportEvent.ByteSentStats,
|
GroupCallStatsReportEvent.ByteSentStats,
|
||||||
onByteSentStatsReport
|
onByteSentStatsReport
|
||||||
);
|
);
|
||||||
|
|
||||||
groupCall.on(GroupCallStatsReportEvent.SummaryStats, onSummaryStatsReport);
|
groupCall.on(GroupCallStatsReportEvent.SummaryStats, onSummaryStatsReport);
|
||||||
|
groupCall.room.currentState.on(
|
||||||
|
RoomStateEvent.Update,
|
||||||
|
checkForParallelCalls
|
||||||
|
);
|
||||||
|
|
||||||
updateState({
|
updateState({
|
||||||
error: null,
|
error: null,
|
||||||
|
@ -448,6 +450,10 @@ export function useGroupCall(
|
||||||
GroupCallStatsReportEvent.SummaryStats,
|
GroupCallStatsReportEvent.SummaryStats,
|
||||||
onSummaryStatsReport
|
onSummaryStatsReport
|
||||||
);
|
);
|
||||||
|
groupCall.room.currentState.off(
|
||||||
|
RoomStateEvent.Update,
|
||||||
|
checkForParallelCalls
|
||||||
|
);
|
||||||
leaveCall();
|
leaveCall();
|
||||||
};
|
};
|
||||||
}, [groupCall, updateState, leaveCall]);
|
}, [groupCall, updateState, leaveCall]);
|
||||||
|
|
171
test/room/checkForParallelCalls-test.ts
Normal file
171
test/room/checkForParallelCalls-test.ts
Normal file
|
@ -0,0 +1,171 @@
|
||||||
|
/*
|
||||||
|
Copyright 2023 New Vector Ltd
|
||||||
|
|
||||||
|
Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
you may not use this file except in compliance with the License.
|
||||||
|
You may obtain a copy of the License at
|
||||||
|
|
||||||
|
http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
|
||||||
|
Unless required by applicable law or agreed to in writing, software
|
||||||
|
distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
See the License for the specific language governing permissions and
|
||||||
|
limitations under the License.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { Mocked, mocked } from "jest-mock";
|
||||||
|
import { RoomState } from "matrix-js-sdk/src/models/room-state";
|
||||||
|
import { PosthogAnalytics } from "../../src/analytics/PosthogAnalytics";
|
||||||
|
import { checkForParallelCalls } from "../../src/room/checkForParallelCalls";
|
||||||
|
|
||||||
|
const withFakeTimers = (continuation: () => void) => {
|
||||||
|
jest.useFakeTimers();
|
||||||
|
try {
|
||||||
|
continuation();
|
||||||
|
} finally {
|
||||||
|
jest.useRealTimers();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const withMockedPosthog = (
|
||||||
|
continuation: (posthog: Mocked<PosthogAnalytics>) => void
|
||||||
|
) => {
|
||||||
|
const posthog = mocked({
|
||||||
|
trackEvent: jest.fn(),
|
||||||
|
} as unknown as PosthogAnalytics);
|
||||||
|
const instanceSpy = jest
|
||||||
|
.spyOn(PosthogAnalytics, "instance", "get")
|
||||||
|
.mockReturnValue(posthog);
|
||||||
|
try {
|
||||||
|
continuation(posthog);
|
||||||
|
} finally {
|
||||||
|
instanceSpy.mockRestore();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const mockRoomState = (
|
||||||
|
groupCallMemberContents: Record<string, unknown>[]
|
||||||
|
): RoomState => {
|
||||||
|
const stateEvents = groupCallMemberContents.map((content) => ({
|
||||||
|
getContent: () => content,
|
||||||
|
}));
|
||||||
|
return { getStateEvents: () => stateEvents } as unknown as RoomState;
|
||||||
|
};
|
||||||
|
|
||||||
|
test("checkForParallelCalls does nothing if all participants are in the same call", () => {
|
||||||
|
withFakeTimers(() => {
|
||||||
|
withMockedPosthog((posthog) => {
|
||||||
|
const roomState = mockRoomState([
|
||||||
|
{
|
||||||
|
"m.calls": [
|
||||||
|
{
|
||||||
|
"m.call_id": "1",
|
||||||
|
"m.devices": [
|
||||||
|
{
|
||||||
|
device_id: "Element Call",
|
||||||
|
session_id: "a",
|
||||||
|
expires_ts: Date.now() + 1000,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"m.call_id": null, // invalid
|
||||||
|
"m.devices": [
|
||||||
|
{
|
||||||
|
device_id: "Element Android",
|
||||||
|
session_id: "a",
|
||||||
|
expires_ts: Date.now() + 1000,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
null, // invalid
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"m.calls": [
|
||||||
|
{
|
||||||
|
"m.call_id": "1",
|
||||||
|
"m.devices": [
|
||||||
|
{
|
||||||
|
device_id: "Element Desktop",
|
||||||
|
session_id: "a",
|
||||||
|
expires_ts: Date.now() + 1000,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
checkForParallelCalls(roomState);
|
||||||
|
expect(posthog.trackEvent).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("checkForParallelCalls sends diagnostics to PostHog if there is a split-brain", () => {
|
||||||
|
withFakeTimers(() => {
|
||||||
|
withMockedPosthog((posthog) => {
|
||||||
|
const roomState = mockRoomState([
|
||||||
|
{
|
||||||
|
"m.calls": [
|
||||||
|
{
|
||||||
|
"m.call_id": "1",
|
||||||
|
"m.devices": [
|
||||||
|
{
|
||||||
|
device_id: "Element Call",
|
||||||
|
session_id: "a",
|
||||||
|
expires_ts: Date.now() + 1000,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"m.call_id": "2",
|
||||||
|
"m.devices": [
|
||||||
|
{
|
||||||
|
device_id: "Element Android",
|
||||||
|
session_id: "a",
|
||||||
|
expires_ts: Date.now() + 1000,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"m.calls": [
|
||||||
|
{
|
||||||
|
"m.call_id": "1",
|
||||||
|
"m.devices": [
|
||||||
|
{
|
||||||
|
device_id: "Element Desktop",
|
||||||
|
session_id: "a",
|
||||||
|
expires_ts: Date.now() + 1000,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"m.call_id": "2",
|
||||||
|
"m.devices": [
|
||||||
|
{
|
||||||
|
device_id: "Element Call",
|
||||||
|
session_id: "a",
|
||||||
|
expires_ts: Date.now() - 1000,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
checkForParallelCalls(roomState);
|
||||||
|
expect(posthog.trackEvent).toHaveBeenCalledWith({
|
||||||
|
eventName: "ParallelCalls",
|
||||||
|
participantsPerCall: {
|
||||||
|
"1": 2,
|
||||||
|
"2": 1,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
Loading…
Add table
Reference in a new issue