Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[MM-57737] Improve client side call state consistency #681

Merged
merged 3 commits into from
Apr 11, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions server/client_message.go
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ const (
clientMessageTypeReact = "react"
clientMessageTypeCaption = "caption"
clientMessageTypeMetric = "metric"
clientMessageTypeCallState = "call_state"
)

func (m *clientMessage) ToJSON() ([]byte, error) {
Expand Down
59 changes: 55 additions & 4 deletions server/websocket.go
Original file line number Diff line number Diff line change
Expand Up @@ -888,6 +888,40 @@ func (p *Plugin) handleReconnect(userID, connID, channelID, originalConnID, prev
return nil
}

func (p *Plugin) handleCallStateRequest(channelID, userID, connID string) error {
// We should go through only if the user has permissions to the requested channel
// or if the user is the Calls bot.
if !(p.isBot(userID) || p.API.HasPermissionToChannel(userID, channelID, model.PermissionReadChannel)) {
return fmt.Errorf("forbidden")
}

// Locking is not ideal but it's the only way to guarantee a race free
// sequence and a consistent state.
// On the client we should make sure to make this request only when strictly
// necessary (i.e first load, joining call, reconnecting).
state, err := p.lockCallReturnState(channelID)
if err != nil {
return fmt.Errorf("failed to lock call: %w", err)
}
defer p.unlockCall(channelID)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Would it make much of a difference if we immediately unocked? Looks like there's no reason no to, and it might reduce some of the non-idealness (and prevent the publish ws event from delaying the unlock).

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We have ways to get the call state without locking but the point here is that we need to queue the event to be sent through WS before unlocking otherwise we are subject to a race again.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ah, of course 🤦


if state == nil {
return fmt.Errorf("no call ongoing")
}

clientStateData, err := json.Marshal(state.getClientState(p.getBotID(), userID))
if err != nil {
return fmt.Errorf("failed to marshal client state: %w", err)
}

p.publishWebSocketEvent(wsEventCallState, map[string]interface{}{
"channel_id": channelID,
"call": string(clientStateData),
}, &model.WebsocketBroadcast{ConnectionId: connID, ReliableClusterSend: true})

return nil
}

func (p *Plugin) WebSocketMessageHasBeenPosted(connID, userID string, req *model.WebSocketRequest) {
var msg clientMessage
msg.Type = strings.TrimPrefix(req.Action, wsActionPrefix)
Expand All @@ -896,10 +930,14 @@ func (p *Plugin) WebSocketMessageHasBeenPosted(connID, userID string, req *model
us := p.sessions[connID]
p.mut.RUnlock()

if msg.Type != clientMessageTypeJoin &&
msg.Type != clientMessageTypeLeave &&
msg.Type != clientMessageTypeReconnect && us == nil {
return
if us == nil {
// Only a few events don't require a user session to exist. For anything else
// we should return.
switch msg.Type {
case clientMessageTypeJoin, clientMessageTypeLeave, clientMessageTypeReconnect, clientMessageTypeCallState:
default:
return
Comment on lines +936 to +939
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

As an aside, this is just bad language design. (I mean all c-derivatives here, not just go). Imagine you are a non-programmer (or a programmer who's used to fallthrough, or not used to fallthrough, really), is this quick to read through and know immediately what's happening? sheesh.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

yeah, I can revert to crazy if conditions if you prefer, just looked cleaner but not a big deal.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nah, no big deal, just complaining.

}
}

if us != nil && !us.wsMsgLimiter.Allow() {
Expand Down Expand Up @@ -985,6 +1023,19 @@ func (p *Plugin) WebSocketMessageHasBeenPosted(connID, userID string, req *model
p.LogError(err.Error())
}

return
case clientMessageTypeCallState:
p.metrics.IncWebSocketEvent("in", "call_state")

channelID, _ := req.Data["channelID"].(string)
if channelID == "" {
p.LogError("missing channelID")
return
}

if err := p.handleCallStateRequest(channelID, userID, connID); err != nil {
p.LogError("handleCallStateRequest failed", "err", err.Error(), "userID", userID, "connID", connID)
}
return
case clientMessageTypeSDP:
msgData, ok := req.Data["data"].([]byte)
Expand Down
65 changes: 65 additions & 0 deletions server/websocket_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -359,3 +359,68 @@ func TestWSReader(t *testing.T) {
})
})
}

func TestHandleCallStateRequest(t *testing.T) {
mockAPI := &pluginMocks.MockAPI{}
mockMetrics := &serverMocks.MockMetrics{}

p := Plugin{
MattermostPlugin: plugin.MattermostPlugin{
API: mockAPI,
},
callsClusterLocks: map[string]*cluster.Mutex{},
metrics: mockMetrics,
}

store, tearDown := NewTestStore(t)
t.Cleanup(tearDown)
p.store = store

mockAPI.On("LogDebug", mock.Anything, mock.Anything, mock.Anything,
mock.Anything, mock.Anything, mock.Anything, mock.Anything,
mock.Anything, mock.Anything, mock.Anything, mock.Anything)
mockAPI.On("KVSetWithOptions", mock.Anything, mock.Anything, mock.Anything).Return(true, nil)
mockMetrics.On("ObserveClusterMutexGrabTime", "mutex_call", mock.AnythingOfType("float64"))
mockMetrics.On("ObserveClusterMutexLockedTime", "mutex_call", mock.AnythingOfType("float64"))
mockMetrics.On("IncStoreOp", "KVGet")
mockMetrics.On("IncStoreOp", "KVSet")

channelID := model.NewId()
userID := model.NewId()
connID := model.NewId()

t.Run("no permissions", func(t *testing.T) {
mockAPI.On("KVDelete", "mutex_call_"+channelID).Return(nil).Once()
mockAPI.On("HasPermissionToChannel", userID, channelID, model.PermissionReadChannel).Return(false).Once()
err := p.handleCallStateRequest(channelID, userID, connID)
require.EqualError(t, err, "forbidden")
})

t.Run("no call ongoing", func(t *testing.T) {
mockAPI.On("KVDelete", "mutex_call_"+channelID).Return(nil).Once()
mockAPI.On("HasPermissionToChannel", userID, channelID, model.PermissionReadChannel).Return(true).Once()
err := p.handleCallStateRequest(channelID, userID, connID)
require.EqualError(t, err, "no call ongoing")
})

t.Run("active call", func(t *testing.T) {
err := p.store.CreateCall(&public.Call{
ID: model.NewId(),
CreateAt: time.Now().UnixMilli(),
ChannelID: channelID,
StartAt: time.Now().UnixMilli(),
PostID: model.NewId(),
ThreadID: model.NewId(),
OwnerID: model.NewId(),
})
require.NoError(t, err)

mockAPI.On("KVDelete", "mutex_call_"+channelID).Return(nil).Once()
mockAPI.On("HasPermissionToChannel", userID, channelID, model.PermissionReadChannel).Return(true).Once()
mockMetrics.On("IncWebSocketEvent", "out", "call_state").Once()
mockAPI.On("PublishWebSocketEvent", "call_state", mock.Anything, mock.Anything).Once()

err = p.handleCallStateRequest(channelID, userID, connID)
require.NoError(t, err)
})
}
9 changes: 9 additions & 0 deletions webapp/src/components/expanded_view/component.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -163,6 +163,8 @@ export default class ExpandedView extends React.PureComponent<Props, State> {
private pushToTalk = false;
private screenPlayer: HTMLVideoElement | null = null;

static contextType = window.ProductApi.WebSocketProvider;
cpoile marked this conversation as resolved.
Show resolved Hide resolved

#unlockNavigation?: () => void;

private genStyle: () => Record<string, React.CSSProperties> = () => {
Expand Down Expand Up @@ -592,9 +594,16 @@ export default class ExpandedView extends React.PureComponent<Props, State> {
public componentDidMount() {
const callsClient = getCallsClient();
if (!callsClient) {
logErr('callsClient should be defined');
return;
}

// On mount we request the call state through websocket. This avoids
// making a potentially racy HTTP call and should guarantee
// a consistent state.
logDebug('requesting call state through ws');
this.context.sendMessage('custom_com.mattermost.calls_call_state', {channelID: callsClient.channelID});
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I wonder if the websocket message isn't a bit confusing. Are you sending the call's state, or requesting it...?

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Just being consistent with other events there. We are using the direction (from/to client) to implicitly define whether it's request or response. I know you don't love it :p

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I guess HTTP fixes the confusion through the Method. Here we use a bit of context as it doesn't make any sense for the client to ever send the call state. Please let me know if this is blocking :)

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nope, not blocking :)


// keyboard shortcuts
window.addEventListener('keydown', this.handleKBShortcuts, true);
window.addEventListener('keyup', this.handleKeyUp, true);
Expand Down
Loading
Loading