From c4e2660ce4fdf14fc42b70d205ef1c6209579545 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ga=C3=ABl=20Bouquain?= Date: Tue, 8 Jul 2025 01:34:08 +0200 Subject: [PATCH 01/10] Fix concurrent connections to room Also: - Renamed things - Split signaling tests in several files TODO: - Maybe refactor - Replace locks with semaphores - Add concurrency tests --- .../Behide.OnlineServices.Tests.fsproj | 6 +- src/Behide.OnlineServices.Tests/Common.fs | 12 +- .../Tests/Signaling/Common.fs | 48 ++ .../Tests/Signaling/RoomManagement.fs | 642 ++++++++++++++++++ .../Tests/Signaling/Signaling.fs | 31 + .../WebRTCSignaling.fs} | 546 +-------------- src/Behide.OnlineServices.Types/Signaling.fs | 9 +- src/Behide.OnlineServices/Hubs/Signaling.fs | 282 ++++---- src/Behide.OnlineServices/Program.fs | 2 +- src/Behide.OnlineServices/Stores.fs | 1 - 10 files changed, 899 insertions(+), 680 deletions(-) create mode 100644 src/Behide.OnlineServices.Tests/Tests/Signaling/Common.fs create mode 100644 src/Behide.OnlineServices.Tests/Tests/Signaling/RoomManagement.fs create mode 100644 src/Behide.OnlineServices.Tests/Tests/Signaling/Signaling.fs rename src/Behide.OnlineServices.Tests/Tests/{Signaling.fs => Signaling/WebRTCSignaling.fs} (50%) diff --git a/src/Behide.OnlineServices.Tests/Behide.OnlineServices.Tests.fsproj b/src/Behide.OnlineServices.Tests/Behide.OnlineServices.Tests.fsproj index e570f95..a426963 100644 --- a/src/Behide.OnlineServices.Tests/Behide.OnlineServices.Tests.fsproj +++ b/src/Behide.OnlineServices.Tests/Behide.OnlineServices.Tests.fsproj @@ -9,7 +9,11 @@ - + + + + + diff --git a/src/Behide.OnlineServices.Tests/Common.fs b/src/Behide.OnlineServices.Tests/Common.fs index 3731d50..22008d7 100644 --- a/src/Behide.OnlineServices.Tests/Common.fs +++ b/src/Behide.OnlineServices.Tests/Common.fs @@ -10,9 +10,9 @@ open Behide.OnlineServices open Behide.OnlineServices.Signaling let createTestServer () = - let offerStore = Hubs.Signaling.ConnAttemptStore() + let connectionAttemptStore = Hubs.Signaling.ConnAttemptStore() let roomStore = Hubs.Signaling.RoomStore() - let playerConnStore = Hubs.Signaling.PlayerConnStore() + let playerConnStore = Hubs.Signaling.PlayerConnectionStore() let hostBuilder = WebHostBuilder() @@ -21,11 +21,11 @@ let createTestServer () = services.Remove(ServiceDescriptor.Singleton()) |> ignore services.Remove(ServiceDescriptor.Singleton()) |> ignore - services.Remove(ServiceDescriptor.Singleton()) |> ignore + services.Remove(ServiceDescriptor.Singleton()) |> ignore - services.AddSingleton(offerStore) |> ignore + services.AddSingleton(connectionAttemptStore) |> ignore services.AddSingleton(roomStore) |> ignore - services.AddSingleton(playerConnStore) |> ignore + services.AddSingleton(playerConnStore) |> ignore ) .Configure(fun app -> app.UseRouting() @@ -36,7 +36,7 @@ let createTestServer () = ) new TestServer(hostBuilder), - offerStore :> Store.IStore<_, _>, + connectionAttemptStore :> Store.IStore<_, _>, roomStore :> Store.IStore<_, _>, playerConnStore :> Store.IStore<_, _> diff --git a/src/Behide.OnlineServices.Tests/Tests/Signaling/Common.fs b/src/Behide.OnlineServices.Tests/Tests/Signaling/Common.fs new file mode 100644 index 0000000..a20ff06 --- /dev/null +++ b/src/Behide.OnlineServices.Tests/Tests/Signaling/Common.fs @@ -0,0 +1,48 @@ +module Behide.OnlineServices.Tests.Signaling.Common + +open Behide.OnlineServices.Signaling + +open System.Text.Json.Serialization +open System.Threading.Tasks + +open Microsoft.Extensions.DependencyInjection +open Microsoft.AspNetCore.Http.Connections.Client +open Microsoft.AspNetCore.SignalR +open Microsoft.AspNetCore.SignalR.Client +open Microsoft.AspNetCore.TestHost + +type SignalingHub(connection: HubConnection) = + interface ISignalingHub with + member _.StartConnectionAttempt sdpDescription = connection.InvokeAsync<_>("StartConnectionAttempt", sdpDescription) + member _.JoinConnectionAttempt offerId = connection.InvokeAsync<_>("JoinConnectionAttempt", offerId) + member _.SendAnswer offerId iceCandidate = connection.InvokeAsync<_>("SendAnswer", offerId, iceCandidate) + member _.SendIceCandidate offerId iceCandidate = connection.InvokeAsync<_>("SendIceCandidate", offerId, iceCandidate) + member _.EndConnectionAttempt offerId = connection.InvokeAsync<_>("EndConnectionAttempt", offerId) + + member _.CreateRoom () = connection.InvokeAsync<_>("CreateRoom") + member _.JoinRoom roomId = connection.InvokeAsync<_>("JoinRoom", roomId) + member _.LeaveRoom () = connection.InvokeAsync<_>("LeaveRoom") + member _.ConnectToRoomPlayers() = connection.InvokeAsync<_>("ConnectToRoomPlayers") + + +let connectHub (testServer: TestServer) : Task = + let httpConnectionOptions (options: HttpConnectionOptions) = + options.HttpMessageHandlerFactory <- fun _ -> testServer.CreateHandler() + + let setupJsonProtocol (options: JsonHubProtocolOptions) = + JsonFSharpOptions + .Default() + .AddToJsonSerializerOptions(options.PayloadSerializerOptions) + + let url = testServer.BaseAddress.ToString() + "webrtc-signaling" + + let connection = + HubConnectionBuilder() + .WithUrl(url, httpConnectionOptions) + .AddJsonProtocol(setupJsonProtocol) + .Build() + + task { + do! connection.StartAsync() + return connection, SignalingHub(connection) :> ISignalingHub + } diff --git a/src/Behide.OnlineServices.Tests/Tests/Signaling/RoomManagement.fs b/src/Behide.OnlineServices.Tests/Tests/Signaling/RoomManagement.fs new file mode 100644 index 0000000..e54fbe3 --- /dev/null +++ b/src/Behide.OnlineServices.Tests/Tests/Signaling/RoomManagement.fs @@ -0,0 +1,642 @@ +module Behide.OnlineServices.Tests.Signaling.RoomManagement + +open Expecto +open FsToolkit.ErrorHandling +open Microsoft.AspNetCore.SignalR.Client + +open System +open System.Collections.Concurrent +open System.Collections.Generic +open System.Threading +open System.Threading.Tasks + +open Behide.OnlineServices +open Behide.OnlineServices.Signaling +open Behide.OnlineServices.Tests +open Behide.OnlineServices.Tests.Signaling.Common + +let tests testServer (roomStore: Hubs.Signaling.IRoomStore) = + testList "RoomManagement" [ + testList "CreateRoom" [ + testTask "Create room should success" { + let! (_, signalingHub: ISignalingHub) = testServer |> connectHub + + let! roomId = + signalingHub.CreateRoom() + |> Task.map (Flip.Expect.wantOk "Room creation should success") + + let room = + roomStore.Get roomId + |> Flip.Expect.wantSome "Room should be created" + + Expect.equal room.Id roomId "Room ID should be the same" + } + + testTask "Create room while already in a room" { + let! (_, signalingHub: ISignalingHub) = testServer |> connectHub + + do! signalingHub.CreateRoom() + |> Task.map (Flip.Expect.isOk "Room creation should success") + + let! (error: Errors.CreateRoomError) = + signalingHub.CreateRoom() + |> Task.map (Flip.Expect.wantError "Second room creation should return an error") + + Expect.equal + error + Errors.CreateRoomError.PlayerAlreadyInARoom + "Room creation should fail" + } + ] + + testList "JoinRoom" [ + testTask "Join room should success" { + let! (conn1: HubConnection, signalingHub1: ISignalingHub) = testServer |> connectHub + let! (conn2: HubConnection, signalingHub2: ISignalingHub) = testServer |> connectHub + + let mutable offerId = None + + // Add handler for creating offer + conn1.On("ConnectionRequested", fun _playerId -> + Common.fakeSdpDescription + |> signalingHub1.StartConnectionAttempt + |> Task.map (function + | Ok o -> offerId <- Some o; o + | Error e -> failwithf "Failed to create offer: %A" e) + ) + |> ignore + + + // Create a room and check if it was created + let! roomId = + signalingHub1.CreateRoom() + |> Task.map (Flip.Expect.wantOk "Room creation should success") + + roomStore.Get roomId + |> Flip.Expect.isSome "Room should be created" + + + // Join the room + let! playerId = + signalingHub2.JoinRoom roomId + |> Task.map (Flip.Expect.wantOk "Room joining should success") + + // Retrieve the updated room + let room = + roomStore.Get roomId + |> Flip.Expect.wantSome "Room should still exist" + + + // Check if the room connection info is correct + Expect.equal playerId 2 "Peer ID should be 2 in that case" + + // Check if the room contains both players + Expect.equal + conn1.ConnectionId + (room.Initiator |> ConnId.raw) + "Room initiator should be the first player connection id" + + Expect.containsAll + room.Players + [ KeyValuePair(conn1.ConnectionId |> ConnId.parse, 1) + KeyValuePair(conn2.ConnectionId |> ConnId.parse, 2) ] + "Room should contain both player connection ids" + } + + testTask "Join room while already in a room" { + let! (_, signalingHub: ISignalingHub) = testServer |> connectHub + + let! roomId = + signalingHub.CreateRoom() + |> Task.map (Flip.Expect.wantOk "Room creation should success") + + let! (error: Errors.JoinRoomError) = + roomId + |> signalingHub.JoinRoom + |> Task.map (Flip.Expect.wantError "Joining room should return an error") + + Expect.equal + error + Errors.JoinRoomError.PlayerAlreadyInARoom + "Room joining should fail" + } + + testTask "Join nonexisting room" { + let! (_, signalingHub: ISignalingHub) = testServer |> connectHub + + let fakeRoomId = RoomId.create() + + let! (error: Errors.JoinRoomError) = + fakeRoomId + |> signalingHub.JoinRoom + |> Task.map (Flip.Expect.wantError "Joining nonexisting room should return an error") + + Expect.equal + error + Errors.JoinRoomError.RoomNotFound + "Nonexisting room joining should fail" + } + + testTask "Joining room should give a unique playerId" { + let! (_, signalingHub1: ISignalingHub) = testServer |> connectHub + let! (_, signalingHub2: ISignalingHub) = testServer |> connectHub + let! (_, signalingHub3: ISignalingHub) = testServer |> connectHub + + // Initialization + let! roomId = + signalingHub1.CreateRoom() + |> Task.map (Flip.Expect.wantOk "Room creation should success") + + let playerId1 = 1 // The first player should always have the playerId 1 + + // Join room + let! playerId2 = + signalingHub2.JoinRoom roomId + |> Task.map (Flip.Expect.wantOk "Room joining should success") + + Expect.equal playerId2 2 "The second player should have the playerId to 2" + Expect.notEqual playerId1 playerId2 "The two players should have different playerId" + + // Leave and rejoin room + do! signalingHub2.LeaveRoom() + |> Task.map (Flip.Expect.wantOk "Leaving room should success") + + let! playerId3 = + signalingHub2.JoinRoom roomId + |> Task.map (Flip.Expect.wantOk "Room joining should success") + + Expect.equal playerId3 2 "The second player should have the playerId to 2" + Expect.notEqual playerId1 playerId3 "The two players should have different playerId" + + // Join room with a third player + let! playerId4 = + signalingHub3.JoinRoom roomId + |> Task.map (Flip.Expect.wantOk "Room joining should success") + + Expect.equal playerId4 3 "The third player should have the playerId to 3" + Expect.notEqual playerId1 playerId4 "The two players should have different playerId" + } + ] + + testList "ConnectToRoomPlayers" [ + testTask "Connect to room players" { + // Create room + let! (conn1: HubConnection, signalingHub1: ISignalingHub) = testServer |> connectHub + + let! roomId = + signalingHub1.CreateRoom() + |> Task.map (Flip.Expect.wantOk "Failed to create a room") + + // Join room + let! (conn2: HubConnection, signalingHub2: ISignalingHub) = testServer |> connectHub + + let! secondPlayerId = + signalingHub2.JoinRoom roomId + |> Task.map (Flip.Expect.wantOk "Failed to join room") + + Expect.equal secondPlayerId 2 "The second player should have a playerId/peerId to 2" + + // Register "ConnectionRequested" handler on first player + let mutable connAttemptId = None + + conn1.On("ConnectionRequested", fun _playerId -> + Common.fakeSdpDescription + |> signalingHub1.StartConnectionAttempt + |> Task.map (function + | Ok c -> connAttemptId <- Some c; c + | Error e -> failtestf "Failed to create connection attempt: %A" e) + ) + |> ignore + + // Connect players + let! (res: RoomConnectionInfo) = + signalingHub2.ConnectToRoomPlayers() + |> Task.map (Flip.Expect.wantOk "Failed to create connection information for players") + + Expect.isEmpty res.FailedCreations "No connection attempt creation should be failed" + + let connAttemptId = Expect.wantSome connAttemptId "An connection attempt id should have been generated" + Expect.sequenceEqual + (res.PlayersConnInfo |> List.ofArray) + [ { PeerId = 1; ConnAttemptId = connAttemptId } ] + "Players connection info should contain the first player connection info and only that" + + // Check connections + let room = + roomStore.Get roomId + |> Flip.Expect.wantSome "Room should still exist" + + let connId1 = conn1.ConnectionId |> ConnId.parse + let connId2 = conn2.ConnectionId |> ConnId.parse + let connection = + min connId1 connId2, + max connId1 connId2 + + Expect.containsAll + room.Connections + [ connection ] + "Room should contain the connection between the two players" + } + + testTheoryTask + "Connect to room players with multiple players" + [ 3, 0 + 10, 4 + 100, 99 ] + <| fun (nbOfPeers: int, peerThatConnects: int) -> task { + let! players = + List.init nbOfPeers (fun _ -> testServer |> connectHub) + |> Task.WhenAll + |> Task.map List.ofArray + + // Register handlers + let cts = new CancellationTokenSource() + let connAttemptIdsTask = + players + |> List.indexed + |> List.choose (fun (idx, (conn, hub)) -> + let connAttemptIdTcs = TaskCompletionSource>() + cts.Token.Register(fun _ -> connAttemptIdTcs.TrySetCanceled() |> ignore) |> ignore + + conn.On("ConnectionRequested", fun _ -> + Common.fakeSdpDescription + |> hub.StartConnectionAttempt + |> Task.map (fun res -> + connAttemptIdTcs.SetResult(res) + + match res with + | Ok c -> c + | Error _ -> failwith "Failed to create connection attempt" + ) + ) + |> ignore + + match idx = peerThatConnects with // Don't await the connection attempt creation for the peer that will connect + | true -> None + | false -> Some connAttemptIdTcs.Task + ) + |> List.sequenceTaskResultA + |> Task.map (Flip.Expect.wantOk "Failed to create some connection attempt") + + // Create room + let! roomId = + (players |> List.head |> snd).CreateRoom() + |> Task.map (Flip.Expect.wantOk "Failed to create room") + + // Join room + do! players + |> List.tail + |> List.map (fun (_, hub) -> hub.JoinRoom roomId) + |> List.sequenceTaskResultA + |> Task.map (Flip.Expect.isOk "All players should be able to join the room") + + // Connect players + let hub = players |> List.item peerThatConnects |> snd + let! res = + hub.ConnectToRoomPlayers() + |> Task.map (Flip.Expect.wantOk "Failed to create connection information for players") + + cts.CancelAfter 1000 + let! connAttemptIds = connAttemptIdsTask + + Expect.isEmpty res.FailedCreations "All connection attempt creations should be successful" + Expect.hasLength res.PlayersConnInfo (players.Length - 1) "Their should be one player connection info per player to connect to" + Expect.hasLength connAttemptIds (players.Length - 1) "Their should be one connection attempt id per players to connect to" + Expect.hasLength connAttemptIds res.PlayersConnInfo.Length "Their should be one connection attempt id per player connection info" + + Expect.containsAll + connAttemptIds + (res.PlayersConnInfo |> Array.map _.ConnAttemptId) + "Created connection attempt ids should be the same that the received connection attempt ids" + + // Check connections + let room = + roomStore.Get roomId + |> Flip.Expect.wantSome "Room should still exist" + + let connIdThatConnects = + players + |> List.item peerThatConnects + |> fst + |> _.ConnectionId + |> ConnId.parse + + let expectedConnections = + players + |> List.choose (fun (conn, _) -> + let connId = conn.ConnectionId |> ConnId.parse + + match connId <> connIdThatConnects with + | false -> None // The player that connects should not be connected to himself + | true -> + Some (min connIdThatConnects connId, + max connIdThatConnects connId) + ) + + Expect.containsAll + room.Connections + expectedConnections + "Room should contain the all the new connections" + } + + testTheoryTask + "Simultaneously connect players" + [ 3, [1; 2] + 10, [5; 6; 7; 8; 9] + 100, (List.init 50 ((+) 50)) + 100, (List.init 99 ((+) 1)) ] + <| fun (nbOfPlayers: int, playersThatConnect: int list) -> task { + let! players = + List.init nbOfPlayers (fun _ -> testServer |> connectHub) + |> Task.WhenAll + |> Task.map List.ofArray + + // Register handlers + let connectionAttemptIds = ConcurrentBag() + players |> List.iter (fun (hubConnection, hub) -> + hubConnection.On("ConnectionRequested", fun requestingPeerId -> + Task.Run(fun () -> + Common.fakeSdpDescription + |> hub.StartConnectionAttempt + |> TaskResult.tee connectionAttemptIds.Add + |> Task.map (Flip.Expect.wantOk "Connection attempt creation should succeed") + ) + ) + |> ignore + ) + + // Create room + let! roomId = + players + |> List.head + |> snd + |> _.CreateRoom() + |> Task.map (Flip.Expect.wantOk "Failed to create room") + + // Join room + let! playerIndexByPlayerPeerId = + players + |> List.tail + |> List.mapi (fun idx (_, hub) -> + let playerIdx = idx + 1 + hub.JoinRoom roomId + |> TaskResult.map (fun peerId -> peerId, playerIdx) + ) + |> List.sequenceTaskResultA + |> Task.map ( + Flip.Expect.wantOk "All players should be able to join the room" + >> List.append [ 1, 0 ] // Room creator + >> dict + ) + + // Connect players + let cts = new CancellationTokenSource(TimeSpan.FromSeconds 10.) + let connectionsMade = Array.init nbOfPlayers (fun _ -> Array.create nbOfPlayers false) + + do! Parallel.ForEachAsync( + playersThatConnect, + ParallelOptions( + MaxDegreeOfParallelism = playersThatConnect.Length, + CancellationToken = cts.Token + ), + Func(fun playerIdx _ -> + task { + let hub = players[playerIdx] |> snd + + let! res = hub.ConnectToRoomPlayers() + let connectionInfo = Expect.wantOk res "ConnectToRoomPlayers method should succeed" + Expect.isEmpty connectionInfo.FailedCreations $"All connection attempt creations should be successful: PlayerIdx {playerIdx}" + + for connectionInfo in connectionInfo.PlayersConnInfo do + let targetPlayerIdx = playerIndexByPlayerPeerId[connectionInfo.PeerId] + connectionsMade[playerIdx][targetPlayerIdx] <- true + connectionsMade[targetPlayerIdx][playerIdx] <- true + } + |> ValueTask + ) + ) + + // Check that players that should connect are connected to every other players + playersThatConnect |> List.iter (fun connectingPlayerIdx -> + let playerConnections = connectionsMade[connectingPlayerIdx] + + playerConnections |> Array.iteri (fun targetPlayerIdx isConnected -> + match connectingPlayerIdx = targetPlayerIdx with + | true -> + Expect.isFalse + isConnected + $"Connection {connectingPlayerIdx} <-> {targetPlayerIdx} should not be established. We cannot connect to ourself" + | false -> + Expect.isTrue + isConnected + $"Connection {connectingPlayerIdx} <-> {targetPlayerIdx} should be established" + ) + ) + + // Check connections are well represented on the room store + let room = roomStore.Get roomId |> Flip.Expect.wantSome "Room should still exist" + + Expect.isEmpty room.ConnectionsInProgress "No connection should still be in progress" + + let expectedConnections = + playersThatConnect |> List.collect (fun playerIdx -> + let requestingConnectionId = + players[playerIdx] + |> fst + |> _.ConnectionId + |> ConnId.parse + + List.init nbOfPlayers (fun otherPlayerIdx -> + players[otherPlayerIdx] + |> fst + |> _.ConnectionId + |> ConnId.parse + ) + |> List.filter ((<>) requestingConnectionId) + |> List.map (fun otherConnectionId -> + min requestingConnectionId otherConnectionId, + max requestingConnectionId otherConnectionId + ) + ) + + Expect.containsAll room.Connections expectedConnections "All connections should be present" + } + + testTask "Connect to players without a ConnectionRequested handler" { + let! (_, signalingHub1: ISignalingHub) = testServer |> connectHub + let! (_, signalingHub2: ISignalingHub) = testServer |> connectHub + + let! roomId = + signalingHub1.CreateRoom() + |> Task.map (Flip.Expect.wantOk "Room creation should success") + + do! signalingHub2.JoinRoom roomId + |> Task.map (Flip.Expect.wantOk "Room joining should success") + + let! (res: RoomConnectionInfo) = + signalingHub2.ConnectToRoomPlayers() + |> Task.map (Flip.Expect.wantOk "Connecting to room players should return an \"Ok\" result") + + Expect.sequenceEqual + res.FailedCreations + [ 1 ] // 1 is the peerId of the first player, the one who created the room + "Joining room without add handler for ConnectionRequested should fail" + + Expect.isEmpty res.PlayersConnInfo "No connection info should be returned" + } + + testTask "Connect to players with a blocking ConnectionRequested handler" { + let! (conn1: HubConnection, signalingHub1: ISignalingHub) = testServer |> connectHub + let! (_, signalingHub2: ISignalingHub) = testServer |> connectHub + + // Create room + let! roomId = + signalingHub1.CreateRoom() + |> Task.map (Flip.Expect.wantOk "Room creation should success") + + // Join room + do! signalingHub2.JoinRoom roomId + |> Task.map (Flip.Expect.wantOk "Room joining should success") + + // Register blocking "ConnectionRequested" handler on first player + conn1.On("ConnectionRequested", fun _playerId -> + while true do () // Never returns + ConnAttemptId.create() + ) + |> ignore + + let! (res: RoomConnectionInfo) = + signalingHub2.ConnectToRoomPlayers() + |> Task.map (Flip.Expect.wantOk "Connecting to room players should return an \"Ok\" result") + + Expect.sequenceEqual + res.FailedCreations + [ 1 ] // 1 is the peerId of the first player, the one who created the room + "Joining room with a blocking ConnectionRequested handler should fail" + + Expect.isEmpty res.PlayersConnInfo "No connection info should be returned" + } + + testTask "Connect to players while not in a room" { + let! (_, signalingHub: ISignalingHub) = testServer |> connectHub + + let! (error: Errors.ConnectToRoomPlayersError) = + signalingHub.ConnectToRoomPlayers() + |> Task.map (Flip.Expect.wantError "Connecting to room players while not in a room should return an error") + + Expect.equal + error + Errors.ConnectToRoomPlayersError.NotInARoom + "Connecting to room players while not in a room should fail" + } + ] + + testList "LeaveRoom" [ + testTask "Leave room" { + // Create room + let! (conn1: HubConnection, signalingHub1: ISignalingHub) = testServer |> connectHub + + let! roomId = + signalingHub1.CreateRoom() + |> Task.map (Flip.Expect.wantOk "Room creation should success") + + // Join room + let! (_, signalingHub2: ISignalingHub) = testServer |> connectHub + + do! roomId + |> signalingHub2.JoinRoom + |> Task.map (Flip.Expect.isOk "Room joining should success") + + // Leave room + do! signalingHub2.LeaveRoom() + |> Task.map (Flip.Expect.wantOk "Leaving room should success") + + // Check if the player is not in the room anymore + let room = + roomStore.Get roomId + |> Flip.Expect.wantSome "Room should still exist" + + Expect.sequenceEqual + room.Players + [ KeyValuePair(conn1.ConnectionId |> ConnId.parse, 1) ] + "Room should not contain the second player" + + Expect.isEmpty room.Connections "Room should not contain any connection" + } + + testTask "Leave room where we are connected to players" { + // Create room + let! (conn1: HubConnection, signalingHub1: ISignalingHub) = testServer |> connectHub + + let! roomId = + signalingHub1.CreateRoom() + |> Task.map (Flip.Expect.wantOk "Room creation should success") + + // Join room + let! (_, signalingHub2: ISignalingHub) = testServer |> connectHub + + do! roomId + |> signalingHub2.JoinRoom + |> Task.map (Flip.Expect.isOk "Room joining should success") + + // Connect to room players + conn1.On("ConnectionRequested", fun _playerId -> + Common.fakeSdpDescription + |> signalingHub1.StartConnectionAttempt + |> Task.map (function + | Ok c -> c + | Error e -> failwithf "Failed to create connection attempt: %A" e) + ) + |> ignore + + do! signalingHub2.ConnectToRoomPlayers() + |> Task.map (Flip.Expect.isOk "Connecting to room players should success") + + // Leave room + do! signalingHub2.LeaveRoom() + |> Task.map (Flip.Expect.isOk "Leaving room should success") + + // Check if the player is not in the room anymore + let room = + roomStore.Get roomId + |> Flip.Expect.wantSome "Room should still exist" + + Expect.sequenceEqual + room.Players + [ KeyValuePair(conn1.ConnectionId |> ConnId.parse, 1) ] + "Room should not contain the second player" + + Expect.isEmpty room.Connections "Room should not contain any connection" + } + + testTask "Leave room while not in a room" { + let! (_, signalingHub: ISignalingHub) = testServer |> connectHub + + let! (error: Errors.LeaveRoomError) = + signalingHub.LeaveRoom() + |> Task.map (Flip.Expect.wantError "Leaving room while not in a room should return an error") + + Expect.equal + error + Errors.LeaveRoomError.NotInARoom + "Leaving room while not in a room should fail" + } + + testTask "Leave room while being the last player delete the room" { + let! (_, hub1: ISignalingHub) = testServer |> connectHub + + // Create room + let! roomId = + hub1.CreateRoom() + |> Task.map (Flip.Expect.wantOk "Room creation should success") + + // Leave room + do! hub1.LeaveRoom() + |> Task.map (Flip.Expect.wantOk "Leaving room should success") + + // Check if the room is removed + roomStore.Get roomId + |> Flip.Expect.isNone "Room should be removed" + } + ] + ] diff --git a/src/Behide.OnlineServices.Tests/Tests/Signaling/Signaling.fs b/src/Behide.OnlineServices.Tests/Tests/Signaling/Signaling.fs new file mode 100644 index 0000000..5ebba90 --- /dev/null +++ b/src/Behide.OnlineServices.Tests/Tests/Signaling/Signaling.fs @@ -0,0 +1,31 @@ +module Behide.OnlineServices.Tests.Signaling.All + +open Expecto +open Microsoft.AspNetCore.SignalR.Client + +open Behide.OnlineServices.Signaling +open Behide.OnlineServices.Tests +open Behide.OnlineServices.Tests.Signaling +open Behide.OnlineServices.Tests.Signaling.Common + +[] +let signalingTests = + let testServer, connectionAttemptStore, roomStore, playerConnStore = Common.createTestServer() + + testList "Signaling" [ + testTask "Signaling hub connection should success" { + let! (connection: HubConnection, _) = testServer |> connectHub + + Expect.equal connection.State HubConnectionState.Connected "Should be connected to the hub" + + let connId = connection.ConnectionId |> ConnId.parse + let playerConn = + playerConnStore.Get connId + |> Flip.Expect.wantSome "Client should be registered in the player connections store" + + Expect.equal playerConn.ConnectionId connId "Connection ID should be the same" + } + + RoomManagement.tests testServer roomStore + WebRTCSignaling.tests testServer connectionAttemptStore + ] diff --git a/src/Behide.OnlineServices.Tests/Tests/Signaling.fs b/src/Behide.OnlineServices.Tests/Tests/Signaling/WebRTCSignaling.fs similarity index 50% rename from src/Behide.OnlineServices.Tests/Tests/Signaling.fs rename to src/Behide.OnlineServices.Tests/Tests/Signaling/WebRTCSignaling.fs index 0df42d5..0665f9c 100644 --- a/src/Behide.OnlineServices.Tests/Tests/Signaling.fs +++ b/src/Behide.OnlineServices.Tests/Tests/Signaling/WebRTCSignaling.fs @@ -1,538 +1,16 @@ -module Behide.OnlineServices.Tests.SignalingHub - -open Microsoft.AspNetCore.TestHost -open Microsoft.AspNetCore.SignalR -open Microsoft.AspNetCore.SignalR.Client -open Microsoft.AspNetCore.Http.Connections.Client -open Microsoft.Extensions.DependencyInjection -open System.Threading -open System.Threading.Tasks -open System.Text.Json.Serialization +module Behide.OnlineServices.Tests.Signaling.WebRTCSignaling open Expecto open FsToolkit.ErrorHandling +open Microsoft.AspNetCore.SignalR.Client +open Behide.OnlineServices open Behide.OnlineServices.Signaling +open Behide.OnlineServices.Tests +open Behide.OnlineServices.Tests.Signaling.Common - -type SignalingHub(connection: HubConnection) = - interface ISignalingHub with - member _.StartConnectionAttempt sdpDescription = connection.InvokeAsync<_>("StartConnectionAttempt", sdpDescription) - member _.JoinConnectionAttempt offerId = connection.InvokeAsync<_>("JoinConnectionAttempt", offerId) - member _.SendAnswer offerId iceCandidate = connection.InvokeAsync<_>("SendAnswer", offerId, iceCandidate) - member _.SendIceCandidate offerId iceCandidate = connection.InvokeAsync<_>("SendIceCandidate", offerId, iceCandidate) - member _.EndConnectionAttempt offerId = connection.InvokeAsync<_>("EndConnectionAttempt", offerId) - - member _.CreateRoom () = connection.InvokeAsync<_>("CreateRoom") - member _.JoinRoom roomId = connection.InvokeAsync<_>("JoinRoom", roomId) - member _.LeaveRoom () = connection.InvokeAsync<_>("LeaveRoom") - member _.ConnectToRoomPlayers() = connection.InvokeAsync<_>("ConnectToRoomPlayers") - - -let connectHub (testServer: TestServer) : Task = - let httpConnectionOptions (options: HttpConnectionOptions) = - options.HttpMessageHandlerFactory <- fun _ -> testServer.CreateHandler() - - let setupJsonProtocol (options: JsonHubProtocolOptions) = - JsonFSharpOptions - .Default() - .AddToJsonSerializerOptions(options.PayloadSerializerOptions) - - let url = testServer.BaseAddress.ToString() + "webrtc-signaling" - - let connection = - HubConnectionBuilder() - .WithUrl(url, httpConnectionOptions) - .AddJsonProtocol(setupJsonProtocol) - .Build() - - task { - do! connection.StartAsync() - return connection, SignalingHub(connection) :> ISignalingHub - } - - -[] -let signalingTests = - let testServer, offerStore, roomStore, playerConnStore = Common.createTestServer() - - testList "Signaling tests" [ - testTask "Signaling hub connection should success" { - let! (connection: HubConnection, _) = testServer |> connectHub - - Expect.equal connection.State HubConnectionState.Connected "Should be connected to the hub" - - let connId = connection.ConnectionId |> ConnId.parse - let playerConn = - playerConnStore.Get connId - |> Flip.Expect.wantSome "Client should be registered in the player connections store" - - Expect.equal playerConn.ConnectionId connId "Connection ID should be the same" - } - - testList "CreateRoom" [ - testTask "Create room should success" { - let! (_, signalingHub: ISignalingHub) = testServer |> connectHub - - let! roomId = - signalingHub.CreateRoom() - |> Task.map (Flip.Expect.wantOk "Room creation should success") - - let room = - roomStore.Get roomId - |> Flip.Expect.wantSome "Room should be created" - - Expect.equal room.Id roomId "Room ID should be the same" - } - - testTask "Create room while already in a room" { - let! (_, signalingHub: ISignalingHub) = testServer |> connectHub - - do! signalingHub.CreateRoom() - |> Task.map (Flip.Expect.isOk "Room creation should success") - - let! (error: Errors.CreateRoomError) = - signalingHub.CreateRoom() - |> Task.map (Flip.Expect.wantError "Second room creation should return an error") - - Expect.equal - error - Errors.CreateRoomError.PlayerAlreadyInARoom - "Room creation should fail" - } - ] - - testList "JoinRoom" [ - testTask "Join room should success" { - let! (conn1: HubConnection, signalingHub1: ISignalingHub) = testServer |> connectHub - let! (conn2: HubConnection, signalingHub2: ISignalingHub) = testServer |> connectHub - - let mutable offerId = None - - // Add handler for creating offer - conn1.On("ConnectionRequested", fun _playerId -> - Common.fakeSdpDescription - |> signalingHub1.StartConnectionAttempt - |> Task.map (function - | Ok o -> offerId <- Some o; o - | Error e -> failwithf "Failed to create offer: %A" e) - ) - |> ignore - - - // Create a room and check if it was created - let! roomId = - signalingHub1.CreateRoom() - |> Task.map (Flip.Expect.wantOk "Room creation should success") - - roomStore.Get roomId - |> Flip.Expect.isSome "Room should be created" - - - // Join the room - let! playerId = - signalingHub2.JoinRoom roomId - |> Task.map (Flip.Expect.wantOk "Room joining should success") - - // Retrieve the updated room - let room = - roomStore.Get roomId - |> Flip.Expect.wantSome "Room should still exist" - - - // Check if the room connection info is correct - Expect.equal playerId 2 "Peer ID should be 2 in that case" - - // Check if the room contains both players - Expect.equal - conn1.ConnectionId - (room.Initiator |> ConnId.raw) - "Room initiator should be the first player connection id" - - Expect.containsAll - room.Players - [ 1, conn1.ConnectionId |> ConnId.parse - 2, conn2.ConnectionId |> ConnId.parse ] - "Room should contain both player connection ids" - } - - testTask "Join room while already in a room" { - let! (_, signalingHub: ISignalingHub) = testServer |> connectHub - - let! roomId = - signalingHub.CreateRoom() - |> Task.map (Flip.Expect.wantOk "Room creation should success") - - let! (error: Errors.JoinRoomError) = - roomId - |> signalingHub.JoinRoom - |> Task.map (Flip.Expect.wantError "Joining room should return an error") - - Expect.equal - error - Errors.JoinRoomError.PlayerAlreadyInARoom - "Room joining should fail" - } - - testTask "Join nonexisting room" { - let! (_, signalingHub: ISignalingHub) = testServer |> connectHub - - let fakeRoomId = RoomId.create() - - let! (error: Errors.JoinRoomError) = - fakeRoomId - |> signalingHub.JoinRoom - |> Task.map (Flip.Expect.wantError "Joining nonexisting room should return an error") - - Expect.equal - error - Errors.JoinRoomError.RoomNotFound - "Nonexisting room joining should fail" - } - - testTask "Joining room should give a unique playerId" { - let! (_, signalingHub1: ISignalingHub) = testServer |> connectHub - let! (_, signalingHub2: ISignalingHub) = testServer |> connectHub - let! (_, signalingHub3: ISignalingHub) = testServer |> connectHub - - // Initialization - let! roomId = - signalingHub1.CreateRoom() - |> Task.map (Flip.Expect.wantOk "Room creation should success") - - let playerId1 = 1 // The first player should always have the playerId 1 - - // Join room - let! playerId2 = - signalingHub2.JoinRoom roomId - |> Task.map (Flip.Expect.wantOk "Room joining should success") - - Expect.equal playerId2 2 "The second player should have the playerId to 2" - Expect.notEqual playerId1 playerId2 "The two players should have different playerId" - - // Leave and rejoin room - do! signalingHub2.LeaveRoom() - |> Task.map (Flip.Expect.wantOk "Leaving room should success") - - let! playerId3 = - signalingHub2.JoinRoom roomId - |> Task.map (Flip.Expect.wantOk "Room joining should success") - - Expect.equal playerId3 2 "The second player should have the playerId to 2" - Expect.notEqual playerId1 playerId3 "The two players should have different playerId" - - // Join room with a third player - let! playerId4 = - signalingHub3.JoinRoom roomId - |> Task.map (Flip.Expect.wantOk "Room joining should success") - - Expect.equal playerId4 3 "The third player should have the playerId to 3" - Expect.notEqual playerId1 playerId4 "The two players should have different playerId" - } - ] - - testList "ConnectToRoomPlayers" [ - testTask "Connect to room players" { - // Create room - let! (conn1: HubConnection, signalingHub1: ISignalingHub) = testServer |> connectHub - - let! roomId = - signalingHub1.CreateRoom() - |> Task.map (Flip.Expect.wantOk "Failed to create a room") - - // Join room - let! (conn2: HubConnection, signalingHub2: ISignalingHub) = testServer |> connectHub - - let! secondPlayerId = - signalingHub2.JoinRoom roomId - |> Task.map (Flip.Expect.wantOk "Failed to join room") - - Expect.equal secondPlayerId 2 "The second player should have a playerId/peerId to 2" - - // Register "ConnectionRequested" handler on first player - let mutable connAttemptId = None - - conn1.On("ConnectionRequested", fun _playerId -> - Common.fakeSdpDescription - |> signalingHub1.StartConnectionAttempt - |> Task.map (function - | Ok c -> connAttemptId <- Some c; c - | Error e -> failwithf "Failed to create connection attempt: %A" e) - ) - |> ignore - - // Connect players - let! (res: RoomConnectionInfo) = - signalingHub2.ConnectToRoomPlayers() - |> Task.map (Flip.Expect.wantOk "Failed to create connection information for players") - - Expect.isEmpty res.FailedCreations "No connection attempt creation should be failed" - - let connAttemptId = Expect.wantSome connAttemptId "An connection attempt id should have been generated" - Expect.sequenceEqual - (res.PlayersConnInfo |> List.ofArray) - [ { PeerId = 1; ConnAttemptId = connAttemptId } ] - "Players connection info should contain the first player connection info and only that" - - // Check connections - let room = - roomStore.Get roomId - |> Flip.Expect.wantSome "Room should still exist" - - let connId1 = conn1.ConnectionId |> ConnId.parse - let connId2 = conn2.ConnectionId |> ConnId.parse - - Expect.containsAll - room.Connections - [ connId2, connId1 ] - "Room should contain the connection between the two players" - } - - testTheoryTask - "Connect to room players with multiple players" - [ 3, 0 - 10, 4 - 100, 99 ] - <| fun (nbOfPeers: int, peerThatConnects: int) -> task { - let! players = - List.init nbOfPeers (fun _ -> testServer |> connectHub) - |> Task.WhenAll - |> Task.map List.ofArray - - // Register handlers - let cts = new CancellationTokenSource() - let connAttemptIdsTask = - players - |> List.indexed - |> List.choose (fun (idx, (conn, hub)) -> - let connAttemptIdTcs = TaskCompletionSource>() - cts.Token.Register(fun _ -> connAttemptIdTcs.TrySetCanceled() |> ignore) |> ignore - - conn.On("ConnectionRequested", fun _ -> - Common.fakeSdpDescription - |> hub.StartConnectionAttempt - |> Task.map (fun res -> - connAttemptIdTcs.SetResult(res) - - match res with - | Ok c -> c - | Error _ -> failwith "Failed to create connection attempt" - ) - ) - |> ignore - - match idx = peerThatConnects with // Don't await the connection attempt creation for the peer that will connect - | true -> None - | false -> Some connAttemptIdTcs.Task - ) - |> List.sequenceTaskResultA - |> Task.map (Flip.Expect.wantOk "Failed to create some connection attempt") - - // Create room - let! roomId = - (players |> List.head |> snd).CreateRoom() - |> Task.map (Flip.Expect.wantOk "Failed to create room") - - // Join room - do! players - |> List.tail - |> List.map (fun (_, hub) -> hub.JoinRoom roomId) - |> List.sequenceTaskResultA - |> Task.map (Flip.Expect.isOk "All players should be able to join the room") - - // Connect players - let hub = players |> List.item peerThatConnects |> snd - let! res = - hub.ConnectToRoomPlayers() - |> Task.map (Flip.Expect.wantOk "Failed to create connection information for players") - - cts.CancelAfter 1000 - let! connAttemptIds = connAttemptIdsTask - - Expect.isEmpty res.FailedCreations "All connection attempt creations should be successful" - Expect.hasLength res.PlayersConnInfo (players.Length - 1) "Their should be one player connection info per player to connect to" - Expect.hasLength connAttemptIds (players.Length - 1) "Their should be one connection attempt id per players to connect to" - Expect.hasLength connAttemptIds res.PlayersConnInfo.Length "Their should be one connection attempt id per player connection info" - - Expect.containsAll - connAttemptIds - (res.PlayersConnInfo |> Array.map _.ConnAttemptId) - "Created connection attempt ids should be the same that the received connection attempt ids" - - // Check connections - let room = - roomStore.Get roomId - |> Flip.Expect.wantSome "Room should still exist" - - let connIdThatConnects = - players - |> List.item peerThatConnects - |> fst - |> _.ConnectionId - |> ConnId.parse - - let expectedConnections = - players - |> List.choose (fun (conn, _) -> - let connId = conn.ConnectionId |> ConnId.parse - - match connId <> connIdThatConnects with - | false -> None // The player that connects should not be connected to himself - | true -> Some (connIdThatConnects, connId) - ) - - Expect.containsAll - room.Connections - expectedConnections - "Room should contain the all the new connections" - } - - testTask "Connect to players without a ConnectionRequested handler" { - let! (_, signalingHub1: ISignalingHub) = testServer |> connectHub - let! (_, signalingHub2: ISignalingHub) = testServer |> connectHub - - let! roomId = - signalingHub1.CreateRoom() - |> Task.map (Flip.Expect.wantOk "Room creation should success") - - do! signalingHub2.JoinRoom roomId - |> Task.map (Flip.Expect.wantOk "Room joining should success") - - let! (res: RoomConnectionInfo) = - signalingHub2.ConnectToRoomPlayers() - |> Task.map (Flip.Expect.wantOk "Connecting to room players should return an \"Ok\" result") - - Expect.sequenceEqual - res.FailedCreations - [ 1 ] // 1 is the peerId of the first player, the one who created the room - "Joining room without add handler for ConnectionRequested should fail" - - Expect.isEmpty res.PlayersConnInfo "No connection info should be returned" - } - - testTask "Connect to players while not in a room" { - let! (_, signalingHub: ISignalingHub) = testServer |> connectHub - - let! (error: Errors.ConnectToRoomPlayersError) = - signalingHub.ConnectToRoomPlayers() - |> Task.map (Flip.Expect.wantError "Connecting to room players while not in a room should return an error") - - Expect.equal - error - Errors.ConnectToRoomPlayersError.NotInARoom - "Connecting to room players while not in a room should fail" - } - ] - - testList "LeaveRoom" [ - testTask "Leave room" { - // Create room - let! (conn1: HubConnection, signalingHub1: ISignalingHub) = testServer |> connectHub - - let! roomId = - signalingHub1.CreateRoom() - |> Task.map (Flip.Expect.wantOk "Room creation should success") - - // Join room - let! (_, signalingHub2: ISignalingHub) = testServer |> connectHub - - do! roomId - |> signalingHub2.JoinRoom - |> Task.map (Flip.Expect.isOk "Room joining should success") - - // Leave room - do! signalingHub2.LeaveRoom() - |> Task.map (Flip.Expect.wantOk "Leaving room should success") - - // Check if the player is not in the room anymore - let room = - roomStore.Get roomId - |> Flip.Expect.wantSome "Room should still exist" - - Expect.sequenceEqual - room.Players - [ 1, conn1.ConnectionId |> ConnId.parse ] - "Room should not contain the second player" - - Expect.isEmpty room.Connections "Room should not contain any connection" - } - - testTask "Leave room where we are connected to players" { - // Create room - let! (conn1: HubConnection, signalingHub1: ISignalingHub) = testServer |> connectHub - - let! roomId = - signalingHub1.CreateRoom() - |> Task.map (Flip.Expect.wantOk "Room creation should success") - - // Join room - let! (_, signalingHub2: ISignalingHub) = testServer |> connectHub - - do! roomId - |> signalingHub2.JoinRoom - |> Task.map (Flip.Expect.isOk "Room joining should success") - - // Connect to room players - conn1.On("ConnectionRequested", fun _playerId -> - Common.fakeSdpDescription - |> signalingHub1.StartConnectionAttempt - |> Task.map (function - | Ok c -> c - | Error e -> failwithf "Failed to create connection attempt: %A" e) - ) - |> ignore - - do! signalingHub2.ConnectToRoomPlayers() - |> Task.map (Flip.Expect.isOk "Connecting to room players should success") - - // Leave room - do! signalingHub2.LeaveRoom() - |> Task.map (Flip.Expect.isOk "Leaving room should success") - - // Check if the player is not in the room anymore - let room = - roomStore.Get roomId - |> Flip.Expect.wantSome "Room should still exist" - - Expect.sequenceEqual - room.Players - [ 1, conn1.ConnectionId |> ConnId.parse ] - "Room should not contain the second player" - - Expect.isEmpty room.Connections "Room should not contain any connection" - } - - testTask "Leave room while not in a room" { - let! (_, signalingHub: ISignalingHub) = testServer |> connectHub - - let! (error: Errors.LeaveRoomError) = - signalingHub.LeaveRoom() - |> Task.map (Flip.Expect.wantError "Leaving room while not in a room should return an error") - - Expect.equal - error - Errors.LeaveRoomError.NotInARoom - "Leaving room while not in a room should fail" - } - - testTask "Leave room while being the last player delete the room" { - let! (_, hub1: ISignalingHub) = testServer |> connectHub - - // Create room - let! roomId = - hub1.CreateRoom() - |> Task.map (Flip.Expect.wantOk "Room creation should success") - - // Leave room - do! hub1.LeaveRoom() - |> Task.map (Flip.Expect.wantOk "Leaving room should success") - - // Check if the room is removed - roomStore.Get roomId - |> Flip.Expect.isNone "Room should be removed" - } - ] - +let tests testServer (connectionAttemptStore: Hubs.Signaling.IConnAttemptStore) = + testList "WebRTC Signaling" [ testList "StartConnectionAttempt" [ testTask "Create connection attempt" { let! (conn: HubConnection, signalingHub: ISignalingHub) = testServer |> connectHub @@ -544,7 +22,7 @@ let signalingTests = // Check if the offer was created let offer = - offerStore.Get offerId + connectionAttemptStore.Get offerId |> Flip.Expect.wantSome "Offer should be created" Expect.equal @@ -572,7 +50,7 @@ let signalingTests = |> Task.map (Flip.Expect.wantOk "Connection attempt creation should success") // Check if the offer has answerer (it should not) - offerStore.Get offerId + connectionAttemptStore.Get offerId |> Flip.Expect.wantSome "Offer should exist" |> _.Answerer |> Flip.Expect.isNone "Offer should be marked as not answered" @@ -592,7 +70,7 @@ let signalingTests = // Check if the offer is marked as answered let offer = - offerStore.Get offerId + connectionAttemptStore.Get offerId |> Flip.Expect.wantSome "Offer should exist" Expect.isSome offer.Answerer "Offer should has an answerer" @@ -691,7 +169,7 @@ let signalingTests = |> Task.map (Flip.Expect.isOk "Connection attempt ending should success") // Check if the offer is removed - offerStore.Get offerId + connectionAttemptStore.Get offerId |> Flip.Expect.isNone "Offer should be removed" } @@ -716,7 +194,7 @@ let signalingTests = |> Task.map (Flip.Expect.isOk "Connection attempt ending should success") // Check if the offer is removed - offerStore.Get offerId + connectionAttemptStore.Get offerId |> Flip.Expect.isNone "Offer should be removed" } diff --git a/src/Behide.OnlineServices.Types/Signaling.fs b/src/Behide.OnlineServices.Types/Signaling.fs index 3f77050..80e22c7 100644 --- a/src/Behide.OnlineServices.Types/Signaling.fs +++ b/src/Behide.OnlineServices.Types/Signaling.fs @@ -1,6 +1,7 @@ namespace Behide.OnlineServices.Signaling open System +open System.Collections.Generic open System.Threading.Tasks type SdpDescription = @@ -71,9 +72,11 @@ type Room = { Id: RoomId Initiator: ConnId /// Contains the initiator - Players: (int * ConnId) list - /// A list of the connections between the peers - Connections: (ConnId * ConnId) list } + Players: Dictionary + /// A list of the connections between the peers (in the tuple, the first is always the lowest peerId) + Connections: HashSet + ConnectionsInProgress: HashSet + Semaphore: System.Threading.SemaphoreSlim } diff --git a/src/Behide.OnlineServices/Hubs/Signaling.fs b/src/Behide.OnlineServices/Hubs/Signaling.fs index a8e1c1c..d8942c7 100644 --- a/src/Behide.OnlineServices/Hubs/Signaling.fs +++ b/src/Behide.OnlineServices/Hubs/Signaling.fs @@ -1,5 +1,7 @@ -namespace Behide.OnlineServices.Hubs.Signaling +module Behide.OnlineServices.Hubs.Signaling +open System +open System.Collections.Generic open System.Threading open System.Threading.Tasks @@ -11,8 +13,8 @@ open Behide.OnlineServices.Signaling open Behide.OnlineServices.Signaling.Errors /// Store of player states in the signaling process -type IPlayerConnStore = Store.IStore -type PlayerConnStore = Store.Store +type IPlayerConnectionStore = Store.IStore +type PlayerConnectionStore = Store.Store /// WebRTC connection attempts store type IConnAttemptStore = Store.IStore @@ -21,8 +23,7 @@ type ConnAttemptStore = Store.Store type IRoomStore = Store.IStore type RoomStore = Store.Store - -type SignalingHub(connAttemptStore: IConnAttemptStore, roomStore: IRoomStore, playerConnStore: IPlayerConnStore) = +type SignalingHub(connAttemptStore: IConnAttemptStore, roomStore: IRoomStore, playerConnectionStore: IPlayerConnectionStore) = inherit Hub() // Should interface ISignalingHub, but it makes the methods not callable from the client @@ -36,7 +37,7 @@ type SignalingHub(connAttemptStore: IConnAttemptStore, roomStore: IRoomStore, pl ConnAttemptIds = [] Room = None } - do! playerConnStore.Add + do! playerConnectionStore.Add playerConnId playerConn |> Result.requireTrue "Failed to add player connection" @@ -51,7 +52,7 @@ type SignalingHub(connAttemptStore: IConnAttemptStore, roomStore: IRoomStore, pl let! playerConn = playerConnId - |> playerConnStore.Get + |> playerConnectionStore.Get |> Result.ofOption "Player connection not found" // Remove player from room @@ -84,7 +85,7 @@ type SignalingHub(connAttemptStore: IConnAttemptStore, roomStore: IRoomStore, pl // Remove player connection let removePlayerConnectionError = - playerConnStore.Remove playerConnId + playerConnectionStore.Remove playerConnId |> Result.requireTrue "Failed to remove player connection" |> function | Ok _ -> None @@ -112,7 +113,7 @@ type SignalingHub(connAttemptStore: IConnAttemptStore, roomStore: IRoomStore, pl let! playerConn = playerConnId - |> playerConnStore.Get + |> playerConnectionStore.Get |> Result.ofOption StartConnectionAttemptError.PlayerConnectionNotFound // Create connection attempt @@ -131,7 +132,7 @@ type SignalingHub(connAttemptStore: IConnAttemptStore, roomStore: IRoomStore, pl let newPlayerConn = { playerConn with ConnAttemptIds = connAttempt.Id :: playerConn.ConnAttemptIds } - do! playerConnStore.Update + do! playerConnectionStore.Update playerConnId playerConn newPlayerConn @@ -147,7 +148,7 @@ type SignalingHub(connAttemptStore: IConnAttemptStore, roomStore: IRoomStore, pl // Check if client has a player connection do! connId - |> playerConnStore.Get + |> playerConnectionStore.Get |> Result.ofOption JoinConnectionAttemptError.PlayerConnectionNotFound |> Result.ignore @@ -181,7 +182,7 @@ type SignalingHub(connAttemptStore: IConnAttemptStore, roomStore: IRoomStore, pl // Check if client has a player connection do! connId - |> playerConnStore.Get + |> playerConnectionStore.Get |> Result.ofOption SendAnswerError.PlayerConnectionNotFound |> Result.ignore @@ -209,7 +210,7 @@ type SignalingHub(connAttemptStore: IConnAttemptStore, roomStore: IRoomStore, pl // Check if client has a player connection do! connId - |> playerConnStore.Get + |> playerConnectionStore.Get |> Result.ofOption SendIceCandidateError.PlayerConnectionNotFound |> Result.ignore @@ -245,7 +246,7 @@ type SignalingHub(connAttemptStore: IConnAttemptStore, roomStore: IRoomStore, pl // Check if client has a player connection do! connId - |> playerConnStore.Get + |> playerConnectionStore.Get |> Result.ofOption EndConnectionAttemptError.PlayerConnectionNotFound |> Result.ignore @@ -274,7 +275,7 @@ type SignalingHub(connAttemptStore: IConnAttemptStore, roomStore: IRoomStore, pl let! playerConn = playerConnId - |> playerConnStore.Get + |> playerConnectionStore.Get |> Result.ofOption CreateRoomError.PlayerConnectionNotFound // Check if player is already in a room @@ -284,8 +285,10 @@ type SignalingHub(connAttemptStore: IConnAttemptStore, roomStore: IRoomStore, pl let room = { Id = RoomId.create () Initiator = playerConnId - Players = [ 1, playerConnId ] // Host peer id should always be 1 - Connections = [] } + Players = [ KeyValuePair(playerConnId, 1) ] |> Dictionary // Host peer id should always be 1 + Connections = HashSet() + ConnectionsInProgress = HashSet() + Semaphore = new SemaphoreSlim(1, 1) } do! roomStore.Add room.Id @@ -295,7 +298,7 @@ type SignalingHub(connAttemptStore: IConnAttemptStore, roomStore: IRoomStore, pl // Update player connection let newPlayerConn = { playerConn with Room = Some room.Id } - do! playerConnStore.Update + do! playerConnectionStore.Update playerConnId playerConn newPlayerConn @@ -310,7 +313,7 @@ type SignalingHub(connAttemptStore: IConnAttemptStore, roomStore: IRoomStore, pl let! playerConn = playerConnId - |> playerConnStore.Get + |> playerConnectionStore.Get |> Result.ofOption JoinRoomError.PlayerConnectionNotFound // Check if player is already in a room @@ -327,17 +330,17 @@ type SignalingHub(connAttemptStore: IConnAttemptStore, roomStore: IRoomStore, pl // Update room let newPeerId = room.Players - |> List.maxBy fst - |> fst + |> Seq.maxBy _.Value // Max by peerId + |> _.Value |> (+) 1 - let newRoom = { room with Players = (newPeerId, playerConnId) :: room.Players } + room.Players.Add(playerConnId, newPeerId) - do! roomStore.Update - roomId - room - newRoom - |> Result.requireTrue JoinRoomError.FailedToUpdateRoom + // do! roomStore.Update + // roomId + // room + // newRoom + // |> Result.requireTrue JoinRoomError.FailedToUpdateRoom // TODO return newPeerId }) @@ -345,7 +348,7 @@ type SignalingHub(connAttemptStore: IConnAttemptStore, roomStore: IRoomStore, pl // Update player connection let newPlayerConn = { playerConn with Room = Some roomId } - do! playerConnStore.Update + do! playerConnectionStore.Update playerConnId playerConn newPlayerConn @@ -356,142 +359,153 @@ type SignalingHub(connAttemptStore: IConnAttemptStore, roomStore: IRoomStore, pl member hub.ConnectToRoomPlayers() = taskResult { - let playerConnId = hub.Context.ConnectionId |> ConnId.parse + let playerConnectionId = hub.Context.ConnectionId |> ConnId.parse let! playerConn = - playerConnId - |> playerConnStore.Get + playerConnectionId + |> playerConnectionStore.Get |> Result.ofOption ConnectToRoomPlayersError.PlayerConnectionNotFound - return! lock roomStore (fun _ -> taskResult { - let! room = - playerConn.Room - |> Option.bind roomStore.Get - |> Result.ofOption ConnectToRoomPlayersError.NotInARoom + // Find players to connect to + let! room = + playerConn.Room + |> Option.bind roomStore.Get + |> Result.ofOption ConnectToRoomPlayersError.NotInARoom - let! requestingPlayerId = - room.Players - |> List.tryFind (snd >> (=) playerConn.ConnectionId) - |> Result.ofOption ConnectToRoomPlayersError.PlayerNotInRoomPlayers - |> Result.map fst - - - /// Create connection attempt on the requested client - let createConnAttemptForPlayer (targetPeerId, targetConnId) = - taskResult { - let! r = - targetConnId - |> ConnId.raw - |> hub.Clients.Client - |> _.ConnectionRequested(requestingPlayerId) - |> Task.catch - |> Task.map (function // Handle the case where the client didn't register a handler - | Choice1Of2 r -> Ok r - | Choice2Of2 _ -> Error targetPeerId - ) - - match r with - | null -> return! Error targetPeerId - | connAttemptId -> - let connInfo = { PeerId = targetPeerId; ConnAttemptId = connAttemptId } - let connection = playerConnId, targetConnId - - return connInfo, connection - } - - let alreadyConnectedPlayers = - room.Connections - |> List.choose (fun (p1, p2) -> - match playerConnId with - | Equals p1 -> Some p2 - | Equals p2 -> Some p1 - | _ -> None - ) - - let! playersConnectionInfoResults = - room.Players - |> List.choose (fun (connInfo, connId) -> - let isRequestingPlayer = connId <> playerConnId - let alreadyConnected = alreadyConnectedPlayers |> List.contains connId |> not - - // Don't connect the player to himself and don't reconnect player - match isRequestingPlayer && alreadyConnected with - | false -> None - | true -> createConnAttemptForPlayer(connInfo, connId) |> Some - ) - |> Task.WhenAll - - let mutable playersConnInfo = [] - let mutable failed = [] - let mutable newConnections = [] - - playersConnectionInfoResults |> Array.iter ( - function - | Ok (connInfo, connection) -> - playersConnInfo <- connInfo :: playersConnInfo - newConnections <- connection :: newConnections - | Error peerId -> - failed <- peerId :: failed + do! room.Semaphore.WaitAsync() // Lock room + + let! requestingPeerId = + match room.Players.TryGetValue playerConnectionId with + | false, _ -> Error ConnectToRoomPlayersError.PlayerNotInRoomPlayers + | true, peerId -> Ok peerId + + let playersToConnectTo = + room.Players + |> Seq.filter (fun kv -> + let playerConnectionId' = kv.Key + + let connectionToCheck = min playerConnectionId playerConnectionId', max playerConnectionId playerConnectionId' + let alreadyConnected = + room.Connections.Contains(connectionToCheck) + || room.ConnectionsInProgress.Contains(connectionToCheck) + + playerConnectionId' <> playerConnectionId && not alreadyConnected + ) + |> Seq.toArray + + // Indicates connections are in progress + let attemptingConnections = + playersToConnectTo |> Seq.map (fun kv -> + let playerConnectionId' = kv.Key + min playerConnectionId playerConnectionId', + max playerConnectionId playerConnectionId' ) + attemptingConnections |> Seq.iter (room.ConnectionsInProgress.Add >> ignore) + + room.Semaphore.Release() |> ignore // Unlock room + + // Create connection attempts + let createConnectionAttemptForPlayer (targetPeerId, targetConnId) = + taskResult { + let! r = + targetConnId + |> ConnId.raw + |> hub.Clients.Client + |> _.ConnectionRequested(requestingPeerId) + |> _.WaitAsync(TimeSpan.FromSeconds 10.) + |> Task.catch + |> Task.map (function // Handle the case where the client didn't register a handler + | Choice1Of2 r -> Ok r + | Choice2Of2 _ -> Error targetPeerId + ) - // Update room - do! roomStore.Update - room.Id - room - { room with Connections = newConnections @ room.Connections } - |> Result.requireTrue ConnectToRoomPlayersError.FailedToUpdateRoom - - return { PlayersConnInfo = playersConnInfo |> List.toArray - FailedCreations = failed |> List.toArray } - }) + match r with + | null -> return! Error targetPeerId + | connAttemptId -> + let connInfo = { PeerId = targetPeerId; ConnAttemptId = connAttemptId } + let connection = + min playerConnectionId targetConnId, + max playerConnectionId targetConnId + + return connInfo, connection + } + + let! playersConnectionInfoResults = + playersToConnectTo + |> Seq.map (fun kv -> createConnectionAttemptForPlayer (kv.Value, kv.Key)) + |> Task.WhenAll + + let mutable playersConnInfo = [] + let mutable failed = [] + let mutable newEstablishedConnections = [] + + playersConnectionInfoResults |> Array.iter ( + function + | Ok (connInfo, connection) -> + playersConnInfo <- connInfo :: playersConnInfo + newEstablishedConnections <- connection :: newEstablishedConnections + | Error peerId -> + failed <- peerId :: failed + ) + + // Update room + do! room.Semaphore.WaitAsync() + + newEstablishedConnections |> Seq.iter (fun connection -> + room.ConnectionsInProgress.Remove connection |> ignore + room.Connections.Add connection |> ignore + ) + + attemptingConnections |> Seq.iter (fun connection -> + room.ConnectionsInProgress.Remove connection |> ignore + ) + + // ConnectToRoomPlayersError.FailedToUpdateRoom // TODO: ? + room.Semaphore.Release() |> ignore + + return { PlayersConnInfo = playersConnInfo |> List.toArray + FailedCreations = failed |> List.toArray } } member hub.LeaveRoom() = taskResult { - let playerConnId = hub.Context.ConnectionId |> ConnId.parse + let playerConnectionId = hub.Context.ConnectionId |> ConnId.parse // Check if a player connection exists - let! playerConn = - playerConnId - |> playerConnStore.Get + let! playerConnection = + playerConnectionId + |> playerConnectionStore.Get |> Result.ofOption LeaveRoomError.PlayerConnectionNotFound do! lock roomStore (fun _ -> taskResult { // Get player's room let! room = - playerConn.Room + playerConnection.Room |> Option.bind roomStore.Get |> Result.ofOption LeaveRoomError.NotInARoom - match room.Players |> List.length with + match room.Players |> Seq.length with | 1 -> // If the player is the last one in the room, remove the room do! roomStore.Remove room.Id |> Result.requireTrue LeaveRoomError.FailedToRemoveRoom | _ -> // Remove player connections and player from room - let newConnections = - room.Connections - |> List.filter (fun (p1, p2) -> - match playerConnId with - | Equals p1 -> false - | Equals p2 -> false - | _ -> true - ) - - let newRoom = - { room with - Players = room.Players |> List.filter (snd >> (<>) playerConnId) - Connections = newConnections} - - do! roomStore.Update room.Id room newRoom + room.Connections.RemoveWhere(fun (p1, p2) -> + match playerConnectionId with + | Equals p1 + | Equals p2 -> true + | _ -> false + ) |> ignore + + do! room.Players.Remove(playerConnectionId) |> Result.requireTrue LeaveRoomError.FailedToUpdateRoom }) // Update player connection - do! playerConnStore.Update - playerConnId - playerConn - { playerConn with Room = None } + do! playerConnectionStore.Update + playerConnectionId + playerConnection + { playerConnection with Room = None } |> Result.requireTrue LeaveRoomError.FailedToUpdatePlayerConnection } diff --git a/src/Behide.OnlineServices/Program.fs b/src/Behide.OnlineServices/Program.fs index f288341..91f179c 100644 --- a/src/Behide.OnlineServices/Program.fs +++ b/src/Behide.OnlineServices/Program.fs @@ -20,7 +20,7 @@ let configureServices (services: IServiceCollection) = |> ignore services.AddSingleton() |> ignore services.AddSingleton() |> ignore - services.AddSingleton() |> ignore + services.AddSingleton() |> ignore services let appEndpoints = diff --git a/src/Behide.OnlineServices/Stores.fs b/src/Behide.OnlineServices/Stores.fs index c10aaff..c0c40ce 100644 --- a/src/Behide.OnlineServices/Stores.fs +++ b/src/Behide.OnlineServices/Stores.fs @@ -23,6 +23,5 @@ type Store<'Id, 'Item when 'Id: not null>() = |> Seq.tryFind (_.Value >> predicate) |> Option.map _.Deconstruct() - /// Don't forget to use "lock" member _.Update id oldValue newValue = dict.TryUpdate(id, newValue, oldValue) From 751bf9b06d627dd5b864b73b2b9ddd59758df1b5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ga=C3=ABl=20Bouquain?= Date: Tue, 8 Jul 2025 11:34:58 +0200 Subject: [PATCH 02/10] Split signaling hub in files --- src/Behide.OnlineServices.Types/Signaling.fs | 1 + .../Behide.OnlineServices.fsproj | 5 +- src/Behide.OnlineServices/Hubs/Signaling.fs | 511 ------------------ .../Hubs/Signaling/Common.fs | 15 + .../Hubs/Signaling/RoomManagement.fs | 254 +++++++++ .../Hubs/Signaling/Signaling.fs | 103 ++++ .../Hubs/Signaling/WebRTCSignaling.fs | 169 ++++++ 7 files changed, 546 insertions(+), 512 deletions(-) delete mode 100644 src/Behide.OnlineServices/Hubs/Signaling.fs create mode 100644 src/Behide.OnlineServices/Hubs/Signaling/Common.fs create mode 100644 src/Behide.OnlineServices/Hubs/Signaling/RoomManagement.fs create mode 100644 src/Behide.OnlineServices/Hubs/Signaling/Signaling.fs create mode 100644 src/Behide.OnlineServices/Hubs/Signaling/WebRTCSignaling.fs diff --git a/src/Behide.OnlineServices.Types/Signaling.fs b/src/Behide.OnlineServices.Types/Signaling.fs index 80e22c7..7007621 100644 --- a/src/Behide.OnlineServices.Types/Signaling.fs +++ b/src/Behide.OnlineServices.Types/Signaling.fs @@ -162,6 +162,7 @@ open Errors // Otherwise, the library TypedSignalR.Client generate invalid C# code type ISignalingHub = abstract member StartConnectionAttempt : SdpDescription -> Task> + /// Returns the offer sdp desc and allow to send the answer abstract member JoinConnectionAttempt : ConnAttemptId -> Task> abstract member SendAnswer : ConnAttemptId -> answer: SdpDescription -> Task> abstract member SendIceCandidate : ConnAttemptId -> iceCandidate: IceCandidate -> Task> diff --git a/src/Behide.OnlineServices/Behide.OnlineServices.fsproj b/src/Behide.OnlineServices/Behide.OnlineServices.fsproj index e0208f9..9c42625 100644 --- a/src/Behide.OnlineServices/Behide.OnlineServices.fsproj +++ b/src/Behide.OnlineServices/Behide.OnlineServices.fsproj @@ -7,7 +7,10 @@ - + + + + diff --git a/src/Behide.OnlineServices/Hubs/Signaling.fs b/src/Behide.OnlineServices/Hubs/Signaling.fs deleted file mode 100644 index d8942c7..0000000 --- a/src/Behide.OnlineServices/Hubs/Signaling.fs +++ /dev/null @@ -1,511 +0,0 @@ -module Behide.OnlineServices.Hubs.Signaling - -open System -open System.Collections.Generic -open System.Threading -open System.Threading.Tasks - -open Microsoft.AspNetCore.SignalR -open FsToolkit.ErrorHandling - -open Behide.OnlineServices -open Behide.OnlineServices.Signaling -open Behide.OnlineServices.Signaling.Errors - -/// Store of player states in the signaling process -type IPlayerConnectionStore = Store.IStore -type PlayerConnectionStore = Store.Store - -/// WebRTC connection attempts store -type IConnAttemptStore = Store.IStore -type ConnAttemptStore = Store.Store - -type IRoomStore = Store.IStore -type RoomStore = Store.Store - -type SignalingHub(connAttemptStore: IConnAttemptStore, roomStore: IRoomStore, playerConnectionStore: IPlayerConnectionStore) = - inherit Hub() - // Should interface ISignalingHub, but it makes the methods not callable from the client - - // --- Player Connection Management --- - override hub.OnConnectedAsync() = - taskResult { - let playerConnId = hub.Context.ConnectionId |> ConnId.parse - - let playerConn = - { ConnectionId = playerConnId - ConnAttemptIds = [] - Room = None } - - do! playerConnectionStore.Add - playerConnId - playerConn - |> Result.requireTrue "Failed to add player connection" - } - |> TaskResult.mapError (printfn "Error occurred while registering player: %s") - |> Task.map ignore - :> Task - - override hub.OnDisconnectedAsync _exn = - taskResult { - let playerConnId = hub.Context.ConnectionId |> ConnId.parse - - let! playerConn = - playerConnId - |> playerConnectionStore.Get - |> Result.ofOption "Player connection not found" - - // Remove player from room - let! leaveRoomError = - match playerConn.Room with - | None -> None |> Task.singleton - | Some _ -> - hub.LeaveRoom() - |> Task.map (function - | Ok _ -> None - | Error _ -> Some "Failed to remove player from it's room") - - // Remove connection attempts - let removeConnAttemptsError = - playerConn.ConnAttemptIds - |> List.choose (fun connAttemptId -> - match connAttemptId |> connAttemptStore.Get with - | None -> None - | Some _ -> - match connAttemptId |> connAttemptStore.Remove with - | true -> None - | false -> Some connAttemptId - ) - |> function - | [] -> None - | failedConnAttempts -> - failedConnAttempts - |> sprintf "Failed to remove connection attempts: %A" - |> Some - - // Remove player connection - let removePlayerConnectionError = - playerConnectionStore.Remove playerConnId - |> Result.requireTrue "Failed to remove player connection" - |> function - | Ok _ -> None - | Error error -> Some error - - return! - match leaveRoomError, removeConnAttemptsError, removePlayerConnectionError with - | None, None, None -> Ok () - | _ -> - sprintf - "\nLeave room error: %s\nRemove connection attempt error: %s\nRemove player connection error: %s" - (leaveRoomError |> Option.defaultValue "None") - (removeConnAttemptsError |> Option.defaultValue "None") - (removePlayerConnectionError |> Option.defaultValue "None") - |> Error - } - |> TaskResult.mapError (printfn "Error occurred while deregistering player: %s") - |> Task.map ignore - :> Task - - // --- WebRTC Signaling --- - member hub.StartConnectionAttempt (sdpDescription: SdpDescription) = - taskResult { - let playerConnId = hub.Context.ConnectionId |> ConnId.parse - - let! playerConn = - playerConnId - |> playerConnectionStore.Get - |> Result.ofOption StartConnectionAttemptError.PlayerConnectionNotFound - - // Create connection attempt - let connAttempt = - { Id = ConnAttemptId.create () - InitiatorConnectionId = playerConnId - SdpDescription = sdpDescription - Answerer = None } - - do! connAttemptStore.Add - connAttempt.Id - connAttempt - |> Result.requireTrue StartConnectionAttemptError.FailedToCreateConnAttempt - - // Update player connection - let newPlayerConn = - { playerConn with ConnAttemptIds = connAttempt.Id :: playerConn.ConnAttemptIds } - - do! playerConnectionStore.Update - playerConnId - playerConn - newPlayerConn - |> Result.requireTrue StartConnectionAttemptError.FailedToUpdatePlayerConnection - - return connAttempt.Id - } - - /// Returns the offer sdp desc and allow to send the answer - member hub.JoinConnectionAttempt (connAttemptId: ConnAttemptId) = - taskResult { - let connId = hub.Context.ConnectionId |> ConnId.parse - - // Check if client has a player connection - do! connId - |> playerConnectionStore.Get - |> Result.ofOption JoinConnectionAttemptError.PlayerConnectionNotFound - |> Result.ignore - - // Retrieve connection attempt - let! connAttempt = - connAttemptId - |> connAttemptStore.Get - |> Result.ofOption JoinConnectionAttemptError.OfferNotFound - - // Check if connection attempt has not been answered - do! connAttempt.Answerer - |> Result.requireNone JoinConnectionAttemptError.OfferAlreadyAnswered - - // Check if the answerer is not the initiator - do! connAttempt.InitiatorConnectionId <> connId - |> Result.requireTrue JoinConnectionAttemptError.InitiatorCannotJoin - - // Update connection attempt - do! connAttemptStore.Update - connAttemptId - connAttempt - { connAttempt with Answerer = Some connId } - |> Result.requireTrue JoinConnectionAttemptError.FailedToUpdateOffer - - return connAttempt.SdpDescription - } - - member hub.SendAnswer (connAttemptId: ConnAttemptId) (sdpDescription: SdpDescription) = - taskResult { - let connId = hub.Context.ConnectionId |> ConnId.parse - - // Check if client has a player connection - do! connId - |> playerConnectionStore.Get - |> Result.ofOption SendAnswerError.PlayerConnectionNotFound - |> Result.ignore - - // Retrieve connection attempt - let! connAttempt = - connAttemptId - |> connAttemptStore.Get - |> Result.ofOption SendAnswerError.OfferNotFound - - // Get answerer - let! answerer = connAttempt.Answerer |> Result.ofOption SendAnswerError.NotAnswerer - // Check if the client is the answerer - do! connId = answerer |> Result.requireTrue SendAnswerError.NotAnswerer - - // Send answer to initiator - try - do! hub.Clients.Client(connAttempt.InitiatorConnectionId |> ConnId.raw).SdpAnswerReceived connAttemptId sdpDescription - with _ -> - return! Error SendAnswerError.FailedToTransmitAnswer - } - - member hub.SendIceCandidate (connAttemptId: ConnAttemptId) (iceCandidate: IceCandidate) = - taskResult { - let connId = hub.Context.ConnectionId |> ConnId.parse - - // Check if client has a player connection - do! connId - |> playerConnectionStore.Get - |> Result.ofOption SendIceCandidateError.PlayerConnectionNotFound - |> Result.ignore - - let! connAttempt = - connAttemptId - |> connAttemptStore.Get - |> Result.ofOption SendIceCandidateError.OfferNotFound - - let! answerer = - connAttempt.Answerer - |> Result.ofOption SendIceCandidateError.NotAnswerer - - // Check if the client is in the connection attempt - do! (connId = connAttempt.InitiatorConnectionId || connId = answerer) - |> Result.requireTrue SendIceCandidateError.NotParticipant - - // Determine the target connection id - let targetConnId = - match connId = answerer with - | true -> connAttempt.InitiatorConnectionId - | false -> answerer - - // Send ice candidate to other peer - try - do! hub.Clients.Client(targetConnId |> ConnId.raw).IceCandidateReceived connAttemptId iceCandidate - with _ -> - return! Error SendIceCandidateError.FailedToTransmitCandidate - } - - member hub.EndConnectionAttempt (connAttemptId: ConnAttemptId) = - taskResult { - let connId = hub.Context.ConnectionId |> ConnId.parse - - // Check if client has a player connection - do! connId - |> playerConnectionStore.Get - |> Result.ofOption EndConnectionAttemptError.PlayerConnectionNotFound - |> Result.ignore - - let! connAttempt = - connAttemptId - |> connAttemptStore.Get - |> Result.ofOption EndConnectionAttemptError.OfferNotFound - - // Check if the client is in the connection attempt - match connId = connAttempt.InitiatorConnectionId with - | true -> () - | false -> - do! connAttempt.Answerer - |> Result.ofOption EndConnectionAttemptError.NotParticipant - |> Result.bind ((=) connId >> Result.requireTrue EndConnectionAttemptError.NotParticipant) - - // Remove connection attempt - do! connAttemptStore.Remove connAttemptId - |> Result.requireTrue EndConnectionAttemptError.FailedToRemoveOffer - } - - // --- Rooms --- - member hub.CreateRoom() = - taskResult { - let playerConnId = hub.Context.ConnectionId |> ConnId.parse - - let! playerConn = - playerConnId - |> playerConnectionStore.Get - |> Result.ofOption CreateRoomError.PlayerConnectionNotFound - - // Check if player is already in a room - do! playerConn.Room |> Result.requireNone CreateRoomError.PlayerAlreadyInARoom - - // Create room - let room = - { Id = RoomId.create () - Initiator = playerConnId - Players = [ KeyValuePair(playerConnId, 1) ] |> Dictionary // Host peer id should always be 1 - Connections = HashSet() - ConnectionsInProgress = HashSet() - Semaphore = new SemaphoreSlim(1, 1) } - - do! roomStore.Add - room.Id - room - |> Result.requireTrue CreateRoomError.FailedToRegisterRoom - - // Update player connection - let newPlayerConn = { playerConn with Room = Some room.Id } - - do! playerConnectionStore.Update - playerConnId - playerConn - newPlayerConn - |> Result.requireTrue CreateRoomError.FailedToUpdatePlayerConnection - - return room.Id - } - - member hub.JoinRoom (roomId: RoomId) = - taskResult { - let playerConnId = hub.Context.ConnectionId |> ConnId.parse - - let! playerConn = - playerConnId - |> playerConnectionStore.Get - |> Result.ofOption JoinRoomError.PlayerConnectionNotFound - - // Check if player is already in a room - do! playerConn.Room - |> Result.requireNone JoinRoomError.PlayerAlreadyInARoom - - // Update room - let! newPeerId = lock roomStore (fun () -> taskResult { - let! room = - roomId - |> roomStore.Get - |> Result.ofOption JoinRoomError.RoomNotFound - - // Update room - let newPeerId = - room.Players - |> Seq.maxBy _.Value // Max by peerId - |> _.Value - |> (+) 1 - - room.Players.Add(playerConnId, newPeerId) - - // do! roomStore.Update - // roomId - // room - // newRoom - // |> Result.requireTrue JoinRoomError.FailedToUpdateRoom // TODO - - return newPeerId - }) - - // Update player connection - let newPlayerConn = { playerConn with Room = Some roomId } - - do! playerConnectionStore.Update - playerConnId - playerConn - newPlayerConn - |> Result.requireTrue JoinRoomError.FailedToUpdatePlayerConnection - - return newPeerId - } - - member hub.ConnectToRoomPlayers() = - taskResult { - let playerConnectionId = hub.Context.ConnectionId |> ConnId.parse - - let! playerConn = - playerConnectionId - |> playerConnectionStore.Get - |> Result.ofOption ConnectToRoomPlayersError.PlayerConnectionNotFound - - // Find players to connect to - let! room = - playerConn.Room - |> Option.bind roomStore.Get - |> Result.ofOption ConnectToRoomPlayersError.NotInARoom - - do! room.Semaphore.WaitAsync() // Lock room - - let! requestingPeerId = - match room.Players.TryGetValue playerConnectionId with - | false, _ -> Error ConnectToRoomPlayersError.PlayerNotInRoomPlayers - | true, peerId -> Ok peerId - - let playersToConnectTo = - room.Players - |> Seq.filter (fun kv -> - let playerConnectionId' = kv.Key - - let connectionToCheck = min playerConnectionId playerConnectionId', max playerConnectionId playerConnectionId' - let alreadyConnected = - room.Connections.Contains(connectionToCheck) - || room.ConnectionsInProgress.Contains(connectionToCheck) - - playerConnectionId' <> playerConnectionId && not alreadyConnected - ) - |> Seq.toArray - - // Indicates connections are in progress - let attemptingConnections = - playersToConnectTo |> Seq.map (fun kv -> - let playerConnectionId' = kv.Key - min playerConnectionId playerConnectionId', - max playerConnectionId playerConnectionId' - ) - attemptingConnections |> Seq.iter (room.ConnectionsInProgress.Add >> ignore) - - room.Semaphore.Release() |> ignore // Unlock room - - // Create connection attempts - let createConnectionAttemptForPlayer (targetPeerId, targetConnId) = - taskResult { - let! r = - targetConnId - |> ConnId.raw - |> hub.Clients.Client - |> _.ConnectionRequested(requestingPeerId) - |> _.WaitAsync(TimeSpan.FromSeconds 10.) - |> Task.catch - |> Task.map (function // Handle the case where the client didn't register a handler - | Choice1Of2 r -> Ok r - | Choice2Of2 _ -> Error targetPeerId - ) - - match r with - | null -> return! Error targetPeerId - | connAttemptId -> - let connInfo = { PeerId = targetPeerId; ConnAttemptId = connAttemptId } - let connection = - min playerConnectionId targetConnId, - max playerConnectionId targetConnId - - return connInfo, connection - } - - let! playersConnectionInfoResults = - playersToConnectTo - |> Seq.map (fun kv -> createConnectionAttemptForPlayer (kv.Value, kv.Key)) - |> Task.WhenAll - - let mutable playersConnInfo = [] - let mutable failed = [] - let mutable newEstablishedConnections = [] - - playersConnectionInfoResults |> Array.iter ( - function - | Ok (connInfo, connection) -> - playersConnInfo <- connInfo :: playersConnInfo - newEstablishedConnections <- connection :: newEstablishedConnections - | Error peerId -> - failed <- peerId :: failed - ) - - // Update room - do! room.Semaphore.WaitAsync() - - newEstablishedConnections |> Seq.iter (fun connection -> - room.ConnectionsInProgress.Remove connection |> ignore - room.Connections.Add connection |> ignore - ) - - attemptingConnections |> Seq.iter (fun connection -> - room.ConnectionsInProgress.Remove connection |> ignore - ) - - // ConnectToRoomPlayersError.FailedToUpdateRoom // TODO: ? - room.Semaphore.Release() |> ignore - - return { PlayersConnInfo = playersConnInfo |> List.toArray - FailedCreations = failed |> List.toArray } - } - - member hub.LeaveRoom() = - taskResult { - let playerConnectionId = hub.Context.ConnectionId |> ConnId.parse - - // Check if a player connection exists - let! playerConnection = - playerConnectionId - |> playerConnectionStore.Get - |> Result.ofOption LeaveRoomError.PlayerConnectionNotFound - - do! lock roomStore (fun _ -> taskResult { - // Get player's room - let! room = - playerConnection.Room - |> Option.bind roomStore.Get - |> Result.ofOption LeaveRoomError.NotInARoom - - match room.Players |> Seq.length with - | 1 -> // If the player is the last one in the room, remove the room - do! roomStore.Remove room.Id - |> Result.requireTrue LeaveRoomError.FailedToRemoveRoom - - | _ -> - // Remove player connections and player from room - room.Connections.RemoveWhere(fun (p1, p2) -> - match playerConnectionId with - | Equals p1 - | Equals p2 -> true - | _ -> false - ) |> ignore - - do! room.Players.Remove(playerConnectionId) - |> Result.requireTrue LeaveRoomError.FailedToUpdateRoom - }) - - // Update player connection - do! playerConnectionStore.Update - playerConnectionId - playerConnection - { playerConnection with Room = None } - |> Result.requireTrue LeaveRoomError.FailedToUpdatePlayerConnection - } diff --git a/src/Behide.OnlineServices/Hubs/Signaling/Common.fs b/src/Behide.OnlineServices/Hubs/Signaling/Common.fs new file mode 100644 index 0000000..53f566b --- /dev/null +++ b/src/Behide.OnlineServices/Hubs/Signaling/Common.fs @@ -0,0 +1,15 @@ +namespace Behide.OnlineServices.Hubs.Signaling + +open Behide.OnlineServices +open Behide.OnlineServices.Signaling + +/// Store of player states in the signaling process +type IPlayerConnectionStore = Store.IStore +type PlayerConnectionStore = Store.Store + +/// WebRTC connection attempts store +type IConnAttemptStore = Store.IStore +type ConnAttemptStore = Store.Store + +type IRoomStore = Store.IStore +type RoomStore = Store.Store diff --git a/src/Behide.OnlineServices/Hubs/Signaling/RoomManagement.fs b/src/Behide.OnlineServices/Hubs/Signaling/RoomManagement.fs new file mode 100644 index 0000000..fbb6ae3 --- /dev/null +++ b/src/Behide.OnlineServices/Hubs/Signaling/RoomManagement.fs @@ -0,0 +1,254 @@ +module Behide.OnlineServices.Hubs.Signaling.RoomManagement + +open Behide.OnlineServices +open Behide.OnlineServices.Signaling +open Behide.OnlineServices.Signaling.Errors +type Hub = Microsoft.AspNetCore.SignalR.Hub + +open System +open System.Collections.Generic +open System.Threading +open System.Threading.Tasks + +open FsToolkit.ErrorHandling + +let createRoom (hub: Hub) (playerConnectionStore: IPlayerConnectionStore) (connAttemptStore: IConnAttemptStore) (roomStore: IRoomStore) = + taskResult { + let playerConnId = hub.Context.ConnectionId |> ConnId.parse + + let! playerConn = + playerConnId + |> playerConnectionStore.Get + |> Result.ofOption CreateRoomError.PlayerConnectionNotFound + + // Check if player is already in a room + do! playerConn.Room |> Result.requireNone CreateRoomError.PlayerAlreadyInARoom + + // Create room + let room = + { Id = RoomId.create () + Initiator = playerConnId + Players = [ KeyValuePair(playerConnId, 1) ] |> Dictionary // Host peer id should always be 1 + Connections = HashSet() + ConnectionsInProgress = HashSet() + Semaphore = new SemaphoreSlim(1, 1) } + + do! roomStore.Add + room.Id + room + |> Result.requireTrue CreateRoomError.FailedToRegisterRoom + + // Update player connection + let newPlayerConn = { playerConn with Room = Some room.Id } + + do! playerConnectionStore.Update + playerConnId + playerConn + newPlayerConn + |> Result.requireTrue CreateRoomError.FailedToUpdatePlayerConnection + + return room.Id + } + +let joinRoom (hub: Hub) (playerConnectionStore: IPlayerConnectionStore) (connAttemptStore: IConnAttemptStore) (roomStore: IRoomStore) (roomId: RoomId) = + taskResult { + let playerConnId = hub.Context.ConnectionId |> ConnId.parse + + let! playerConn = + playerConnId + |> playerConnectionStore.Get + |> Result.ofOption JoinRoomError.PlayerConnectionNotFound + + // Check if player is already in a room + do! playerConn.Room + |> Result.requireNone JoinRoomError.PlayerAlreadyInARoom + + // Update room + let! newPeerId = lock roomStore (fun () -> taskResult { + let! room = + roomId + |> roomStore.Get + |> Result.ofOption JoinRoomError.RoomNotFound + + // Update room + let newPeerId = + room.Players + |> Seq.maxBy _.Value // Max by peerId + |> _.Value + |> (+) 1 + + room.Players.Add(playerConnId, newPeerId) + + // do! roomStore.Update + // roomId + // room + // newRoom + // |> Result.requireTrue JoinRoomError.FailedToUpdateRoom // TODO + + return newPeerId + }) + + // Update player connection + let newPlayerConn = { playerConn with Room = Some roomId } + + do! playerConnectionStore.Update + playerConnId + playerConn + newPlayerConn + |> Result.requireTrue JoinRoomError.FailedToUpdatePlayerConnection + + return newPeerId + } + +let connectToRoomPlayers (hub: Hub) (playerConnectionStore: IPlayerConnectionStore) (connAttemptStore: IConnAttemptStore) (roomStore: IRoomStore) = + taskResult { + let playerConnectionId = hub.Context.ConnectionId |> ConnId.parse + + let! playerConn = + playerConnectionId + |> playerConnectionStore.Get + |> Result.ofOption ConnectToRoomPlayersError.PlayerConnectionNotFound + + // Find players to connect to + let! room = + playerConn.Room + |> Option.bind roomStore.Get + |> Result.ofOption ConnectToRoomPlayersError.NotInARoom + + do! room.Semaphore.WaitAsync() // Lock room + + let! requestingPeerId = + match room.Players.TryGetValue playerConnectionId with + | false, _ -> Error ConnectToRoomPlayersError.PlayerNotInRoomPlayers + | true, peerId -> Ok peerId + + let playersToConnectTo = + room.Players + |> Seq.filter (fun kv -> + let playerConnectionId' = kv.Key + + let connectionToCheck = min playerConnectionId playerConnectionId', max playerConnectionId playerConnectionId' + let alreadyConnected = + room.Connections.Contains(connectionToCheck) + || room.ConnectionsInProgress.Contains(connectionToCheck) + + playerConnectionId' <> playerConnectionId && not alreadyConnected + ) + |> Seq.toArray + + // Indicates connections are in progress + let attemptingConnections = + playersToConnectTo |> Seq.map (fun kv -> + let playerConnectionId' = kv.Key + min playerConnectionId playerConnectionId', + max playerConnectionId playerConnectionId' + ) + attemptingConnections |> Seq.iter (room.ConnectionsInProgress.Add >> ignore) + + room.Semaphore.Release() |> ignore // Unlock room + + // Create connection attempts + let createConnectionAttemptForPlayer (targetPeerId, targetConnId) = + taskResult { + let! r = + targetConnId + |> ConnId.raw + |> hub.Clients.Client + |> _.ConnectionRequested(requestingPeerId) + |> _.WaitAsync(TimeSpan.FromSeconds 10.) + |> Task.catch + |> Task.map (function // Handle the case where the client didn't register a handler + | Choice1Of2 r -> Ok r + | Choice2Of2 _ -> Error targetPeerId + ) + + match r with + | null -> return! Error targetPeerId + | connAttemptId -> + let connInfo = { PeerId = targetPeerId; ConnAttemptId = connAttemptId } + let connection = + min playerConnectionId targetConnId, + max playerConnectionId targetConnId + + return connInfo, connection + } + + let! playersConnectionInfoResults = + playersToConnectTo + |> Seq.map (fun kv -> createConnectionAttemptForPlayer (kv.Value, kv.Key)) + |> Task.WhenAll + + let mutable playersConnInfo = [] + let mutable failed = [] + let mutable newEstablishedConnections = [] + + playersConnectionInfoResults |> Array.iter ( + function + | Ok (connInfo, connection) -> + playersConnInfo <- connInfo :: playersConnInfo + newEstablishedConnections <- connection :: newEstablishedConnections + | Error peerId -> + failed <- peerId :: failed + ) + + // Update room + do! room.Semaphore.WaitAsync() + + newEstablishedConnections |> Seq.iter (fun connection -> + room.ConnectionsInProgress.Remove connection |> ignore + room.Connections.Add connection |> ignore + ) + + attemptingConnections |> Seq.iter (fun connection -> + room.ConnectionsInProgress.Remove connection |> ignore + ) + + // ConnectToRoomPlayersError.FailedToUpdateRoom // TODO: ? + room.Semaphore.Release() |> ignore + + return { PlayersConnInfo = playersConnInfo |> List.toArray + FailedCreations = failed |> List.toArray } + } + +let leaveRoom (hub: Hub) (playerConnectionStore: IPlayerConnectionStore) (connAttemptStore: IConnAttemptStore) (roomStore: IRoomStore) = + taskResult { + let playerConnectionId = hub.Context.ConnectionId |> ConnId.parse + + // Check if a player connection exists + let! playerConnection = + playerConnectionId + |> playerConnectionStore.Get + |> Result.ofOption LeaveRoomError.PlayerConnectionNotFound + + do! lock roomStore (fun _ -> taskResult { + // Get player's room + let! room = + playerConnection.Room + |> Option.bind roomStore.Get + |> Result.ofOption LeaveRoomError.NotInARoom + + match room.Players |> Seq.length with + | 1 -> // If the player is the last one in the room, remove the room + do! roomStore.Remove room.Id + |> Result.requireTrue LeaveRoomError.FailedToRemoveRoom + + | _ -> + // Remove player connections and player from room + room.Connections.RemoveWhere(fun (p1, p2) -> + match playerConnectionId with + | Equals p1 + | Equals p2 -> true + | _ -> false + ) |> ignore + + do! room.Players.Remove(playerConnectionId) + |> Result.requireTrue LeaveRoomError.FailedToUpdateRoom + }) + + // Update player connection + do! playerConnectionStore.Update + playerConnectionId + playerConnection + { playerConnection with Room = None } + |> Result.requireTrue LeaveRoomError.FailedToUpdatePlayerConnection + } diff --git a/src/Behide.OnlineServices/Hubs/Signaling/Signaling.fs b/src/Behide.OnlineServices/Hubs/Signaling/Signaling.fs new file mode 100644 index 0000000..3ab5944 --- /dev/null +++ b/src/Behide.OnlineServices/Hubs/Signaling/Signaling.fs @@ -0,0 +1,103 @@ +namespace Behide.OnlineServices.Hubs.Signaling + +open System.Threading.Tasks +open Microsoft.AspNetCore.SignalR +open FsToolkit.ErrorHandling +open Behide.OnlineServices +open Behide.OnlineServices.Signaling + +type SignalingHub(connAttemptStore: IConnAttemptStore, roomStore: IRoomStore, playerConnectionStore: IPlayerConnectionStore) = + inherit Hub() + // Should interface ISignalingHub, but it makes the methods not callable from the client + + // --- Player Connection Management --- + override hub.OnConnectedAsync() = + taskResult { + let playerConnId = hub.Context.ConnectionId |> ConnId.parse + + let playerConn = + { ConnectionId = playerConnId + ConnAttemptIds = [] + Room = None } + + do! playerConnectionStore.Add + playerConnId + playerConn + |> Result.requireTrue "Failed to add player connection" + } + |> TaskResult.mapError (printfn "Error occurred while registering player: %s") + |> Task.map ignore + :> Task + + override hub.OnDisconnectedAsync _exn = + taskResult { + let playerConnId = hub.Context.ConnectionId |> ConnId.parse + + let! playerConn = + playerConnId + |> playerConnectionStore.Get + |> Result.ofOption "Player connection not found" + + // Remove player from room + let! leaveRoomError = + match playerConn.Room with + | None -> None |> Task.singleton + | Some _ -> + hub.LeaveRoom() + |> Task.map (function + | Ok _ -> None + | Error _ -> Some "Failed to remove player from it's room") + + // Remove connection attempts + let removeConnAttemptsError = + playerConn.ConnAttemptIds + |> List.choose (fun connAttemptId -> + match connAttemptId |> connAttemptStore.Get with + | None -> None + | Some _ -> + match connAttemptId |> connAttemptStore.Remove with // TODO: Notify other player in the connection attempt + | true -> None + | false -> Some connAttemptId + ) + |> function + | [] -> None + | failedConnAttempts -> + failedConnAttempts + |> sprintf "Failed to remove connection attempts: %A" + |> Some + + // Remove player connection + let removePlayerConnectionError = + playerConnectionStore.Remove playerConnId + |> Result.requireTrue "Failed to remove player connection" + |> function + | Ok _ -> None + | Error error -> Some error + + return! + match leaveRoomError, removeConnAttemptsError, removePlayerConnectionError with + | None, None, None -> Ok () + | _ -> + sprintf + "\nLeave room error: %s\nRemove connection attempt error: %s\nRemove player connection error: %s" + (leaveRoomError |> Option.defaultValue "None") + (removeConnAttemptsError |> Option.defaultValue "None") + (removePlayerConnectionError |> Option.defaultValue "None") + |> Error + } + |> TaskResult.mapError (printfn "Error occurred while deregistering player: %s") + |> Task.map ignore + :> Task + + // --- WebRTC Signaling --- + member hub.StartConnectionAttempt (sdpDescription: SdpDescription) = WebRTCSignaling.startConnectionAttempt hub playerConnectionStore connAttemptStore sdpDescription + member hub.JoinConnectionAttempt (connAttemptId: ConnAttemptId) = WebRTCSignaling.joinConnectionAttempt hub playerConnectionStore connAttemptStore connAttemptId + member hub.SendAnswer (connAttemptId: ConnAttemptId) (answer: SdpDescription) = WebRTCSignaling.sendAnswer hub playerConnectionStore connAttemptStore connAttemptId answer + member hub.SendIceCandidate (connAttemptId: ConnAttemptId) (iceCandidate: IceCandidate) = WebRTCSignaling.sendIceCandidate hub playerConnectionStore connAttemptStore connAttemptId iceCandidate + member hub.EndConnectionAttempt (connAttemptId: ConnAttemptId) = WebRTCSignaling.endConnectionAttempt hub playerConnectionStore connAttemptStore connAttemptId + + // --- Rooms --- + member hub.CreateRoom() = RoomManagement.createRoom hub playerConnectionStore connAttemptStore roomStore + member hub.JoinRoom (roomId: RoomId) = RoomManagement.joinRoom hub playerConnectionStore connAttemptStore roomStore roomId + member hub.ConnectToRoomPlayers() = RoomManagement.connectToRoomPlayers hub playerConnectionStore connAttemptStore roomStore + member hub.LeaveRoom() = RoomManagement.leaveRoom hub playerConnectionStore connAttemptStore roomStore diff --git a/src/Behide.OnlineServices/Hubs/Signaling/WebRTCSignaling.fs b/src/Behide.OnlineServices/Hubs/Signaling/WebRTCSignaling.fs new file mode 100644 index 0000000..9a0d351 --- /dev/null +++ b/src/Behide.OnlineServices/Hubs/Signaling/WebRTCSignaling.fs @@ -0,0 +1,169 @@ +module Behide.OnlineServices.Hubs.Signaling.WebRTCSignaling + +open Behide.OnlineServices +open Behide.OnlineServices.Signaling +open Behide.OnlineServices.Signaling.Errors +type Hub = Microsoft.AspNetCore.SignalR.Hub + +open FsToolkit.ErrorHandling + +let startConnectionAttempt (hub: Hub) (playerConnectionStore: IPlayerConnectionStore) (connAttemptStore: IConnAttemptStore) (sdpDescription: SdpDescription) = + taskResult { + let playerConnId = hub.Context.ConnectionId |> ConnId.parse + + let! playerConn = + playerConnId + |> playerConnectionStore.Get + |> Result.ofOption StartConnectionAttemptError.PlayerConnectionNotFound + + // Create connection attempt + let connAttempt = + { Id = ConnAttemptId.create () + InitiatorConnectionId = playerConnId + SdpDescription = sdpDescription + Answerer = None } + + do! connAttemptStore.Add + connAttempt.Id + connAttempt + |> Result.requireTrue StartConnectionAttemptError.FailedToCreateConnAttempt + + // Update player connection + let newPlayerConn = + { playerConn with ConnAttemptIds = connAttempt.Id :: playerConn.ConnAttemptIds } + + do! playerConnectionStore.Update + playerConnId + playerConn + newPlayerConn + |> Result.requireTrue StartConnectionAttemptError.FailedToUpdatePlayerConnection + + return connAttempt.Id + } + +/// Returns the offer sdp desc and allow to send the answer +let joinConnectionAttempt (hub: Hub) (playerConnectionStore: IPlayerConnectionStore) (connAttemptStore: IConnAttemptStore) (connAttemptId: ConnAttemptId) = + taskResult { + let connId = hub.Context.ConnectionId |> ConnId.parse + + // Check if client has a player connection + do! connId + |> playerConnectionStore.Get + |> Result.ofOption JoinConnectionAttemptError.PlayerConnectionNotFound + |> Result.ignore + + // Retrieve connection attempt + let! connAttempt = + connAttemptId + |> connAttemptStore.Get + |> Result.ofOption JoinConnectionAttemptError.OfferNotFound + + // Check if connection attempt has not been answered + do! connAttempt.Answerer + |> Result.requireNone JoinConnectionAttemptError.OfferAlreadyAnswered + + // Check if the answerer is not the initiator + do! connAttempt.InitiatorConnectionId <> connId + |> Result.requireTrue JoinConnectionAttemptError.InitiatorCannotJoin + + // Update connection attempt + do! connAttemptStore.Update + connAttemptId + connAttempt + { connAttempt with Answerer = Some connId } + |> Result.requireTrue JoinConnectionAttemptError.FailedToUpdateOffer + + return connAttempt.SdpDescription + } + +let sendAnswer (hub: Hub) (playerConnectionStore: IPlayerConnectionStore) (connAttemptStore: IConnAttemptStore) (connAttemptId: ConnAttemptId) (sdpDescription: SdpDescription) = + taskResult { + let connId = hub.Context.ConnectionId |> ConnId.parse + + // Check if client has a player connection + do! connId + |> playerConnectionStore.Get + |> Result.ofOption SendAnswerError.PlayerConnectionNotFound + |> Result.ignore + + // Retrieve connection attempt + let! connAttempt = + connAttemptId + |> connAttemptStore.Get + |> Result.ofOption SendAnswerError.OfferNotFound + + // Get answerer + let! answerer = connAttempt.Answerer |> Result.ofOption SendAnswerError.NotAnswerer + // Check if the client is the answerer + do! connId = answerer |> Result.requireTrue SendAnswerError.NotAnswerer + + // Send answer to initiator + try + do! hub.Clients.Client(connAttempt.InitiatorConnectionId |> ConnId.raw).SdpAnswerReceived connAttemptId sdpDescription + with _ -> + return! Error SendAnswerError.FailedToTransmitAnswer + } + +let sendIceCandidate (hub: Hub) (playerConnectionStore: IPlayerConnectionStore) (connAttemptStore: IConnAttemptStore) (connAttemptId: ConnAttemptId) (iceCandidate: IceCandidate) = + taskResult { + let connId = hub.Context.ConnectionId |> ConnId.parse + + // Check if client has a player connection + do! connId + |> playerConnectionStore.Get + |> Result.ofOption SendIceCandidateError.PlayerConnectionNotFound + |> Result.ignore + + let! connAttempt = + connAttemptId + |> connAttemptStore.Get + |> Result.ofOption SendIceCandidateError.OfferNotFound + + let! answerer = + connAttempt.Answerer + |> Result.ofOption SendIceCandidateError.NotAnswerer + + // Check if the client is in the connection attempt + do! (connId = connAttempt.InitiatorConnectionId || connId = answerer) + |> Result.requireTrue SendIceCandidateError.NotParticipant + + // Determine the target connection id + let targetConnId = + match connId = answerer with + | true -> connAttempt.InitiatorConnectionId + | false -> answerer + + // Send ice candidate to other peer + try + do! hub.Clients.Client(targetConnId |> ConnId.raw).IceCandidateReceived connAttemptId iceCandidate + with _ -> + return! Error SendIceCandidateError.FailedToTransmitCandidate + } + +let endConnectionAttempt (hub: Hub) (playerConnectionStore: IPlayerConnectionStore) (connAttemptStore: IConnAttemptStore) (connAttemptId: ConnAttemptId) = + taskResult { + let connId = hub.Context.ConnectionId |> ConnId.parse + + // Check if client has a player connection + do! connId + |> playerConnectionStore.Get + |> Result.ofOption EndConnectionAttemptError.PlayerConnectionNotFound + |> Result.ignore + + let! connAttempt = + connAttemptId + |> connAttemptStore.Get + |> Result.ofOption EndConnectionAttemptError.OfferNotFound + + // Check if the client is in the connection attempt + match connId = connAttempt.InitiatorConnectionId with + | true -> () + | false -> + do! connAttempt.Answerer + |> Result.ofOption EndConnectionAttemptError.NotParticipant + |> Result.bind ((=) connId >> Result.requireTrue EndConnectionAttemptError.NotParticipant) + + // Remove connection attempt + do! connAttemptStore.Remove connAttemptId + |> Result.requireTrue EndConnectionAttemptError.FailedToRemoveOffer + } From d6f2489ce11a9ae57b4d5aacf0e880d8bd88cc37 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ga=C3=ABl=20Bouquain?= Date: Tue, 8 Jul 2025 12:15:27 +0200 Subject: [PATCH 03/10] Refactor ConnectToRoomPlayers --- .../Tests/Signaling/RoomManagement.fs | 14 +- .../Tests/Signaling/Signaling.fs | 2 +- src/Behide.OnlineServices.Types/Signaling.fs | 20 ++- .../Hubs/Signaling/RoomManagement.fs | 160 ++++++++---------- .../Hubs/Signaling/Signaling.fs | 2 +- 5 files changed, 95 insertions(+), 103 deletions(-) diff --git a/src/Behide.OnlineServices.Tests/Tests/Signaling/RoomManagement.fs b/src/Behide.OnlineServices.Tests/Tests/Signaling/RoomManagement.fs index e54fbe3..924a503 100644 --- a/src/Behide.OnlineServices.Tests/Tests/Signaling/RoomManagement.fs +++ b/src/Behide.OnlineServices.Tests/Tests/Signaling/RoomManagement.fs @@ -228,13 +228,10 @@ let tests testServer (roomStore: Hubs.Signaling.IRoomStore) = let connId1 = conn1.ConnectionId |> ConnId.parse let connId2 = conn2.ConnectionId |> ConnId.parse - let connection = - min connId1 connId2, - max connId1 connId2 Expect.containsAll room.Connections - [ connection ] + [ Connection.create connId1 connId2 ] "Room should contain the connection between the two players" } @@ -328,9 +325,7 @@ let tests testServer (roomStore: Hubs.Signaling.IRoomStore) = match connId <> connIdThatConnects with | false -> None // The player that connects should not be connected to himself - | true -> - Some (min connIdThatConnects connId, - max connIdThatConnects connId) + | true -> Some <| Connection.create connIdThatConnects connId ) Expect.containsAll @@ -453,10 +448,7 @@ let tests testServer (roomStore: Hubs.Signaling.IRoomStore) = |> ConnId.parse ) |> List.filter ((<>) requestingConnectionId) - |> List.map (fun otherConnectionId -> - min requestingConnectionId otherConnectionId, - max requestingConnectionId otherConnectionId - ) + |> List.map (Connection.create requestingConnectionId) ) Expect.containsAll room.Connections expectedConnections "All connections should be present" diff --git a/src/Behide.OnlineServices.Tests/Tests/Signaling/Signaling.fs b/src/Behide.OnlineServices.Tests/Tests/Signaling/Signaling.fs index 5ebba90..2e6fd31 100644 --- a/src/Behide.OnlineServices.Tests/Tests/Signaling/Signaling.fs +++ b/src/Behide.OnlineServices.Tests/Tests/Signaling/Signaling.fs @@ -23,7 +23,7 @@ let signalingTests = playerConnStore.Get connId |> Flip.Expect.wantSome "Client should be registered in the player connections store" - Expect.equal playerConn.ConnectionId connId "Connection ID should be the same" + Expect.equal playerConn.Id connId "Connection ID should be the same" } RoomManagement.tests testServer roomStore diff --git a/src/Behide.OnlineServices.Types/Signaling.fs b/src/Behide.OnlineServices.Types/Signaling.fs index 7007621..62bf547 100644 --- a/src/Behide.OnlineServices.Types/Signaling.fs +++ b/src/Behide.OnlineServices.Types/Signaling.fs @@ -67,6 +67,19 @@ type RoomId = | false -> None | true -> RoomId str |> Some +/// A connection between 2 players of the same room +type Connection = // TODO: Add tests + private { FirstPlayerConnectionId: ConnId + SecondPlayerConnectionId: ConnId } + + static member create firstConnectionId secondConnectionId = + { FirstPlayerConnectionId = min firstConnectionId secondConnectionId + SecondPlayerConnectionId = max firstConnectionId secondConnectionId } + + static member playerIsPartOf connection playerConnectionId = + connection.FirstPlayerConnectionId = playerConnectionId + || connection.SecondPlayerConnectionId = playerConnectionId + /// A room, also a group of players that are connected to each other type Room = { Id: RoomId @@ -74,8 +87,8 @@ type Room = /// Contains the initiator Players: Dictionary /// A list of the connections between the peers (in the tuple, the first is always the lowest peerId) - Connections: HashSet - ConnectionsInProgress: HashSet + Connections: HashSet + ConnectionsInProgress: HashSet Semaphore: System.Threading.SemaphoreSlim } @@ -83,7 +96,7 @@ type Room = /// Player state in the signaling process /// Only for the server to keep track of the player state type PlayerConnection = - { ConnectionId: ConnId + { Id: ConnId ConnAttemptIds: ConnAttemptId list Room: RoomId option } @@ -147,7 +160,6 @@ module Errors = | PlayerConnectionNotFound = 0 | NotInARoom = 1 | PlayerNotInRoomPlayers = 2 - | FailedToUpdateRoom = 3 type LeaveRoomError = | PlayerConnectionNotFound = 0 diff --git a/src/Behide.OnlineServices/Hubs/Signaling/RoomManagement.fs b/src/Behide.OnlineServices/Hubs/Signaling/RoomManagement.fs index fbb6ae3..83ad1dd 100644 --- a/src/Behide.OnlineServices/Hubs/Signaling/RoomManagement.fs +++ b/src/Behide.OnlineServices/Hubs/Signaling/RoomManagement.fs @@ -12,7 +12,7 @@ open System.Threading.Tasks open FsToolkit.ErrorHandling -let createRoom (hub: Hub) (playerConnectionStore: IPlayerConnectionStore) (connAttemptStore: IConnAttemptStore) (roomStore: IRoomStore) = +let createRoom (hub: Hub) (playerConnectionStore: IPlayerConnectionStore) (_connAttemptStore: IConnAttemptStore) (roomStore: IRoomStore) = taskResult { let playerConnId = hub.Context.ConnectionId |> ConnId.parse @@ -50,7 +50,7 @@ let createRoom (hub: Hub) (playerConnectionStore: IPlayerConnectionStore) (connA return room.Id } -let joinRoom (hub: Hub) (playerConnectionStore: IPlayerConnectionStore) (connAttemptStore: IConnAttemptStore) (roomStore: IRoomStore) (roomId: RoomId) = +let joinRoom (hub: Hub) (playerConnectionStore: IPlayerConnectionStore) (_connAttemptStore: IConnAttemptStore) (roomStore: IRoomStore) (roomId: RoomId) = taskResult { let playerConnId = hub.Context.ConnectionId |> ConnId.parse @@ -100,117 +100,108 @@ let joinRoom (hub: Hub) (playerConnectionStore: IPlayerConnectionStore) (connAtt return newPeerId } -let connectToRoomPlayers (hub: Hub) (playerConnectionStore: IPlayerConnectionStore) (connAttemptStore: IConnAttemptStore) (roomStore: IRoomStore) = + +let private findPlayersToConnectTo (playerConnection: PlayerConnection) (room: Room) = + room.Players + |> Array.ofSeq + |> Array.filter (fun kv -> + let playerConnectionId = kv.Key + + let connectionToCheck = Connection.create playerConnection.Id playerConnectionId + let alreadyConnected = + room.Connections.Contains(connectionToCheck) + || room.ConnectionsInProgress.Contains(connectionToCheck) + + playerConnectionId <> playerConnection.Id && not alreadyConnected + ) + +let setInProgressConnections playerConnection (playersToConnectTo: KeyValuePair array) room = + playersToConnectTo |> Array.map (fun kv -> + let connection = Connection.create playerConnection.Id kv.Key + room.ConnectionsInProgress.Add connection |> ignore + connection + ) + +let requestConnectionForPlayer (hub: Hub) playerConnection requestingPeerId (targetPeerId, targetConnId) = + taskResult { + let! r = + targetConnId + |> ConnId.raw + |> hub.Clients.Client + |> _.ConnectionRequested(requestingPeerId) + |> _.WaitAsync(TimeSpan.FromSeconds 10.) + |> Task.catch + |> Task.map (function // Handle the case where the client didn't register a handler + | Choice1Of2 r -> Ok r + | Choice2Of2 _ -> Error targetPeerId + ) + + match r with + | null -> return! Error targetPeerId + | connAttemptId -> + return { PeerId = targetPeerId; ConnAttemptId = connAttemptId }, + Connection.create playerConnection.Id targetConnId + } + +let connectToRoomPlayers (hub: Hub) (playerConnectionStore: IPlayerConnectionStore) (_connAttemptStore: IConnAttemptStore) (roomStore: IRoomStore) = taskResult { let playerConnectionId = hub.Context.ConnectionId |> ConnId.parse - let! playerConn = + let! playerConnection = playerConnectionId |> playerConnectionStore.Get |> Result.ofOption ConnectToRoomPlayersError.PlayerConnectionNotFound - // Find players to connect to let! room = - playerConn.Room + playerConnection.Room |> Option.bind roomStore.Get |> Result.ofOption ConnectToRoomPlayersError.NotInARoom do! room.Semaphore.WaitAsync() // Lock room - let! requestingPeerId = - match room.Players.TryGetValue playerConnectionId with + match room.Players.TryGetValue playerConnection.Id with | false, _ -> Error ConnectToRoomPlayersError.PlayerNotInRoomPlayers | true, peerId -> Ok peerId - let playersToConnectTo = - room.Players - |> Seq.filter (fun kv -> - let playerConnectionId' = kv.Key - - let connectionToCheck = min playerConnectionId playerConnectionId', max playerConnectionId playerConnectionId' - let alreadyConnected = - room.Connections.Contains(connectionToCheck) - || room.ConnectionsInProgress.Contains(connectionToCheck) - - playerConnectionId' <> playerConnectionId && not alreadyConnected - ) - |> Seq.toArray - - // Indicates connections are in progress - let attemptingConnections = - playersToConnectTo |> Seq.map (fun kv -> - let playerConnectionId' = kv.Key - min playerConnectionId playerConnectionId', - max playerConnectionId playerConnectionId' - ) - attemptingConnections |> Seq.iter (room.ConnectionsInProgress.Add >> ignore) - + let playersToConnectTo = findPlayersToConnectTo playerConnection room + let inProgressConnections = setInProgressConnections playerConnection playersToConnectTo room room.Semaphore.Release() |> ignore // Unlock room // Create connection attempts - let createConnectionAttemptForPlayer (targetPeerId, targetConnId) = - taskResult { - let! r = - targetConnId - |> ConnId.raw - |> hub.Clients.Client - |> _.ConnectionRequested(requestingPeerId) - |> _.WaitAsync(TimeSpan.FromSeconds 10.) - |> Task.catch - |> Task.map (function // Handle the case where the client didn't register a handler - | Choice1Of2 r -> Ok r - | Choice2Of2 _ -> Error targetPeerId - ) - - match r with - | null -> return! Error targetPeerId - | connAttemptId -> - let connInfo = { PeerId = targetPeerId; ConnAttemptId = connAttemptId } - let connection = - min playerConnectionId targetConnId, - max playerConnectionId targetConnId - - return connInfo, connection - } - - let! playersConnectionInfoResults = + let requestConnectionForPlayer = requestConnectionForPlayer hub playerConnection requestingPeerId + let! playersConnectionInfo = playersToConnectTo - |> Seq.map (fun kv -> createConnectionAttemptForPlayer (kv.Value, kv.Key)) + |> Array.map (fun kv -> requestConnectionForPlayer (kv.Value, kv.Key)) |> Task.WhenAll - let mutable playersConnInfo = [] - let mutable failed = [] - let mutable newEstablishedConnections = [] - - playersConnectionInfoResults |> Array.iter ( - function - | Ok (connInfo, connection) -> - playersConnInfo <- connInfo :: playersConnInfo - newEstablishedConnections <- connection :: newEstablishedConnections - | Error peerId -> - failed <- peerId :: failed - ) - - // Update room + // Build return value and update room connections do! room.Semaphore.WaitAsync() - - newEstablishedConnections |> Seq.iter (fun connection -> - room.ConnectionsInProgress.Remove connection |> ignore - room.Connections.Add connection |> ignore - ) - - attemptingConnections |> Seq.iter (fun connection -> + let playersConnInfo, failed = + playersConnectionInfo |> Array.fold + (fun (playersConnInfo, failed) playerConnectionInfo -> + match playerConnectionInfo with + | Ok (connInfo, connection) -> + + // Convert in progress connections in connections + room.ConnectionsInProgress.Remove connection |> ignore + room.Connections.Add connection |> ignore + + connInfo :: playersConnInfo, failed + | Error peerId -> playersConnInfo, peerId :: failed) + (List.empty, List.empty) + + // Remove inProgressConnections (in case a connection attempt failed) + inProgressConnections |> Array.iter (fun connection -> room.ConnectionsInProgress.Remove connection |> ignore ) - // ConnectToRoomPlayersError.FailedToUpdateRoom // TODO: ? room.Semaphore.Release() |> ignore return { PlayersConnInfo = playersConnInfo |> List.toArray FailedCreations = failed |> List.toArray } } -let leaveRoom (hub: Hub) (playerConnectionStore: IPlayerConnectionStore) (connAttemptStore: IConnAttemptStore) (roomStore: IRoomStore) = +let leaveRoom (hub: Hub) (playerConnectionStore: IPlayerConnectionStore) (_connAttemptStore: IConnAttemptStore) (roomStore: IRoomStore) = taskResult { let playerConnectionId = hub.Context.ConnectionId |> ConnId.parse @@ -234,11 +225,8 @@ let leaveRoom (hub: Hub) (playerConnectionStore: IPlayerConnectionStore) (connAt | _ -> // Remove player connections and player from room - room.Connections.RemoveWhere(fun (p1, p2) -> - match playerConnectionId with - | Equals p1 - | Equals p2 -> true - | _ -> false + room.Connections.RemoveWhere(fun connection -> + playerConnectionId |> Connection.playerIsPartOf connection ) |> ignore do! room.Players.Remove(playerConnectionId) diff --git a/src/Behide.OnlineServices/Hubs/Signaling/Signaling.fs b/src/Behide.OnlineServices/Hubs/Signaling/Signaling.fs index 3ab5944..907daeb 100644 --- a/src/Behide.OnlineServices/Hubs/Signaling/Signaling.fs +++ b/src/Behide.OnlineServices/Hubs/Signaling/Signaling.fs @@ -16,7 +16,7 @@ type SignalingHub(connAttemptStore: IConnAttemptStore, roomStore: IRoomStore, pl let playerConnId = hub.Context.ConnectionId |> ConnId.parse let playerConn = - { ConnectionId = playerConnId + { Id = playerConnId ConnAttemptIds = [] Room = None } From e9f70182fb948576b732e2419ddd5bd7a2062e6e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ga=C3=ABl=20Bouquain?= Date: Tue, 8 Jul 2025 15:40:44 +0200 Subject: [PATCH 04/10] Renamed things --- src/Behide.OnlineServices.Tests/Common.fs | 13 +- .../Tests/Signaling/RoomManagement.fs | 90 ++++----- .../Tests/Signaling/Signaling.fs | 10 +- .../Tests/Signaling/WebRTCSignaling.fs | 44 ++--- .../Behide.OnlineServices.Types.fsproj | 1 + src/Behide.OnlineServices.Types/Signaling.fs | 164 +++++----------- .../SignalingErrors.fs | 56 ++++++ .../Hubs/Signaling/Common.fs | 8 +- .../Hubs/Signaling/RoomManagement.fs | 160 ++++++++------- .../Hubs/Signaling/Signaling.fs | 74 ++++--- .../Hubs/Signaling/WebRTCSignaling.fs | 184 +++++++++--------- src/Behide.OnlineServices/Program.fs | 9 +- 12 files changed, 393 insertions(+), 420 deletions(-) create mode 100644 src/Behide.OnlineServices.Types/SignalingErrors.fs diff --git a/src/Behide.OnlineServices.Tests/Common.fs b/src/Behide.OnlineServices.Tests/Common.fs index 22008d7..1d4a7eb 100644 --- a/src/Behide.OnlineServices.Tests/Common.fs +++ b/src/Behide.OnlineServices.Tests/Common.fs @@ -10,22 +10,17 @@ open Behide.OnlineServices open Behide.OnlineServices.Signaling let createTestServer () = - let connectionAttemptStore = Hubs.Signaling.ConnAttemptStore() + let connectionAttemptStore = Hubs.Signaling.ConnectionAttemptStore() let roomStore = Hubs.Signaling.RoomStore() - let playerConnStore = Hubs.Signaling.PlayerConnectionStore() + let playerConnStore = Hubs.Signaling.PlayerStore() let hostBuilder = WebHostBuilder() .ConfigureServices(fun services -> services |> Program.configureServices |> ignore - - services.Remove(ServiceDescriptor.Singleton()) |> ignore - services.Remove(ServiceDescriptor.Singleton()) |> ignore - services.Remove(ServiceDescriptor.Singleton()) |> ignore - - services.AddSingleton(connectionAttemptStore) |> ignore + services.AddSingleton(connectionAttemptStore) |> ignore services.AddSingleton(roomStore) |> ignore - services.AddSingleton(playerConnStore) |> ignore + services.AddSingleton(playerConnStore) |> ignore ) .Configure(fun app -> app.UseRouting() diff --git a/src/Behide.OnlineServices.Tests/Tests/Signaling/RoomManagement.fs b/src/Behide.OnlineServices.Tests/Tests/Signaling/RoomManagement.fs index 924a503..537d91e 100644 --- a/src/Behide.OnlineServices.Tests/Tests/Signaling/RoomManagement.fs +++ b/src/Behide.OnlineServices.Tests/Tests/Signaling/RoomManagement.fs @@ -57,7 +57,7 @@ let tests testServer (roomStore: Hubs.Signaling.IRoomStore) = let mutable offerId = None // Add handler for creating offer - conn1.On("ConnectionRequested", fun _playerId -> + conn1.On("ConnectionRequested", fun _playerId -> Common.fakeSdpDescription |> signalingHub1.StartConnectionAttempt |> Task.map (function @@ -77,7 +77,7 @@ let tests testServer (roomStore: Hubs.Signaling.IRoomStore) = // Join the room - let! playerId = + let! peerId = signalingHub2.JoinRoom roomId |> Task.map (Flip.Expect.wantOk "Room joining should success") @@ -88,18 +88,18 @@ let tests testServer (roomStore: Hubs.Signaling.IRoomStore) = // Check if the room connection info is correct - Expect.equal playerId 2 "Peer ID should be 2 in that case" + Expect.equal peerId 2 "Peer ID should be 2 in that case" // Check if the room contains both players Expect.equal conn1.ConnectionId - (room.Initiator |> ConnId.raw) + (room.Initiator |> PlayerId.raw) "Room initiator should be the first player connection id" Expect.containsAll room.Players - [ KeyValuePair(conn1.ConnectionId |> ConnId.parse, 1) - KeyValuePair(conn2.ConnectionId |> ConnId.parse, 2) ] + [ KeyValuePair(conn1.ConnectionId |> PlayerId.fromHubConnectionId, 1) + KeyValuePair(conn2.ConnectionId |> PlayerId.fromHubConnectionId, 2) ] "Room should contain both player connection ids" } @@ -137,7 +137,7 @@ let tests testServer (roomStore: Hubs.Signaling.IRoomStore) = "Nonexisting room joining should fail" } - testTask "Joining room should give a unique playerId" { + testTask "Joining room should give a unique peerId" { let! (_, signalingHub1: ISignalingHub) = testServer |> connectHub let! (_, signalingHub2: ISignalingHub) = testServer |> connectHub let! (_, signalingHub3: ISignalingHub) = testServer |> connectHub @@ -147,34 +147,34 @@ let tests testServer (roomStore: Hubs.Signaling.IRoomStore) = signalingHub1.CreateRoom() |> Task.map (Flip.Expect.wantOk "Room creation should success") - let playerId1 = 1 // The first player should always have the playerId 1 + let peerId1 = 1 // The first player should always have the peerId 1 // Join room - let! playerId2 = + let! peerId2 = signalingHub2.JoinRoom roomId |> Task.map (Flip.Expect.wantOk "Room joining should success") - Expect.equal playerId2 2 "The second player should have the playerId to 2" - Expect.notEqual playerId1 playerId2 "The two players should have different playerId" + Expect.equal peerId2 2 "The second player should have the peerId to 2" + Expect.notEqual peerId1 peerId2 "The two players should have different peerId" // Leave and rejoin room do! signalingHub2.LeaveRoom() |> Task.map (Flip.Expect.wantOk "Leaving room should success") - let! playerId3 = + let! peerId3 = signalingHub2.JoinRoom roomId |> Task.map (Flip.Expect.wantOk "Room joining should success") - Expect.equal playerId3 2 "The second player should have the playerId to 2" - Expect.notEqual playerId1 playerId3 "The two players should have different playerId" + Expect.equal peerId3 2 "The second player should have the peerId to 2" + Expect.notEqual peerId1 peerId3 "The two players should have different peerId" // Join room with a third player - let! playerId4 = + let! peerId4 = signalingHub3.JoinRoom roomId |> Task.map (Flip.Expect.wantOk "Room joining should success") - Expect.equal playerId4 3 "The third player should have the playerId to 3" - Expect.notEqual playerId1 playerId4 "The two players should have different playerId" + Expect.equal peerId4 3 "The third player should have the peerId to 3" + Expect.notEqual peerId1 peerId4 "The two players should have different peerId" } ] @@ -190,16 +190,16 @@ let tests testServer (roomStore: Hubs.Signaling.IRoomStore) = // Join room let! (conn2: HubConnection, signalingHub2: ISignalingHub) = testServer |> connectHub - let! secondPlayerId = + let! secondPeerId = signalingHub2.JoinRoom roomId |> Task.map (Flip.Expect.wantOk "Failed to join room") - Expect.equal secondPlayerId 2 "The second player should have a playerId/peerId to 2" + Expect.equal secondPeerId 2 "The second player should have a peerId to 2" // Register "ConnectionRequested" handler on first player let mutable connAttemptId = None - conn1.On("ConnectionRequested", fun _playerId -> + conn1.On("ConnectionRequested", fun _peerId -> Common.fakeSdpDescription |> signalingHub1.StartConnectionAttempt |> Task.map (function @@ -217,7 +217,7 @@ let tests testServer (roomStore: Hubs.Signaling.IRoomStore) = let connAttemptId = Expect.wantSome connAttemptId "An connection attempt id should have been generated" Expect.sequenceEqual - (res.PlayersConnInfo |> List.ofArray) + (res.PlayersConnectionInfo |> List.ofArray) [ { PeerId = 1; ConnAttemptId = connAttemptId } ] "Players connection info should contain the first player connection info and only that" @@ -226,12 +226,12 @@ let tests testServer (roomStore: Hubs.Signaling.IRoomStore) = roomStore.Get roomId |> Flip.Expect.wantSome "Room should still exist" - let connId1 = conn1.ConnectionId |> ConnId.parse - let connId2 = conn2.ConnectionId |> ConnId.parse + let connId1 = conn1.ConnectionId |> PlayerId.fromHubConnectionId + let connId2 = conn2.ConnectionId |> PlayerId.fromHubConnectionId Expect.containsAll room.Connections - [ Connection.create connId1 connId2 ] + [ Pair.create connId1 connId2 ] "Room should contain the connection between the two players" } @@ -252,10 +252,10 @@ let tests testServer (roomStore: Hubs.Signaling.IRoomStore) = players |> List.indexed |> List.choose (fun (idx, (conn, hub)) -> - let connAttemptIdTcs = TaskCompletionSource>() + let connAttemptIdTcs = TaskCompletionSource>() cts.Token.Register(fun _ -> connAttemptIdTcs.TrySetCanceled() |> ignore) |> ignore - conn.On("ConnectionRequested", fun _ -> + conn.On("ConnectionRequested", fun _ -> Common.fakeSdpDescription |> hub.StartConnectionAttempt |> Task.map (fun res -> @@ -297,13 +297,13 @@ let tests testServer (roomStore: Hubs.Signaling.IRoomStore) = let! connAttemptIds = connAttemptIdsTask Expect.isEmpty res.FailedCreations "All connection attempt creations should be successful" - Expect.hasLength res.PlayersConnInfo (players.Length - 1) "Their should be one player connection info per player to connect to" + Expect.hasLength res.PlayersConnectionInfo (players.Length - 1) "Their should be one player connection info per player to connect to" Expect.hasLength connAttemptIds (players.Length - 1) "Their should be one connection attempt id per players to connect to" - Expect.hasLength connAttemptIds res.PlayersConnInfo.Length "Their should be one connection attempt id per player connection info" + Expect.hasLength connAttemptIds res.PlayersConnectionInfo.Length "Their should be one connection attempt id per player connection info" Expect.containsAll connAttemptIds - (res.PlayersConnInfo |> Array.map _.ConnAttemptId) + (res.PlayersConnectionInfo |> Array.map _.ConnAttemptId) "Created connection attempt ids should be the same that the received connection attempt ids" // Check connections @@ -316,16 +316,16 @@ let tests testServer (roomStore: Hubs.Signaling.IRoomStore) = |> List.item peerThatConnects |> fst |> _.ConnectionId - |> ConnId.parse + |> PlayerId.fromHubConnectionId let expectedConnections = players |> List.choose (fun (conn, _) -> - let connId = conn.ConnectionId |> ConnId.parse + let connId = conn.ConnectionId |> PlayerId.fromHubConnectionId match connId <> connIdThatConnects with | false -> None // The player that connects should not be connected to himself - | true -> Some <| Connection.create connIdThatConnects connId + | true -> Some <| Pair.create connIdThatConnects connId ) Expect.containsAll @@ -349,8 +349,8 @@ let tests testServer (roomStore: Hubs.Signaling.IRoomStore) = // Register handlers let connectionAttemptIds = ConcurrentBag() players |> List.iter (fun (hubConnection, hub) -> - hubConnection.On("ConnectionRequested", fun requestingPeerId -> - Task.Run(fun () -> + hubConnection.On("ConnectionRequested", fun requestingPeerId -> + Task.Run(fun () -> Common.fakeSdpDescription |> hub.StartConnectionAttempt |> TaskResult.tee connectionAttemptIds.Add @@ -402,7 +402,7 @@ let tests testServer (roomStore: Hubs.Signaling.IRoomStore) = let connectionInfo = Expect.wantOk res "ConnectToRoomPlayers method should succeed" Expect.isEmpty connectionInfo.FailedCreations $"All connection attempt creations should be successful: PlayerIdx {playerIdx}" - for connectionInfo in connectionInfo.PlayersConnInfo do + for connectionInfo in connectionInfo.PlayersConnectionInfo do let targetPlayerIdx = playerIndexByPlayerPeerId[connectionInfo.PeerId] connectionsMade[playerIdx][targetPlayerIdx] <- true connectionsMade[targetPlayerIdx][playerIdx] <- true @@ -439,16 +439,16 @@ let tests testServer (roomStore: Hubs.Signaling.IRoomStore) = players[playerIdx] |> fst |> _.ConnectionId - |> ConnId.parse + |> PlayerId.fromHubConnectionId List.init nbOfPlayers (fun otherPlayerIdx -> players[otherPlayerIdx] |> fst |> _.ConnectionId - |> ConnId.parse + |> PlayerId.fromHubConnectionId ) |> List.filter ((<>) requestingConnectionId) - |> List.map (Connection.create requestingConnectionId) + |> List.map (Pair.create requestingConnectionId) ) Expect.containsAll room.Connections expectedConnections "All connections should be present" @@ -474,7 +474,7 @@ let tests testServer (roomStore: Hubs.Signaling.IRoomStore) = [ 1 ] // 1 is the peerId of the first player, the one who created the room "Joining room without add handler for ConnectionRequested should fail" - Expect.isEmpty res.PlayersConnInfo "No connection info should be returned" + Expect.isEmpty res.PlayersConnectionInfo "No connection info should be returned" } testTask "Connect to players with a blocking ConnectionRequested handler" { @@ -491,9 +491,9 @@ let tests testServer (roomStore: Hubs.Signaling.IRoomStore) = |> Task.map (Flip.Expect.wantOk "Room joining should success") // Register blocking "ConnectionRequested" handler on first player - conn1.On("ConnectionRequested", fun _playerId -> + conn1.On("ConnectionRequested", fun _playerId -> while true do () // Never returns - ConnAttemptId.create() + ConnectionAttemptId.create() ) |> ignore @@ -506,7 +506,7 @@ let tests testServer (roomStore: Hubs.Signaling.IRoomStore) = [ 1 ] // 1 is the peerId of the first player, the one who created the room "Joining room with a blocking ConnectionRequested handler should fail" - Expect.isEmpty res.PlayersConnInfo "No connection info should be returned" + Expect.isEmpty res.PlayersConnectionInfo "No connection info should be returned" } testTask "Connect to players while not in a room" { @@ -550,7 +550,7 @@ let tests testServer (roomStore: Hubs.Signaling.IRoomStore) = Expect.sequenceEqual room.Players - [ KeyValuePair(conn1.ConnectionId |> ConnId.parse, 1) ] + [ KeyValuePair(conn1.ConnectionId |> PlayerId.fromHubConnectionId, 1) ] "Room should not contain the second player" Expect.isEmpty room.Connections "Room should not contain any connection" @@ -572,7 +572,7 @@ let tests testServer (roomStore: Hubs.Signaling.IRoomStore) = |> Task.map (Flip.Expect.isOk "Room joining should success") // Connect to room players - conn1.On("ConnectionRequested", fun _playerId -> + conn1.On("ConnectionRequested", fun _playerId -> Common.fakeSdpDescription |> signalingHub1.StartConnectionAttempt |> Task.map (function @@ -595,7 +595,7 @@ let tests testServer (roomStore: Hubs.Signaling.IRoomStore) = Expect.sequenceEqual room.Players - [ KeyValuePair(conn1.ConnectionId |> ConnId.parse, 1) ] + [ KeyValuePair(conn1.ConnectionId |> PlayerId.fromHubConnectionId, 1) ] "Room should not contain the second player" Expect.isEmpty room.Connections "Room should not contain any connection" diff --git a/src/Behide.OnlineServices.Tests/Tests/Signaling/Signaling.fs b/src/Behide.OnlineServices.Tests/Tests/Signaling/Signaling.fs index 2e6fd31..cd050de 100644 --- a/src/Behide.OnlineServices.Tests/Tests/Signaling/Signaling.fs +++ b/src/Behide.OnlineServices.Tests/Tests/Signaling/Signaling.fs @@ -18,14 +18,14 @@ let signalingTests = Expect.equal connection.State HubConnectionState.Connected "Should be connected to the hub" - let connId = connection.ConnectionId |> ConnId.parse - let playerConn = - playerConnStore.Get connId + let playerId = connection.ConnectionId |> PlayerId.fromHubConnectionId + let player = + playerConnStore.Get playerId |> Flip.Expect.wantSome "Client should be registered in the player connections store" - Expect.equal playerConn.Id connId "Connection ID should be the same" + Expect.equal player.Id playerId "Player ids should be the same" } - RoomManagement.tests testServer roomStore WebRTCSignaling.tests testServer connectionAttemptStore + RoomManagement.tests testServer roomStore ] diff --git a/src/Behide.OnlineServices.Tests/Tests/Signaling/WebRTCSignaling.fs b/src/Behide.OnlineServices.Tests/Tests/Signaling/WebRTCSignaling.fs index 0665f9c..b5aa213 100644 --- a/src/Behide.OnlineServices.Tests/Tests/Signaling/WebRTCSignaling.fs +++ b/src/Behide.OnlineServices.Tests/Tests/Signaling/WebRTCSignaling.fs @@ -9,7 +9,7 @@ open Behide.OnlineServices.Signaling open Behide.OnlineServices.Tests open Behide.OnlineServices.Tests.Signaling.Common -let tests testServer (connectionAttemptStore: Hubs.Signaling.IConnAttemptStore) = +let tests testServer (connectionAttemptStore: Hubs.Signaling.IConnectionAttemptStore) = testList "WebRTC Signaling" [ testList "StartConnectionAttempt" [ testTask "Create connection attempt" { @@ -27,11 +27,11 @@ let tests testServer (connectionAttemptStore: Hubs.Signaling.IConnAttemptStore) Expect.equal offer.InitiatorConnectionId - (conn.ConnectionId |> ConnId.parse) + (conn.ConnectionId |> PlayerId.fromHubConnectionId) "Offer initiator should be the player connection id" Expect.equal - offer.SdpDescription + offer.Offer Common.fakeSdpDescription "Offer SDP description should be the same" } @@ -77,14 +77,14 @@ let tests testServer (connectionAttemptStore: Hubs.Signaling.IConnAttemptStore) Expect.equal offer.InitiatorConnectionId - (conn1.ConnectionId |> ConnId.parse) + (conn1.ConnectionId |> PlayerId.fromHubConnectionId) "Offer initiator should be the first player connection id" } testTask "Join nonexisting connection attempt" { let! (_, signalingHub: ISignalingHub) = testServer |> connectHub - let fakeOfferId = ConnAttemptId.create() + let fakeOfferId = ConnectionAttemptId.create() let! (error: Errors.JoinConnectionAttemptError) = fakeOfferId @@ -93,7 +93,7 @@ let tests testServer (connectionAttemptStore: Hubs.Signaling.IConnAttemptStore) Expect.equal error - Errors.JoinConnectionAttemptError.OfferNotFound + Errors.JoinConnectionAttemptError.ConnectionAttemptNotFound "Nonexisting connection attempt joining should fail" } @@ -121,7 +121,7 @@ let tests testServer (connectionAttemptStore: Hubs.Signaling.IConnAttemptStore) Expect.equal error - Errors.JoinConnectionAttemptError.OfferAlreadyAnswered + Errors.JoinConnectionAttemptError.ConnectionAttemptAlreadyAnswered "Already answered connection attempt joining should fail" } @@ -201,7 +201,7 @@ let tests testServer (connectionAttemptStore: Hubs.Signaling.IConnAttemptStore) testTask "End nonexisting connection attempt" { let! (_, hub: ISignalingHub) = testServer |> connectHub - let fakeOfferId = ConnAttemptId.create() + let fakeOfferId = ConnectionAttemptId.create() let! (error: Errors.EndConnectionAttemptError) = hub.EndConnectionAttempt fakeOfferId @@ -209,7 +209,7 @@ let tests testServer (connectionAttemptStore: Hubs.Signaling.IConnAttemptStore) Expect.equal error - Errors.EndConnectionAttemptError.OfferNotFound + Errors.EndConnectionAttemptError.ConnectionAttemptNotFound "Ending nonexisting connection attempt should fail" } @@ -241,7 +241,7 @@ let tests testServer (connectionAttemptStore: Hubs.Signaling.IConnAttemptStore) Expect.equal error - Errors.EndConnectionAttemptError.OfferNotFound + Errors.EndConnectionAttemptError.ConnectionAttemptNotFound "Ending already ended connection attempt should fail" } @@ -302,9 +302,9 @@ let tests testServer (connectionAttemptStore: Hubs.Signaling.IConnAttemptStore) let! (_, signalingHub2: ISignalingHub) = testServer |> connectHub // Subscribe to SdpAnswerReceived event - let sdpAnswerReceivedTcs = Common.TimedTaskCompletionSource(1000) + let sdpAnswerReceivedTcs = Common.TimedTaskCompletionSource(1000) - conn1.On("SdpAnswerReceived", fun (offerId: ConnAttemptId) (sdpDescription: SdpDescription) -> + conn1.On("SdpAnswerReceived", fun (offerId: ConnectionAttemptId) (sdpDescription: SdpDescription) -> sdpAnswerReceivedTcs.SetResult(offerId, sdpDescription) ) |> ignore @@ -327,7 +327,7 @@ let tests testServer (connectionAttemptStore: Hubs.Signaling.IConnAttemptStore) |> Task.map (Flip.Expect.isOk "Answer sending should success") // Test received answer - let! (receivedOfferId: ConnAttemptId, receivedSdpDesc: SdpDescription) = sdpAnswerReceivedTcs.Task + let! (receivedOfferId: ConnectionAttemptId, receivedSdpDesc: SdpDescription) = sdpAnswerReceivedTcs.Task Expect.equal receivedOfferId @@ -343,7 +343,7 @@ let tests testServer (connectionAttemptStore: Hubs.Signaling.IConnAttemptStore) testTask "Send answer to nonexisting connection attempt" { let! (_, signalingHub: ISignalingHub) = testServer |> connectHub - let fakeOfferId = ConnAttemptId.create() + let fakeOfferId = ConnectionAttemptId.create() let! (error: Errors.SendAnswerError) = signalingHub.SendAnswer @@ -353,7 +353,7 @@ let tests testServer (connectionAttemptStore: Hubs.Signaling.IConnAttemptStore) Expect.equal error - Errors.SendAnswerError.OfferNotFound + Errors.SendAnswerError.ConnectionAttemptNotFound "Sending answer to nonexisting connection attempt should fail" } @@ -416,9 +416,9 @@ let tests testServer (connectionAttemptStore: Hubs.Signaling.IConnAttemptStore) let! (_, signalingHub2: ISignalingHub) = testServer |> connectHub // Subscribe to IceCandidateReceived event - let iceCandidateReceivedTcs = Common.TimedTaskCompletionSource(1000) + let iceCandidateReceivedTcs = Common.TimedTaskCompletionSource(1000) - conn1.On("IceCandidateReceived", fun (offerId: ConnAttemptId) (iceCandidate: IceCandidate) -> + conn1.On("IceCandidateReceived", fun (offerId: ConnectionAttemptId) (iceCandidate: IceCandidate) -> iceCandidateReceivedTcs.SetResult(offerId, iceCandidate) ) |> ignore @@ -441,7 +441,7 @@ let tests testServer (connectionAttemptStore: Hubs.Signaling.IConnAttemptStore) |> Task.map (Flip.Expect.isOk "Ice candidate sending should success") // Test received ice candidate - let! (receivedOfferId: ConnAttemptId, receivedIceCandidate: IceCandidate) = iceCandidateReceivedTcs.Task + let! (receivedOfferId: ConnectionAttemptId, receivedIceCandidate: IceCandidate) = iceCandidateReceivedTcs.Task Expect.equal receivedOfferId @@ -457,7 +457,7 @@ let tests testServer (connectionAttemptStore: Hubs.Signaling.IConnAttemptStore) testTask "Send ice candidate to nonexisting connection attempt" { let! (_, signalingHub: ISignalingHub) = testServer |> connectHub - let fakeOfferId = ConnAttemptId.create() + let fakeOfferId = ConnectionAttemptId.create() let! (error: Errors.SendIceCandidateError) = signalingHub.SendIceCandidate @@ -467,7 +467,7 @@ let tests testServer (connectionAttemptStore: Hubs.Signaling.IConnAttemptStore) Expect.equal error - Errors.SendIceCandidateError.OfferNotFound + Errors.SendIceCandidateError.ConnectionAttemptNotFound "Sending ice candidate to nonexisting connection attempt should fail" } @@ -490,7 +490,7 @@ let tests testServer (connectionAttemptStore: Hubs.Signaling.IConnAttemptStore) Expect.equal error - Errors.SendIceCandidateError.NotAnswerer + Errors.SendIceCandidateError.NoAnswerer "Sending ice candidate to not joined connection attempt should fail" } @@ -542,7 +542,7 @@ let tests testServer (connectionAttemptStore: Hubs.Signaling.IConnAttemptStore) Expect.equal error - Errors.SendIceCandidateError.NotAnswerer + Errors.SendIceCandidateError.NoAnswerer "Sending ice candidate connection attempt should fail" } ] diff --git a/src/Behide.OnlineServices.Types/Behide.OnlineServices.Types.fsproj b/src/Behide.OnlineServices.Types/Behide.OnlineServices.Types.fsproj index ee065c0..a1f2d85 100644 --- a/src/Behide.OnlineServices.Types/Behide.OnlineServices.Types.fsproj +++ b/src/Behide.OnlineServices.Types/Behide.OnlineServices.Types.fsproj @@ -4,6 +4,7 @@ net9.0 + \ No newline at end of file diff --git a/src/Behide.OnlineServices.Types/Signaling.fs b/src/Behide.OnlineServices.Types/Signaling.fs index 62bf547..79c2b2a 100644 --- a/src/Behide.OnlineServices.Types/Signaling.fs +++ b/src/Behide.OnlineServices.Types/Signaling.fs @@ -4,6 +4,16 @@ open System open System.Collections.Generic open System.Threading.Tasks +type Pair<'T when 'T: comparison> = // TODO: Add tests + { First: 'T; Second: 'T } + +module Pair = + let create first second = + { First = min first second + Second = max first second } + + let isInPair pair value = pair.First = value || pair.Second = value + type SdpDescription = { ``type``: string sdp: string } @@ -13,30 +23,26 @@ type IceCandidate = index: int name: string } -/// Represents a SignalR connection id -type ConnId = - private | ConnId of string - static member parse connId = ConnId connId - static member raw (ConnId connId) = connId +type PlayerId = + private | PlayerId of string + static member fromHubConnectionId connId = PlayerId connId + static member raw (PlayerId connId) = connId // --- Connection attempt -/// Represents an connection attempt id -type ConnAttemptId = - private | ConnAttemptId of Guid - static member create () = Guid.NewGuid() |> ConnAttemptId - static member raw (ConnAttemptId guid) = guid.ToString() - -/// A peer to peer connection attempt -type ConnAttempt = - { Id: ConnAttemptId - InitiatorConnectionId: ConnId - SdpDescription: SdpDescription - Answerer: ConnId option } - +type ConnectionAttemptId = + private | ConnectionAttemptId of Guid + static member create () = Guid.NewGuid() |> ConnectionAttemptId + static member raw (ConnectionAttemptId guid) = guid.ToString() + +/// A WebRTC connection attempt +type ConnectionAttempt = + { Id: ConnectionAttemptId + InitiatorConnectionId: PlayerId + Offer: SdpDescription + Answerer: PlayerId option } // --- Room -/// Represents a room id type RoomId = private | RoomId of string @@ -67,118 +73,46 @@ type RoomId = | false -> None | true -> RoomId str |> Some -/// A connection between 2 players of the same room -type Connection = // TODO: Add tests - private { FirstPlayerConnectionId: ConnId - SecondPlayerConnectionId: ConnId } - - static member create firstConnectionId secondConnectionId = - { FirstPlayerConnectionId = min firstConnectionId secondConnectionId - SecondPlayerConnectionId = max firstConnectionId secondConnectionId } - - static member playerIsPartOf connection playerConnectionId = - connection.FirstPlayerConnectionId = playerConnectionId - || connection.SecondPlayerConnectionId = playerConnectionId - /// A room, also a group of players that are connected to each other type Room = { Id: RoomId - Initiator: ConnId - /// Contains the initiator - Players: Dictionary - /// A list of the connections between the peers (in the tuple, the first is always the lowest peerId) - Connections: HashSet - ConnectionsInProgress: HashSet + Initiator: PlayerId + /// Player peer ids by player id + /// Contains the initiator + Players: Dictionary + /// A list of the connections between the peers + Connections: HashSet + ConnectionsInProgress: HashSet Semaphore: System.Threading.SemaphoreSlim } - /// Player state in the signaling process /// Only for the server to keep track of the player state -type PlayerConnection = - { Id: ConnId - ConnAttemptIds: ConnAttemptId list - Room: RoomId option } +type Player = + { Id: PlayerId + ConnectionAttemptIds: ConnectionAttemptId list + Room: {| Id: RoomId; PeerId: int |} option } -/// The connection info of a player -/// With its peer id that represent his id in the room -/// And the connection attempt id to connect to the player -/// Used when a player join a room and need to connect to the other players -type PlayerConnectionInfo = { PeerId: int; ConnAttemptId: ConnAttemptId } +/// Information needed to connect to a player +/// Used when a player join a room and need to connect to the other players +type PlayerConnectionInfo = { PeerId: int; ConnAttemptId: ConnectionAttemptId } type RoomConnectionInfo = - { PlayersConnInfo: PlayerConnectionInfo array + { PlayersConnectionInfo: PlayerConnectionInfo array FailedCreations: int array } -module Errors = - type StartConnectionAttemptError = - | PlayerConnectionNotFound = 0 - | FailedToCreateConnAttempt = 1 - | FailedToUpdatePlayerConnection = 2 - - type JoinConnectionAttemptError = - | PlayerConnectionNotFound = 0 - | OfferNotFound = 1 - | OfferAlreadyAnswered = 2 - | InitiatorCannotJoin = 3 - | FailedToUpdateOffer = 4 - - type SendAnswerError = - | PlayerConnectionNotFound = 0 - | OfferNotFound = 1 - | NotAnswerer = 2 - | FailedToTransmitAnswer = 3 - - type SendIceCandidateError = - | PlayerConnectionNotFound = 0 - | OfferNotFound = 1 - | NotAnswerer = 2 - | NotParticipant = 3 - | FailedToTransmitCandidate = 4 - - type EndConnectionAttemptError = - | PlayerConnectionNotFound = 0 - | OfferNotFound = 1 - | NotParticipant = 2 - | FailedToRemoveOffer = 3 - - type CreateRoomError = - | PlayerConnectionNotFound = 0 - | PlayerAlreadyInARoom = 1 - | FailedToRegisterRoom = 2 - | FailedToUpdatePlayerConnection = 3 - - type JoinRoomError = - | PlayerConnectionNotFound = 0 - | PlayerAlreadyInARoom = 1 - | RoomNotFound = 2 - | FailedToUpdateRoom = 3 - | FailedToUpdatePlayerConnection = 4 - - type ConnectToRoomPlayersError = - | PlayerConnectionNotFound = 0 - | NotInARoom = 1 - | PlayerNotInRoomPlayers = 2 - - type LeaveRoomError = - | PlayerConnectionNotFound = 0 - | NotInARoom = 1 - | FailedToUpdateRoom = 2 - | FailedToRemoveRoom = 3 - | FailedToUpdatePlayerConnection = 4 - -open Errors +open Behide.OnlineServices.Signaling.Errors // Members with several parameters should have their parameters named // Otherwise, the library TypedSignalR.Client generate invalid C# code type ISignalingHub = - abstract member StartConnectionAttempt : SdpDescription -> Task> + abstract member StartConnectionAttempt : SdpDescription -> Task> /// Returns the offer sdp desc and allow to send the answer - abstract member JoinConnectionAttempt : ConnAttemptId -> Task> - abstract member SendAnswer : ConnAttemptId -> answer: SdpDescription -> Task> - abstract member SendIceCandidate : ConnAttemptId -> iceCandidate: IceCandidate -> Task> - abstract member EndConnectionAttempt : ConnAttemptId -> Task> + abstract member JoinConnectionAttempt : ConnectionAttemptId -> Task> + abstract member SendAnswer : ConnectionAttemptId -> answer: SdpDescription -> Task> + abstract member SendIceCandidate : ConnectionAttemptId -> iceCandidate: IceCandidate -> Task> + abstract member EndConnectionAttempt : ConnectionAttemptId -> Task> abstract member CreateRoom : unit -> Task> /// Return the peerId of the player in the room @@ -187,6 +121,6 @@ type ISignalingHub = abstract member LeaveRoom : unit -> Task> type ISignalingClient = - abstract member ConnectionRequested: applicantPeerId: int -> Task - abstract member SdpAnswerReceived: ConnAttemptId -> SdpDescription -> Task - abstract member IceCandidateReceived: ConnAttemptId -> IceCandidate -> Task + abstract member ConnectionRequested: applicantPeerId: int -> Task + abstract member SdpAnswerReceived: ConnectionAttemptId -> SdpDescription -> Task + abstract member IceCandidateReceived: ConnectionAttemptId -> IceCandidate -> Task diff --git a/src/Behide.OnlineServices.Types/SignalingErrors.fs b/src/Behide.OnlineServices.Types/SignalingErrors.fs new file mode 100644 index 0000000..28dd18d --- /dev/null +++ b/src/Behide.OnlineServices.Types/SignalingErrors.fs @@ -0,0 +1,56 @@ +namespace Behide.OnlineServices.Signaling.Errors + +type StartConnectionAttemptError = + | PlayerNotFound = 0 + | FailedToCreateConnectionAttempt = 1 + | FailedToUpdatePlayer = 2 + +type JoinConnectionAttemptError = + | PlayerNotFound = 0 + | ConnectionAttemptNotFound = 1 + | ConnectionAttemptAlreadyAnswered = 2 + | InitiatorCannotJoin = 3 + | FailedToUpdateConnectionAttempt = 4 + +type SendAnswerError = + | PlayerNotFound = 0 + | ConnectionAttemptNotFound = 1 + | NotAnswerer = 2 + | FailedToTransmitAnswer = 3 + +type SendIceCandidateError = + | PlayerNotFound = 0 + | ConnectionAttemptNotFound = 1 + | NoAnswerer = 2 + | NotParticipant = 3 + | FailedToTransmitCandidate = 4 + +type EndConnectionAttemptError = + | PlayerNotFound = 0 + | ConnectionAttemptNotFound = 1 + | NotParticipant = 2 + | FailedToRemoveConnectionAttempt = 3 + +type CreateRoomError = + | PlayerNotFound = 0 + | PlayerAlreadyInARoom = 1 + | FailedToRegisterRoom = 2 + | FailedToUpdatePlayer = 3 + +type JoinRoomError = + | PlayerNotFound = 0 + | PlayerAlreadyInARoom = 1 + | RoomNotFound = 2 + | FailedToUpdatePlayer = 4 + +type ConnectToRoomPlayersError = + | PlayerNotFound = 0 + | NotInARoom = 1 + | PlayerNotInRoomPlayers = 2 + +type LeaveRoomError = + | PlayerNotFound = 0 + | NotInARoom = 1 + | FailedToUpdateRoom = 2 + | FailedToRemoveRoom = 3 + | FailedToUpdatePlayer = 4 diff --git a/src/Behide.OnlineServices/Hubs/Signaling/Common.fs b/src/Behide.OnlineServices/Hubs/Signaling/Common.fs index 53f566b..a9df69c 100644 --- a/src/Behide.OnlineServices/Hubs/Signaling/Common.fs +++ b/src/Behide.OnlineServices/Hubs/Signaling/Common.fs @@ -4,12 +4,12 @@ open Behide.OnlineServices open Behide.OnlineServices.Signaling /// Store of player states in the signaling process -type IPlayerConnectionStore = Store.IStore -type PlayerConnectionStore = Store.Store +type IPlayerStore = Store.IStore +type PlayerStore = Store.Store /// WebRTC connection attempts store -type IConnAttemptStore = Store.IStore -type ConnAttemptStore = Store.Store +type IConnectionAttemptStore = Store.IStore +type ConnectionAttemptStore = Store.Store type IRoomStore = Store.IStore type RoomStore = Store.Store diff --git a/src/Behide.OnlineServices/Hubs/Signaling/RoomManagement.fs b/src/Behide.OnlineServices/Hubs/Signaling/RoomManagement.fs index 83ad1dd..d47345f 100644 --- a/src/Behide.OnlineServices/Hubs/Signaling/RoomManagement.fs +++ b/src/Behide.OnlineServices/Hubs/Signaling/RoomManagement.fs @@ -12,23 +12,23 @@ open System.Threading.Tasks open FsToolkit.ErrorHandling -let createRoom (hub: Hub) (playerConnectionStore: IPlayerConnectionStore) (_connAttemptStore: IConnAttemptStore) (roomStore: IRoomStore) = +let createRoom (hub: Hub) (playerStore: IPlayerStore) (_connectionAttemptStore: IConnectionAttemptStore) (roomStore: IRoomStore) = taskResult { - let playerConnId = hub.Context.ConnectionId |> ConnId.parse + let playerId = hub.Context.ConnectionId |> PlayerId.fromHubConnectionId - let! playerConn = - playerConnId - |> playerConnectionStore.Get - |> Result.ofOption CreateRoomError.PlayerConnectionNotFound + let! player = + playerId + |> playerStore.Get + |> Result.ofOption CreateRoomError.PlayerNotFound // Check if player is already in a room - do! playerConn.Room |> Result.requireNone CreateRoomError.PlayerAlreadyInARoom + do! player.Room |> Result.requireNone CreateRoomError.PlayerAlreadyInARoom // Create room let room = { Id = RoomId.create () - Initiator = playerConnId - Players = [ KeyValuePair(playerConnId, 1) ] |> Dictionary // Host peer id should always be 1 + Initiator = playerId + Players = [ KeyValuePair(playerId, 1) ] |> Dictionary // Host peer id should always be 1 Connections = HashSet() ConnectionsInProgress = HashSet() Semaphore = new SemaphoreSlim(1, 1) } @@ -38,33 +38,29 @@ let createRoom (hub: Hub) (playerConnectionStore: IPlayerConnectionStore) (_conn room |> Result.requireTrue CreateRoomError.FailedToRegisterRoom - // Update player connection - let newPlayerConn = { playerConn with Room = Some room.Id } + // Update player + let newPlayer = { player with Room = Some {| Id = room.Id; PeerId = 1 |} } - do! playerConnectionStore.Update - playerConnId - playerConn - newPlayerConn - |> Result.requireTrue CreateRoomError.FailedToUpdatePlayerConnection + do! playerStore.Update playerId player newPlayer + |> Result.requireTrue CreateRoomError.FailedToUpdatePlayer return room.Id } -let joinRoom (hub: Hub) (playerConnectionStore: IPlayerConnectionStore) (_connAttemptStore: IConnAttemptStore) (roomStore: IRoomStore) (roomId: RoomId) = +let joinRoom (hub: Hub) (playerStore: IPlayerStore) (_connectionAttemptStore: IConnectionAttemptStore) (roomStore: IRoomStore) (roomId: RoomId) = taskResult { - let playerConnId = hub.Context.ConnectionId |> ConnId.parse + let playerId = hub.Context.ConnectionId |> PlayerId.fromHubConnectionId - let! playerConn = - playerConnId - |> playerConnectionStore.Get - |> Result.ofOption JoinRoomError.PlayerConnectionNotFound + let! player = + playerId + |> playerStore.Get + |> Result.ofOption JoinRoomError.PlayerNotFound // Check if player is already in a room - do! playerConn.Room - |> Result.requireNone JoinRoomError.PlayerAlreadyInARoom + do! player.Room |> Result.requireNone JoinRoomError.PlayerAlreadyInARoom // Update room - let! newPeerId = lock roomStore (fun () -> taskResult { + let! newPeerId = lock roomStore (fun () -> taskResult { // TODO: Use semaphore let! room = roomId |> roomStore.Get @@ -77,56 +73,49 @@ let joinRoom (hub: Hub) (playerConnectionStore: IPlayerConnectionStore) (_connAt |> _.Value |> (+) 1 - room.Players.Add(playerConnId, newPeerId) - - // do! roomStore.Update - // roomId - // room - // newRoom - // |> Result.requireTrue JoinRoomError.FailedToUpdateRoom // TODO - + room.Players.Add(playerId, newPeerId) return newPeerId }) // Update player connection - let newPlayerConn = { playerConn with Room = Some roomId } + let newPlayerConn = { player with Room = Some {| Id = roomId; PeerId = newPeerId |} } - do! playerConnectionStore.Update - playerConnId - playerConn + do! playerStore.Update + playerId + player newPlayerConn - |> Result.requireTrue JoinRoomError.FailedToUpdatePlayerConnection + |> Result.requireTrue JoinRoomError.FailedToUpdatePlayer return newPeerId } -let private findPlayersToConnectTo (playerConnection: PlayerConnection) (room: Room) = +let private findPlayersToConnectTo (requestingPlayer: Player) (room: Room) = room.Players |> Array.ofSeq |> Array.filter (fun kv -> - let playerConnectionId = kv.Key + let playerId = kv.Key - let connectionToCheck = Connection.create playerConnection.Id playerConnectionId + let connectionToCheck = Pair.create requestingPlayer.Id playerId let alreadyConnected = room.Connections.Contains(connectionToCheck) || room.ConnectionsInProgress.Contains(connectionToCheck) - playerConnectionId <> playerConnection.Id && not alreadyConnected + playerId <> requestingPlayer.Id && not alreadyConnected ) -let setInProgressConnections playerConnection (playersToConnectTo: KeyValuePair array) room = +let setInProgressConnections player (playersToConnectTo: KeyValuePair array) room = playersToConnectTo |> Array.map (fun kv -> - let connection = Connection.create playerConnection.Id kv.Key + let connection = Pair.create player.Id kv.Key room.ConnectionsInProgress.Add connection |> ignore connection ) -let requestConnectionForPlayer (hub: Hub) playerConnection requestingPeerId (targetPeerId, targetConnId) = +let requestConnectionForPlayer (hub: Hub) player requestingPeerId targetPeerId targetPlayerId = taskResult { let! r = - targetConnId - |> ConnId.raw + targetPlayerId + |> PlayerId.raw |> hub.Clients.Client |> _.ConnectionRequested(requestingPeerId) |> _.WaitAsync(TimeSpan.FromSeconds 10.) @@ -140,43 +129,46 @@ let requestConnectionForPlayer (hub: Hub) playerConnection requestingPeerId (tar | null -> return! Error targetPeerId | connAttemptId -> return { PeerId = targetPeerId; ConnAttemptId = connAttemptId }, - Connection.create playerConnection.Id targetConnId + Pair.create player.Id targetPlayerId } -let connectToRoomPlayers (hub: Hub) (playerConnectionStore: IPlayerConnectionStore) (_connAttemptStore: IConnAttemptStore) (roomStore: IRoomStore) = +let connectToRoomPlayers (hub: Hub) (playerStore: IPlayerStore) (_connectionAttemptStore: IConnectionAttemptStore) (roomStore: IRoomStore) = taskResult { - let playerConnectionId = hub.Context.ConnectionId |> ConnId.parse + let playerId = hub.Context.ConnectionId |> PlayerId.fromHubConnectionId - let! playerConnection = - playerConnectionId - |> playerConnectionStore.Get - |> Result.ofOption ConnectToRoomPlayersError.PlayerConnectionNotFound + let! player = + playerId + |> playerStore.Get + |> Result.ofOption ConnectToRoomPlayersError.PlayerNotFound - let! room = - playerConnection.Room - |> Option.bind roomStore.Get + let! room, requestingPeerId = + player.Room + |> Option.bind (fun roomInfo -> + roomInfo.Id + |> roomStore.Get + |> Option.map (fun roomId -> roomId, roomInfo.PeerId) + ) |> Result.ofOption ConnectToRoomPlayersError.NotInARoom - do! room.Semaphore.WaitAsync() // Lock room - let! requestingPeerId = - match room.Players.TryGetValue playerConnection.Id with - | false, _ -> Error ConnectToRoomPlayersError.PlayerNotInRoomPlayers - | true, peerId -> Ok peerId + // Ensure player is in room players + do! room.Players.ContainsKey player.Id + |> Result.requireTrue ConnectToRoomPlayersError.PlayerNotInRoomPlayers - let playersToConnectTo = findPlayersToConnectTo playerConnection room - let inProgressConnections = setInProgressConnections playerConnection playersToConnectTo room + do! room.Semaphore.WaitAsync() // Lock room + let playersToConnectTo = findPlayersToConnectTo player room + let inProgressConnections = setInProgressConnections player playersToConnectTo room room.Semaphore.Release() |> ignore // Unlock room // Create connection attempts - let requestConnectionForPlayer = requestConnectionForPlayer hub playerConnection requestingPeerId + let requestConnectionForPlayer = requestConnectionForPlayer hub player requestingPeerId let! playersConnectionInfo = playersToConnectTo - |> Array.map (fun kv -> requestConnectionForPlayer (kv.Value, kv.Key)) + |> Array.map (fun kv -> requestConnectionForPlayer kv.Value kv.Key) |> Task.WhenAll // Build return value and update room connections do! room.Semaphore.WaitAsync() - let playersConnInfo, failed = + let playersConnectionInfo, failed = playersConnectionInfo |> Array.fold (fun (playersConnInfo, failed) playerConnectionInfo -> match playerConnectionInfo with @@ -197,28 +189,28 @@ let connectToRoomPlayers (hub: Hub) (playerConnectionStore: IPlayerConnectionSto room.Semaphore.Release() |> ignore - return { PlayersConnInfo = playersConnInfo |> List.toArray + return { PlayersConnectionInfo = playersConnectionInfo |> List.toArray FailedCreations = failed |> List.toArray } } -let leaveRoom (hub: Hub) (playerConnectionStore: IPlayerConnectionStore) (_connAttemptStore: IConnAttemptStore) (roomStore: IRoomStore) = +let leaveRoom (hub: Hub) (playerStore: IPlayerStore) (_connectionAttemptStore: IConnectionAttemptStore) (roomStore: IRoomStore) = taskResult { - let playerConnectionId = hub.Context.ConnectionId |> ConnId.parse + let playerId = hub.Context.ConnectionId |> PlayerId.fromHubConnectionId // Check if a player connection exists - let! playerConnection = - playerConnectionId - |> playerConnectionStore.Get - |> Result.ofOption LeaveRoomError.PlayerConnectionNotFound + let! player = + playerId + |> playerStore.Get + |> Result.ofOption LeaveRoomError.PlayerNotFound do! lock roomStore (fun _ -> taskResult { // Get player's room let! room = - playerConnection.Room - |> Option.bind roomStore.Get + player.Room + |> Option.bind (_.Id >> roomStore.Get) |> Result.ofOption LeaveRoomError.NotInARoom - match room.Players |> Seq.length with + match room.Players.Count with | 1 -> // If the player is the last one in the room, remove the room do! roomStore.Remove room.Id |> Result.requireTrue LeaveRoomError.FailedToRemoveRoom @@ -226,17 +218,17 @@ let leaveRoom (hub: Hub) (playerConnectionStore: IPlayerConnectionStore) (_connA | _ -> // Remove player connections and player from room room.Connections.RemoveWhere(fun connection -> - playerConnectionId |> Connection.playerIsPartOf connection + playerId |> Pair.isInPair connection ) |> ignore - do! room.Players.Remove(playerConnectionId) + do! room.Players.Remove(playerId) |> Result.requireTrue LeaveRoomError.FailedToUpdateRoom }) // Update player connection - do! playerConnectionStore.Update - playerConnectionId - playerConnection - { playerConnection with Room = None } - |> Result.requireTrue LeaveRoomError.FailedToUpdatePlayerConnection + do! playerStore.Update + playerId + player + { player with Room = None } + |> Result.requireTrue LeaveRoomError.FailedToUpdatePlayer } diff --git a/src/Behide.OnlineServices/Hubs/Signaling/Signaling.fs b/src/Behide.OnlineServices/Hubs/Signaling/Signaling.fs index 907daeb..419d7b0 100644 --- a/src/Behide.OnlineServices/Hubs/Signaling/Signaling.fs +++ b/src/Behide.OnlineServices/Hubs/Signaling/Signaling.fs @@ -6,23 +6,21 @@ open FsToolkit.ErrorHandling open Behide.OnlineServices open Behide.OnlineServices.Signaling -type SignalingHub(connAttemptStore: IConnAttemptStore, roomStore: IRoomStore, playerConnectionStore: IPlayerConnectionStore) = +type SignalingHub(connectionAttemptStore: IConnectionAttemptStore, roomStore: IRoomStore, playerStore: IPlayerStore) = inherit Hub() // Should interface ISignalingHub, but it makes the methods not callable from the client // --- Player Connection Management --- override hub.OnConnectedAsync() = taskResult { - let playerConnId = hub.Context.ConnectionId |> ConnId.parse + let playerId = hub.Context.ConnectionId |> PlayerId.fromHubConnectionId - let playerConn = - { Id = playerConnId - ConnAttemptIds = [] + let player = + { Id = playerId + ConnectionAttemptIds = List.empty Room = None } - do! playerConnectionStore.Add - playerConnId - playerConn + do! playerStore.Add playerId player |> Result.requireTrue "Failed to add player connection" } |> TaskResult.mapError (printfn "Error occurred while registering player: %s") @@ -31,16 +29,16 @@ type SignalingHub(connAttemptStore: IConnAttemptStore, roomStore: IRoomStore, pl override hub.OnDisconnectedAsync _exn = taskResult { - let playerConnId = hub.Context.ConnectionId |> ConnId.parse + let playerId = hub.Context.ConnectionId |> PlayerId.fromHubConnectionId - let! playerConn = - playerConnId - |> playerConnectionStore.Get + let! player = + playerId + |> playerStore.Get |> Result.ofOption "Player connection not found" // Remove player from room let! leaveRoomError = - match playerConn.Room with + match player.Room with | None -> None |> Task.singleton | Some _ -> hub.LeaveRoom() @@ -49,55 +47,51 @@ type SignalingHub(connAttemptStore: IConnAttemptStore, roomStore: IRoomStore, pl | Error _ -> Some "Failed to remove player from it's room") // Remove connection attempts - let removeConnAttemptsError = - playerConn.ConnAttemptIds - |> List.choose (fun connAttemptId -> - match connAttemptId |> connAttemptStore.Get with - | None -> None - | Some _ -> - match connAttemptId |> connAttemptStore.Remove with // TODO: Notify other player in the connection attempt - | true -> None - | false -> Some connAttemptId + let removeConnectionAttemptsError = + player.ConnectionAttemptIds + |> List.filter (fun connAttemptId -> + match connAttemptId |> connectionAttemptStore.Get with + | None -> false + | Some _ -> connAttemptId |> connectionAttemptStore.Remove // TODO: Notify other player in the connection attempt ) |> function | [] -> None - | failedConnAttempts -> - failedConnAttempts + | failedConnectionAttempts -> + failedConnectionAttempts |> sprintf "Failed to remove connection attempts: %A" |> Some // Remove player connection let removePlayerConnectionError = - playerConnectionStore.Remove playerConnId - |> Result.requireTrue "Failed to remove player connection" + playerStore.Remove playerId + |> Result.requireTrue "Failed to remove player" |> function | Ok _ -> None | Error error -> Some error return! - match leaveRoomError, removeConnAttemptsError, removePlayerConnectionError with + match leaveRoomError, removeConnectionAttemptsError, removePlayerConnectionError with | None, None, None -> Ok () | _ -> - sprintf - "\nLeave room error: %s\nRemove connection attempt error: %s\nRemove player connection error: %s" + Error <| sprintf + "\nLeave room error: %s\nRemove connection attempt error: %s\nRemove player error: %s" (leaveRoomError |> Option.defaultValue "None") - (removeConnAttemptsError |> Option.defaultValue "None") + (removeConnectionAttemptsError |> Option.defaultValue "None") (removePlayerConnectionError |> Option.defaultValue "None") - |> Error } |> TaskResult.mapError (printfn "Error occurred while deregistering player: %s") |> Task.map ignore :> Task // --- WebRTC Signaling --- - member hub.StartConnectionAttempt (sdpDescription: SdpDescription) = WebRTCSignaling.startConnectionAttempt hub playerConnectionStore connAttemptStore sdpDescription - member hub.JoinConnectionAttempt (connAttemptId: ConnAttemptId) = WebRTCSignaling.joinConnectionAttempt hub playerConnectionStore connAttemptStore connAttemptId - member hub.SendAnswer (connAttemptId: ConnAttemptId) (answer: SdpDescription) = WebRTCSignaling.sendAnswer hub playerConnectionStore connAttemptStore connAttemptId answer - member hub.SendIceCandidate (connAttemptId: ConnAttemptId) (iceCandidate: IceCandidate) = WebRTCSignaling.sendIceCandidate hub playerConnectionStore connAttemptStore connAttemptId iceCandidate - member hub.EndConnectionAttempt (connAttemptId: ConnAttemptId) = WebRTCSignaling.endConnectionAttempt hub playerConnectionStore connAttemptStore connAttemptId + member hub.StartConnectionAttempt (sdpDescription: SdpDescription) = WebRTCSignaling.startConnectionAttempt hub playerStore connectionAttemptStore sdpDescription + member hub.JoinConnectionAttempt (connAttemptId: ConnectionAttemptId) = WebRTCSignaling.joinConnectionAttempt hub playerStore connectionAttemptStore connAttemptId + member hub.SendAnswer (connAttemptId: ConnectionAttemptId) (answer: SdpDescription) = WebRTCSignaling.sendAnswer hub playerStore connectionAttemptStore connAttemptId answer + member hub.SendIceCandidate (connAttemptId: ConnectionAttemptId) (iceCandidate: IceCandidate) = WebRTCSignaling.sendIceCandidate hub playerStore connectionAttemptStore connAttemptId iceCandidate + member hub.EndConnectionAttempt (connAttemptId: ConnectionAttemptId) = WebRTCSignaling.endConnectionAttempt hub playerStore connectionAttemptStore connAttemptId // --- Rooms --- - member hub.CreateRoom() = RoomManagement.createRoom hub playerConnectionStore connAttemptStore roomStore - member hub.JoinRoom (roomId: RoomId) = RoomManagement.joinRoom hub playerConnectionStore connAttemptStore roomStore roomId - member hub.ConnectToRoomPlayers() = RoomManagement.connectToRoomPlayers hub playerConnectionStore connAttemptStore roomStore - member hub.LeaveRoom() = RoomManagement.leaveRoom hub playerConnectionStore connAttemptStore roomStore + member hub.CreateRoom() = RoomManagement.createRoom hub playerStore connectionAttemptStore roomStore + member hub.JoinRoom (roomId: RoomId) = RoomManagement.joinRoom hub playerStore connectionAttemptStore roomStore roomId + member hub.ConnectToRoomPlayers() = RoomManagement.connectToRoomPlayers hub playerStore connectionAttemptStore roomStore + member hub.LeaveRoom() = RoomManagement.leaveRoom hub playerStore connectionAttemptStore roomStore diff --git a/src/Behide.OnlineServices/Hubs/Signaling/WebRTCSignaling.fs b/src/Behide.OnlineServices/Hubs/Signaling/WebRTCSignaling.fs index 9a0d351..4d98f00 100644 --- a/src/Behide.OnlineServices/Hubs/Signaling/WebRTCSignaling.fs +++ b/src/Behide.OnlineServices/Hubs/Signaling/WebRTCSignaling.fs @@ -3,167 +3,165 @@ open Behide.OnlineServices open Behide.OnlineServices.Signaling open Behide.OnlineServices.Signaling.Errors -type Hub = Microsoft.AspNetCore.SignalR.Hub - open FsToolkit.ErrorHandling -let startConnectionAttempt (hub: Hub) (playerConnectionStore: IPlayerConnectionStore) (connAttemptStore: IConnAttemptStore) (sdpDescription: SdpDescription) = +type Hub = Microsoft.AspNetCore.SignalR.Hub + +let startConnectionAttempt (hub: Hub) (playerStore: IPlayerStore) (connectionAttemptStore: IConnectionAttemptStore) (offer: SdpDescription) = taskResult { - let playerConnId = hub.Context.ConnectionId |> ConnId.parse + let playerId = hub.Context.ConnectionId |> PlayerId.fromHubConnectionId - let! playerConn = - playerConnId - |> playerConnectionStore.Get - |> Result.ofOption StartConnectionAttemptError.PlayerConnectionNotFound + let! player = + playerId + |> playerStore.Get + |> Result.ofOption StartConnectionAttemptError.PlayerNotFound // Create connection attempt - let connAttempt = - { Id = ConnAttemptId.create () - InitiatorConnectionId = playerConnId - SdpDescription = sdpDescription + let connectionAttempt = + { Id = ConnectionAttemptId.create () + InitiatorConnectionId = playerId + Offer = offer Answerer = None } - do! connAttemptStore.Add - connAttempt.Id - connAttempt - |> Result.requireTrue StartConnectionAttemptError.FailedToCreateConnAttempt + do! connectionAttemptStore.Add connectionAttempt.Id connectionAttempt + |> Result.requireTrue StartConnectionAttemptError.FailedToCreateConnectionAttempt // Update player connection - let newPlayerConn = - { playerConn with ConnAttemptIds = connAttempt.Id :: playerConn.ConnAttemptIds } + let newPlayer = + { player with ConnectionAttemptIds = connectionAttempt.Id :: player.ConnectionAttemptIds } - do! playerConnectionStore.Update - playerConnId - playerConn - newPlayerConn - |> Result.requireTrue StartConnectionAttemptError.FailedToUpdatePlayerConnection + do! playerStore.Update + playerId + player + newPlayer + |> Result.requireTrue StartConnectionAttemptError.FailedToUpdatePlayer - return connAttempt.Id + return connectionAttempt.Id } /// Returns the offer sdp desc and allow to send the answer -let joinConnectionAttempt (hub: Hub) (playerConnectionStore: IPlayerConnectionStore) (connAttemptStore: IConnAttemptStore) (connAttemptId: ConnAttemptId) = +let joinConnectionAttempt (hub: Hub) (playerStore: IPlayerStore) (connectionAttemptStore: IConnectionAttemptStore) (connectionAttemptId: ConnectionAttemptId) = taskResult { - let connId = hub.Context.ConnectionId |> ConnId.parse + let playerId = hub.Context.ConnectionId |> PlayerId.fromHubConnectionId - // Check if client has a player connection - do! connId - |> playerConnectionStore.Get - |> Result.ofOption JoinConnectionAttemptError.PlayerConnectionNotFound + // Check if player exists + do! playerId + |> playerStore.Get + |> Result.ofOption JoinConnectionAttemptError.PlayerNotFound |> Result.ignore // Retrieve connection attempt - let! connAttempt = - connAttemptId - |> connAttemptStore.Get - |> Result.ofOption JoinConnectionAttemptError.OfferNotFound + let! connectionAttempt = + connectionAttemptId + |> connectionAttemptStore.Get + |> Result.ofOption JoinConnectionAttemptError.ConnectionAttemptNotFound // Check if connection attempt has not been answered - do! connAttempt.Answerer - |> Result.requireNone JoinConnectionAttemptError.OfferAlreadyAnswered + do! connectionAttempt.Answerer + |> Result.requireNone JoinConnectionAttemptError.ConnectionAttemptAlreadyAnswered // Check if the answerer is not the initiator - do! connAttempt.InitiatorConnectionId <> connId + do! connectionAttempt.InitiatorConnectionId <> playerId |> Result.requireTrue JoinConnectionAttemptError.InitiatorCannotJoin // Update connection attempt - do! connAttemptStore.Update - connAttemptId - connAttempt - { connAttempt with Answerer = Some connId } - |> Result.requireTrue JoinConnectionAttemptError.FailedToUpdateOffer + do! connectionAttemptStore.Update + connectionAttempt.Id + connectionAttempt + { connectionAttempt with Answerer = Some playerId } + |> Result.requireTrue JoinConnectionAttemptError.FailedToUpdateConnectionAttempt - return connAttempt.SdpDescription + return connectionAttempt.Offer } -let sendAnswer (hub: Hub) (playerConnectionStore: IPlayerConnectionStore) (connAttemptStore: IConnAttemptStore) (connAttemptId: ConnAttemptId) (sdpDescription: SdpDescription) = +let sendAnswer (hub: Hub) (playerStore: IPlayerStore) (connectionAttemptStore: IConnectionAttemptStore) (connectionAttemptId: ConnectionAttemptId) (sdpDescription: SdpDescription) = taskResult { - let connId = hub.Context.ConnectionId |> ConnId.parse + let playerId = hub.Context.ConnectionId |> PlayerId.fromHubConnectionId - // Check if client has a player connection - do! connId - |> playerConnectionStore.Get - |> Result.ofOption SendAnswerError.PlayerConnectionNotFound + // Check if player exists + do! playerId + |> playerStore.Get + |> Result.ofOption SendAnswerError.PlayerNotFound |> Result.ignore // Retrieve connection attempt - let! connAttempt = - connAttemptId - |> connAttemptStore.Get - |> Result.ofOption SendAnswerError.OfferNotFound + let! connectionAttempt = + connectionAttemptId + |> connectionAttemptStore.Get + |> Result.ofOption SendAnswerError.ConnectionAttemptNotFound - // Get answerer - let! answerer = connAttempt.Answerer |> Result.ofOption SendAnswerError.NotAnswerer // Check if the client is the answerer - do! connId = answerer |> Result.requireTrue SendAnswerError.NotAnswerer + do! connectionAttempt.Answerer + |> Result.ofOption SendAnswerError.NotAnswerer + |> Result.bind (fun pId -> Result.requireEqual pId playerId SendAnswerError.NotAnswerer) // Send answer to initiator try - do! hub.Clients.Client(connAttempt.InitiatorConnectionId |> ConnId.raw).SdpAnswerReceived connAttemptId sdpDescription + do! hub.Clients.Client(connectionAttempt.InitiatorConnectionId |> PlayerId.raw).SdpAnswerReceived connectionAttemptId sdpDescription with _ -> return! Error SendAnswerError.FailedToTransmitAnswer } -let sendIceCandidate (hub: Hub) (playerConnectionStore: IPlayerConnectionStore) (connAttemptStore: IConnAttemptStore) (connAttemptId: ConnAttemptId) (iceCandidate: IceCandidate) = +let sendIceCandidate (hub: Hub) (playerStore: IPlayerStore) (connectionAttemptStore: IConnectionAttemptStore) (connectionAttemptId: ConnectionAttemptId) (iceCandidate: IceCandidate) = taskResult { - let connId = hub.Context.ConnectionId |> ConnId.parse + let playerId = hub.Context.ConnectionId |> PlayerId.fromHubConnectionId - // Check if client has a player connection - do! connId - |> playerConnectionStore.Get - |> Result.ofOption SendIceCandidateError.PlayerConnectionNotFound + // Check if player exists + do! playerId + |> playerStore.Get + |> Result.ofOption SendIceCandidateError.PlayerNotFound |> Result.ignore - let! connAttempt = - connAttemptId - |> connAttemptStore.Get - |> Result.ofOption SendIceCandidateError.OfferNotFound + let! connectionAttempt = + connectionAttemptId + |> connectionAttemptStore.Get + |> Result.ofOption SendIceCandidateError.ConnectionAttemptNotFound let! answerer = - connAttempt.Answerer - |> Result.ofOption SendIceCandidateError.NotAnswerer + connectionAttempt.Answerer + |> Result.ofOption SendIceCandidateError.NoAnswerer - // Check if the client is in the connection attempt - do! (connId = connAttempt.InitiatorConnectionId || connId = answerer) + // Check if the player is in the connection attempt + do! (playerId = connectionAttempt.InitiatorConnectionId || playerId = answerer) |> Result.requireTrue SendIceCandidateError.NotParticipant - // Determine the target connection id - let targetConnId = - match connId = answerer with - | true -> connAttempt.InitiatorConnectionId + // Determine the target player + let targetPlayerId = + match playerId = answerer with + | true -> connectionAttempt.InitiatorConnectionId | false -> answerer - // Send ice candidate to other peer + // Send ice candidate to other player try - do! hub.Clients.Client(targetConnId |> ConnId.raw).IceCandidateReceived connAttemptId iceCandidate + do! hub.Clients.Client(targetPlayerId |> PlayerId.raw).IceCandidateReceived connectionAttemptId iceCandidate with _ -> return! Error SendIceCandidateError.FailedToTransmitCandidate } -let endConnectionAttempt (hub: Hub) (playerConnectionStore: IPlayerConnectionStore) (connAttemptStore: IConnAttemptStore) (connAttemptId: ConnAttemptId) = +let endConnectionAttempt (hub: Hub) (playerStore: IPlayerStore) (connectionAttemptStore: IConnectionAttemptStore) (connectionAttemptId: ConnectionAttemptId) = taskResult { - let connId = hub.Context.ConnectionId |> ConnId.parse + let playerId = hub.Context.ConnectionId |> PlayerId.fromHubConnectionId - // Check if client has a player connection - do! connId - |> playerConnectionStore.Get - |> Result.ofOption EndConnectionAttemptError.PlayerConnectionNotFound + // Check if player exists + do! playerId + |> playerStore.Get + |> Result.ofOption EndConnectionAttemptError.PlayerNotFound |> Result.ignore - let! connAttempt = - connAttemptId - |> connAttemptStore.Get - |> Result.ofOption EndConnectionAttemptError.OfferNotFound + let! connectionAttempt = + connectionAttemptId + |> connectionAttemptStore.Get + |> Result.ofOption EndConnectionAttemptError.ConnectionAttemptNotFound - // Check if the client is in the connection attempt - match connId = connAttempt.InitiatorConnectionId with + // Check if the player is in the connection attempt + match playerId = connectionAttempt.InitiatorConnectionId with | true -> () | false -> - do! connAttempt.Answerer + do! connectionAttempt.Answerer |> Result.ofOption EndConnectionAttemptError.NotParticipant - |> Result.bind ((=) connId >> Result.requireTrue EndConnectionAttemptError.NotParticipant) + |> Result.bind ((=) playerId >> Result.requireTrue EndConnectionAttemptError.NotParticipant) // Remove connection attempt - do! connAttemptStore.Remove connAttemptId - |> Result.requireTrue EndConnectionAttemptError.FailedToRemoveOffer + do! connectionAttemptStore.Remove connectionAttemptId + |> Result.requireTrue EndConnectionAttemptError.FailedToRemoveConnectionAttempt } diff --git a/src/Behide.OnlineServices/Program.fs b/src/Behide.OnlineServices/Program.fs index 91f179c..b200594 100644 --- a/src/Behide.OnlineServices/Program.fs +++ b/src/Behide.OnlineServices/Program.fs @@ -18,9 +18,12 @@ let configureServices (services: IServiceCollection) = .AddToJsonSerializerOptions(options.PayloadSerializerOptions) ) |> ignore - services.AddSingleton() |> ignore + services + +let configureSingletons (services: IServiceCollection) = + services.AddSingleton() |> ignore services.AddSingleton() |> ignore - services.AddSingleton() |> ignore + services.AddSingleton() |> ignore services let appEndpoints = @@ -30,7 +33,7 @@ let appEndpoints = let main args = let wapp = WebApplication.CreateBuilder(args) - .AddServices(fun _ -> configureServices) + .AddServices(fun _ -> configureServices >> configureSingletons) .Build() wapp.UseRouting() From ea9c3ee9e7de5a6c82c37c169ac960bcd5075b81 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ga=C3=ABl=20Bouquain?= Date: Tue, 8 Jul 2025 15:52:50 +0200 Subject: [PATCH 05/10] Replaced locks with semaphore --- .../Hubs/Signaling/RoomManagement.fs | 73 +++++++++---------- 1 file changed, 34 insertions(+), 39 deletions(-) diff --git a/src/Behide.OnlineServices/Hubs/Signaling/RoomManagement.fs b/src/Behide.OnlineServices/Hubs/Signaling/RoomManagement.fs index d47345f..719ebdc 100644 --- a/src/Behide.OnlineServices/Hubs/Signaling/RoomManagement.fs +++ b/src/Behide.OnlineServices/Hubs/Signaling/RoomManagement.fs @@ -47,7 +47,7 @@ let createRoom (hub: Hub) (playerStore: IPlayerStore) (_connectionAttemptStore: return room.Id } -let joinRoom (hub: Hub) (playerStore: IPlayerStore) (_connectionAttemptStore: IConnectionAttemptStore) (roomStore: IRoomStore) (roomId: RoomId) = +let joinRoom (hub: Hub) (playerStore: IPlayerStore) (_connectionAttemptStore: IConnectionAttemptStore) (roomStore: IRoomStore) (roomId: RoomId) = // TODO: Add concurrency test taskResult { let playerId = hub.Context.ConnectionId |> PlayerId.fromHubConnectionId @@ -60,22 +60,19 @@ let joinRoom (hub: Hub) (playerStore: IPlayerStore) (_connectionAttemptStore: IC do! player.Room |> Result.requireNone JoinRoomError.PlayerAlreadyInARoom // Update room - let! newPeerId = lock roomStore (fun () -> taskResult { // TODO: Use semaphore - let! room = - roomId - |> roomStore.Get - |> Result.ofOption JoinRoomError.RoomNotFound - - // Update room - let newPeerId = - room.Players - |> Seq.maxBy _.Value // Max by peerId - |> _.Value - |> (+) 1 - - room.Players.Add(playerId, newPeerId) - return newPeerId - }) + let! room = + roomId + |> roomStore.Get + |> Result.ofOption JoinRoomError.RoomNotFound + do! room.Semaphore.WaitAsync() // Lock to prevent several players having the same peerId + let newPeerId = + room.Players + |> Seq.maxBy _.Value // Max by peerId + |> _.Value + |> (+) 1 + + room.Players.Add(playerId, newPeerId) + room.Semaphore.Release() |> ignore // Update player connection let newPlayerConn = { player with Room = Some {| Id = roomId; PeerId = newPeerId |} } @@ -193,7 +190,7 @@ let connectToRoomPlayers (hub: Hub) (playerStore: IPlayerStore) (_connectionAtte FailedCreations = failed |> List.toArray } } -let leaveRoom (hub: Hub) (playerStore: IPlayerStore) (_connectionAttemptStore: IConnectionAttemptStore) (roomStore: IRoomStore) = +let leaveRoom (hub: Hub) (playerStore: IPlayerStore) (_connectionAttemptStore: IConnectionAttemptStore) (roomStore: IRoomStore) = // TODO: Add tests (ex: Leave while another is connecting) taskResult { let playerId = hub.Context.ConnectionId |> PlayerId.fromHubConnectionId @@ -203,27 +200,25 @@ let leaveRoom (hub: Hub) (playerStore: IPlayerStore) (_connectionAttemptStore: I |> playerStore.Get |> Result.ofOption LeaveRoomError.PlayerNotFound - do! lock roomStore (fun _ -> taskResult { - // Get player's room - let! room = - player.Room - |> Option.bind (_.Id >> roomStore.Get) - |> Result.ofOption LeaveRoomError.NotInARoom - - match room.Players.Count with - | 1 -> // If the player is the last one in the room, remove the room - do! roomStore.Remove room.Id - |> Result.requireTrue LeaveRoomError.FailedToRemoveRoom - - | _ -> - // Remove player connections and player from room - room.Connections.RemoveWhere(fun connection -> - playerId |> Pair.isInPair connection - ) |> ignore - - do! room.Players.Remove(playerId) - |> Result.requireTrue LeaveRoomError.FailedToUpdateRoom - }) + let! room = + player.Room + |> Option.bind (_.Id >> roomStore.Get) + |> Result.ofOption LeaveRoomError.NotInARoom + do! room.Semaphore.WaitAsync() + match room.Players.Count with + | 1 -> // If the player is the last one in the room, remove the room + do! roomStore.Remove room.Id + |> Result.requireTrue LeaveRoomError.FailedToRemoveRoom + + | _ -> + // Remove player connections and player from room + room.Connections.RemoveWhere(fun connection -> + playerId |> Pair.isInPair connection + ) |> ignore + + do! room.Players.Remove(playerId) + |> Result.requireTrue LeaveRoomError.FailedToUpdateRoom + room.Semaphore.Release() |> ignore // Update player connection do! playerStore.Update From 369237127e0831410a512b985aae97d5d5f94a7f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ga=C3=ABl=20Bouquain?= Date: Tue, 8 Jul 2025 16:14:52 +0200 Subject: [PATCH 06/10] Add types tests --- .../Behide.OnlineServices.Tests.fsproj | 2 + .../Tests/Types.fs | 56 +++++++++++++++++++ .../Behide.OnlineServices.Types.fsproj | 1 + src/Behide.OnlineServices.Types/Signaling.fs | 18 ++---- src/Behide.OnlineServices.Types/Types.fs | 12 ++++ 5 files changed, 76 insertions(+), 13 deletions(-) create mode 100644 src/Behide.OnlineServices.Tests/Tests/Types.fs create mode 100644 src/Behide.OnlineServices.Types/Types.fs diff --git a/src/Behide.OnlineServices.Tests/Behide.OnlineServices.Tests.fsproj b/src/Behide.OnlineServices.Tests/Behide.OnlineServices.Tests.fsproj index a426963..27cf5f8 100644 --- a/src/Behide.OnlineServices.Tests/Behide.OnlineServices.Tests.fsproj +++ b/src/Behide.OnlineServices.Tests/Behide.OnlineServices.Tests.fsproj @@ -10,6 +10,8 @@ + + diff --git a/src/Behide.OnlineServices.Tests/Tests/Types.fs b/src/Behide.OnlineServices.Tests/Tests/Types.fs new file mode 100644 index 0000000..35a94f1 --- /dev/null +++ b/src/Behide.OnlineServices.Tests/Tests/Types.fs @@ -0,0 +1,56 @@ +module Behide.OnlineServices.Tests.Types + +open Behide.OnlineServices +open Expecto + +[] +let tests = + testList "Types" [ + testList "Pair" [ + testCase "Test pairs are unordered" (fun () -> + let stringPair1 = Pair.create "a" "b" + let stringPair2 = Pair.create "b" "a" + + let intPair1 = Pair.create 69 42 + let intPair2 = Pair.create 42 69 + + Expect.equal stringPair1 stringPair2 "Pairs should equal" + Expect.equal intPair1 intPair2 "Pairs should equal" + ) + + testCase "Test isInPair works" (fun () -> + let stringPair = Pair.create "a" "b" + Expect.isTrue ("a" |> Pair.isInPair stringPair) "Pair should contain \"a\"" + Expect.isTrue ("b" |> Pair.isInPair stringPair) "Pair should contain \"b\"" + + let intPair = Pair.create 69 42 + Expect.isTrue (69 |> Pair.isInPair intPair) "Pair should contain 69" + Expect.isTrue (42 |> Pair.isInPair intPair) "Pair should contain 42" + ) + ] + + testList "Signaling" [ + testList "RoomId" [ + testTheory "correct parsing" + [ "abcd" + "ABCD" + "0123" + "a1b3" + "A1b3" + "A1B3" ] + (Signaling.RoomId.tryParse >> Flip.Expect.isSome "Room id should be parsable") + + testTheory "incorrect room id not parsable" + [ "abc" + "abç" + "abçd" + "ABÇD" + "^123" + "ä1b3" + "A1ü3" + "Ü1Ö3" + "01234" ] + (Signaling.RoomId.tryParse >> Flip.Expect.isNone "Room id should not be parsable") + ] + ] + ] diff --git a/src/Behide.OnlineServices.Types/Behide.OnlineServices.Types.fsproj b/src/Behide.OnlineServices.Types/Behide.OnlineServices.Types.fsproj index a1f2d85..24829c2 100644 --- a/src/Behide.OnlineServices.Types/Behide.OnlineServices.Types.fsproj +++ b/src/Behide.OnlineServices.Types/Behide.OnlineServices.Types.fsproj @@ -4,6 +4,7 @@ net9.0 + diff --git a/src/Behide.OnlineServices.Types/Signaling.fs b/src/Behide.OnlineServices.Types/Signaling.fs index 79c2b2a..4aab151 100644 --- a/src/Behide.OnlineServices.Types/Signaling.fs +++ b/src/Behide.OnlineServices.Types/Signaling.fs @@ -3,16 +3,7 @@ namespace Behide.OnlineServices.Signaling open System open System.Collections.Generic open System.Threading.Tasks - -type Pair<'T when 'T: comparison> = // TODO: Add tests - { First: 'T; Second: 'T } - -module Pair = - let create first second = - { First = min first second - Second = max first second } - - let isInPair pair value = pair.First = value || pair.Second = value +open Behide.OnlineServices type SdpDescription = { ``type``: string @@ -65,13 +56,14 @@ type RoomId = |> RoomId static member tryParse (str: string) = + let loweredStr = str.ToLowerInvariant() let validStr = - str.Length = 4 - && str |> Seq.forall (fun char -> Array.contains char chars) + loweredStr.Length = 4 + && loweredStr |> Seq.forall (fun char -> Array.contains char chars) match validStr with | false -> None - | true -> RoomId str |> Some + | true -> RoomId loweredStr |> Some /// A room, also a group of players that are connected to each other type Room = diff --git a/src/Behide.OnlineServices.Types/Types.fs b/src/Behide.OnlineServices.Types/Types.fs new file mode 100644 index 0000000..a1b47c7 --- /dev/null +++ b/src/Behide.OnlineServices.Types/Types.fs @@ -0,0 +1,12 @@ +namespace Behide.OnlineServices + +/// Unordered pair +type Pair<'T when 'T: comparison> = + { First: 'T; Second: 'T } + +module Pair = + let create first second = + { First = min first second + Second = max first second } + + let isInPair pair value = pair.First = value || pair.Second = value From 323bdfa16d3ece2c1273d8027dcd19ff8a3dd025 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ga=C3=ABl=20Bouquain?= Date: Tue, 8 Jul 2025 16:45:29 +0200 Subject: [PATCH 07/10] Add concurrency test for joining a room --- .../Tests/Signaling/Common.fs | 21 ++++- .../Tests/Signaling/RoomManagement.fs | 89 ++++++++++++++----- .../Tests/Signaling/WebRTCSignaling.fs | 76 ++++++++-------- .../Tests/Types.fs | 10 ++- .../Hubs/Signaling/RoomManagement.fs | 2 +- 5 files changed, 129 insertions(+), 69 deletions(-) diff --git a/src/Behide.OnlineServices.Tests/Tests/Signaling/Common.fs b/src/Behide.OnlineServices.Tests/Tests/Signaling/Common.fs index a20ff06..90b8699 100644 --- a/src/Behide.OnlineServices.Tests/Tests/Signaling/Common.fs +++ b/src/Behide.OnlineServices.Tests/Tests/Signaling/Common.fs @@ -19,13 +19,26 @@ type SignalingHub(connection: HubConnection) = member _.SendIceCandidate offerId iceCandidate = connection.InvokeAsync<_>("SendIceCandidate", offerId, iceCandidate) member _.EndConnectionAttempt offerId = connection.InvokeAsync<_>("EndConnectionAttempt", offerId) - member _.CreateRoom () = connection.InvokeAsync<_>("CreateRoom") + member _.CreateRoom() = connection.InvokeAsync<_>("CreateRoom") member _.JoinRoom roomId = connection.InvokeAsync<_>("JoinRoom", roomId) - member _.LeaveRoom () = connection.InvokeAsync<_>("LeaveRoom") + member _.LeaveRoom() = connection.InvokeAsync<_>("LeaveRoom") member _.ConnectToRoomPlayers() = connection.InvokeAsync<_>("ConnectToRoomPlayers") + member this.StartConnectionAttempt sdpDescription = (this :> ISignalingHub).StartConnectionAttempt sdpDescription + member this.JoinConnectionAttempt offerId = (this :> ISignalingHub).JoinConnectionAttempt offerId + member this.SendAnswer offerId iceCandidate = (this :> ISignalingHub).SendAnswer offerId iceCandidate + member this.SendIceCandidate offerId iceCandidate = (this :> ISignalingHub).SendIceCandidate offerId iceCandidate + member this.EndConnectionAttempt offerId = (this :> ISignalingHub).EndConnectionAttempt offerId -let connectHub (testServer: TestServer) : Task = + member this.CreateRoom() = (this :> ISignalingHub).CreateRoom() + member this.JoinRoom roomId = (this :> ISignalingHub).JoinRoom roomId + member this.LeaveRoom() = (this :> ISignalingHub).LeaveRoom() + member this.ConnectToRoomPlayers() = (this :> ISignalingHub).ConnectToRoomPlayers() + + member _.PlayerId = connection.ConnectionId |> PlayerId.fromHubConnectionId + + +let connectHub (testServer: TestServer) : Task = let httpConnectionOptions (options: HttpConnectionOptions) = options.HttpMessageHandlerFactory <- fun _ -> testServer.CreateHandler() @@ -44,5 +57,5 @@ let connectHub (testServer: TestServer) : Task = task { do! connection.StartAsync() - return connection, SignalingHub(connection) :> ISignalingHub + return connection, SignalingHub(connection) } diff --git a/src/Behide.OnlineServices.Tests/Tests/Signaling/RoomManagement.fs b/src/Behide.OnlineServices.Tests/Tests/Signaling/RoomManagement.fs index 537d91e..716db88 100644 --- a/src/Behide.OnlineServices.Tests/Tests/Signaling/RoomManagement.fs +++ b/src/Behide.OnlineServices.Tests/Tests/Signaling/RoomManagement.fs @@ -19,7 +19,7 @@ let tests testServer (roomStore: Hubs.Signaling.IRoomStore) = testList "RoomManagement" [ testList "CreateRoom" [ testTask "Create room should success" { - let! (_, signalingHub: ISignalingHub) = testServer |> connectHub + let! (_, signalingHub: SignalingHub) = testServer |> connectHub let! roomId = signalingHub.CreateRoom() @@ -33,7 +33,7 @@ let tests testServer (roomStore: Hubs.Signaling.IRoomStore) = } testTask "Create room while already in a room" { - let! (_, signalingHub: ISignalingHub) = testServer |> connectHub + let! (_, signalingHub: SignalingHub) = testServer |> connectHub do! signalingHub.CreateRoom() |> Task.map (Flip.Expect.isOk "Room creation should success") @@ -51,8 +51,8 @@ let tests testServer (roomStore: Hubs.Signaling.IRoomStore) = testList "JoinRoom" [ testTask "Join room should success" { - let! (conn1: HubConnection, signalingHub1: ISignalingHub) = testServer |> connectHub - let! (conn2: HubConnection, signalingHub2: ISignalingHub) = testServer |> connectHub + let! (conn1: HubConnection, signalingHub1: SignalingHub) = testServer |> connectHub + let! (conn2: HubConnection, signalingHub2: SignalingHub) = testServer |> connectHub let mutable offerId = None @@ -104,7 +104,7 @@ let tests testServer (roomStore: Hubs.Signaling.IRoomStore) = } testTask "Join room while already in a room" { - let! (_, signalingHub: ISignalingHub) = testServer |> connectHub + let! (_, signalingHub: SignalingHub) = testServer |> connectHub let! roomId = signalingHub.CreateRoom() @@ -122,7 +122,7 @@ let tests testServer (roomStore: Hubs.Signaling.IRoomStore) = } testTask "Join nonexisting room" { - let! (_, signalingHub: ISignalingHub) = testServer |> connectHub + let! (_, signalingHub: SignalingHub) = testServer |> connectHub let fakeRoomId = RoomId.create() @@ -138,9 +138,9 @@ let tests testServer (roomStore: Hubs.Signaling.IRoomStore) = } testTask "Joining room should give a unique peerId" { - let! (_, signalingHub1: ISignalingHub) = testServer |> connectHub - let! (_, signalingHub2: ISignalingHub) = testServer |> connectHub - let! (_, signalingHub3: ISignalingHub) = testServer |> connectHub + let! (_, signalingHub1: SignalingHub) = testServer |> connectHub + let! (_, signalingHub2: SignalingHub) = testServer |> connectHub + let! (_, signalingHub3: SignalingHub) = testServer |> connectHub // Initialization let! roomId = @@ -176,19 +176,64 @@ let tests testServer (roomStore: Hubs.Signaling.IRoomStore) = Expect.equal peerId4 3 "The third player should have the peerId to 3" Expect.notEqual peerId1 peerId4 "The two players should have different peerId" } + + testTheoryTask + "Simultaneously joining room should give unique peerIds" + [ 3; 5; 10; 100 ] + (fun nbOfPlayers -> task { + let! roomCreator = testServer |> connectHub |> Task.map snd + let! joiningPlayers = + List.init nbOfPlayers (fun _ -> testServer |> connectHub |> Task.map snd) + |> Task.WhenAll + + // Create room + let! roomId = roomCreator.CreateRoom() |> Task.map (Flip.Expect.wantOk "Room creation should success") + + // Join room + let cts = new CancellationTokenSource(TimeSpan.FromSeconds 20.) + let peerIds = ConcurrentBag([ roomCreator.PlayerId, 1 ]) + + do! Parallel.ForEachAsync( + joiningPlayers, + ParallelOptions( + MaxDegreeOfParallelism = nbOfPlayers, + CancellationToken = cts.Token + ), + Func(fun player ct -> + player.JoinRoom(roomId) + |> Task.map (Flip.Expect.wantOk "Should be able to join the room") + |> Task.map (fun peerId -> peerIds.Add (player.PlayerId, peerId)) + |> ValueTask + ) + ) + + // Check + Expect.hasLength peerIds (nbOfPlayers + 1) "All players should have a peer id" + + let distinctPeerIds = peerIds |> Array.ofSeq |> Array.distinct + Expect.hasLength distinctPeerIds peerIds.Count "Peer ids should all be unique" + + let room = roomStore.Get roomId |> Flip.Expect.wantSome "Room should still exist" + let roomPeerIds = + room.Players + |> Array.ofSeq + |> Array.map _.Deconstruct() + + Expect.containsAll roomPeerIds distinctPeerIds "Room should have assigned the peer ids to the correct players" + }) ] testList "ConnectToRoomPlayers" [ testTask "Connect to room players" { // Create room - let! (conn1: HubConnection, signalingHub1: ISignalingHub) = testServer |> connectHub + let! (conn1: HubConnection, signalingHub1: SignalingHub) = testServer |> connectHub let! roomId = signalingHub1.CreateRoom() |> Task.map (Flip.Expect.wantOk "Failed to create a room") // Join room - let! (conn2: HubConnection, signalingHub2: ISignalingHub) = testServer |> connectHub + let! (conn2: HubConnection, signalingHub2: SignalingHub) = testServer |> connectHub let! secondPeerId = signalingHub2.JoinRoom roomId @@ -455,8 +500,8 @@ let tests testServer (roomStore: Hubs.Signaling.IRoomStore) = } testTask "Connect to players without a ConnectionRequested handler" { - let! (_, signalingHub1: ISignalingHub) = testServer |> connectHub - let! (_, signalingHub2: ISignalingHub) = testServer |> connectHub + let! (_, signalingHub1: SignalingHub) = testServer |> connectHub + let! (_, signalingHub2: SignalingHub) = testServer |> connectHub let! roomId = signalingHub1.CreateRoom() @@ -478,8 +523,8 @@ let tests testServer (roomStore: Hubs.Signaling.IRoomStore) = } testTask "Connect to players with a blocking ConnectionRequested handler" { - let! (conn1: HubConnection, signalingHub1: ISignalingHub) = testServer |> connectHub - let! (_, signalingHub2: ISignalingHub) = testServer |> connectHub + let! (conn1: HubConnection, signalingHub1: SignalingHub) = testServer |> connectHub + let! (_, signalingHub2: SignalingHub) = testServer |> connectHub // Create room let! roomId = @@ -510,7 +555,7 @@ let tests testServer (roomStore: Hubs.Signaling.IRoomStore) = } testTask "Connect to players while not in a room" { - let! (_, signalingHub: ISignalingHub) = testServer |> connectHub + let! (_, signalingHub: SignalingHub) = testServer |> connectHub let! (error: Errors.ConnectToRoomPlayersError) = signalingHub.ConnectToRoomPlayers() @@ -526,14 +571,14 @@ let tests testServer (roomStore: Hubs.Signaling.IRoomStore) = testList "LeaveRoom" [ testTask "Leave room" { // Create room - let! (conn1: HubConnection, signalingHub1: ISignalingHub) = testServer |> connectHub + let! (conn1: HubConnection, signalingHub1: SignalingHub) = testServer |> connectHub let! roomId = signalingHub1.CreateRoom() |> Task.map (Flip.Expect.wantOk "Room creation should success") // Join room - let! (_, signalingHub2: ISignalingHub) = testServer |> connectHub + let! (_, signalingHub2: SignalingHub) = testServer |> connectHub do! roomId |> signalingHub2.JoinRoom @@ -558,14 +603,14 @@ let tests testServer (roomStore: Hubs.Signaling.IRoomStore) = testTask "Leave room where we are connected to players" { // Create room - let! (conn1: HubConnection, signalingHub1: ISignalingHub) = testServer |> connectHub + let! (conn1: HubConnection, signalingHub1: SignalingHub) = testServer |> connectHub let! roomId = signalingHub1.CreateRoom() |> Task.map (Flip.Expect.wantOk "Room creation should success") // Join room - let! (_, signalingHub2: ISignalingHub) = testServer |> connectHub + let! (_, signalingHub2: SignalingHub) = testServer |> connectHub do! roomId |> signalingHub2.JoinRoom @@ -602,7 +647,7 @@ let tests testServer (roomStore: Hubs.Signaling.IRoomStore) = } testTask "Leave room while not in a room" { - let! (_, signalingHub: ISignalingHub) = testServer |> connectHub + let! (_, signalingHub: SignalingHub) = testServer |> connectHub let! (error: Errors.LeaveRoomError) = signalingHub.LeaveRoom() @@ -615,7 +660,7 @@ let tests testServer (roomStore: Hubs.Signaling.IRoomStore) = } testTask "Leave room while being the last player delete the room" { - let! (_, hub1: ISignalingHub) = testServer |> connectHub + let! (_, hub1: SignalingHub) = testServer |> connectHub // Create room let! roomId = diff --git a/src/Behide.OnlineServices.Tests/Tests/Signaling/WebRTCSignaling.fs b/src/Behide.OnlineServices.Tests/Tests/Signaling/WebRTCSignaling.fs index b5aa213..50dea7f 100644 --- a/src/Behide.OnlineServices.Tests/Tests/Signaling/WebRTCSignaling.fs +++ b/src/Behide.OnlineServices.Tests/Tests/Signaling/WebRTCSignaling.fs @@ -13,7 +13,7 @@ let tests testServer (connectionAttemptStore: Hubs.Signaling.IConnectionAttemptS testList "WebRTC Signaling" [ testList "StartConnectionAttempt" [ testTask "Create connection attempt" { - let! (conn: HubConnection, signalingHub: ISignalingHub) = testServer |> connectHub + let! (conn: HubConnection, signalingHub: SignalingHub) = testServer |> connectHub let! offerId = Common.fakeSdpDescription @@ -40,7 +40,7 @@ let tests testServer (connectionAttemptStore: Hubs.Signaling.IConnectionAttemptS testList "JoinConnectionAttempt" [ testTask "Join connection attempt" { // Create a connection attempt - let! (conn1: HubConnection, signalingHub1: ISignalingHub) = testServer |> connectHub + let! (conn1: HubConnection, signalingHub1: SignalingHub) = testServer |> connectHub let originalSdpDesc = Common.fakeSdpDescription @@ -56,7 +56,7 @@ let tests testServer (connectionAttemptStore: Hubs.Signaling.IConnectionAttemptS |> Flip.Expect.isNone "Offer should be marked as not answered" // Join connection attempt - let! (_, signalingHub2: ISignalingHub) = testServer |> connectHub + let! (_, signalingHub2: SignalingHub) = testServer |> connectHub let! sdpDescription = offerId @@ -82,7 +82,7 @@ let tests testServer (connectionAttemptStore: Hubs.Signaling.IConnectionAttemptS } testTask "Join nonexisting connection attempt" { - let! (_, signalingHub: ISignalingHub) = testServer |> connectHub + let! (_, signalingHub: SignalingHub) = testServer |> connectHub let fakeOfferId = ConnectionAttemptId.create() @@ -98,9 +98,9 @@ let tests testServer (connectionAttemptStore: Hubs.Signaling.IConnectionAttemptS } testTask "Join already answered connection attempt" { - let! (_, signalingHub1: ISignalingHub) = testServer |> connectHub - let! (_, signalingHub2: ISignalingHub) = testServer |> connectHub - let! (_, signalingHub3: ISignalingHub) = testServer |> connectHub + let! (_, signalingHub1: SignalingHub) = testServer |> connectHub + let! (_, signalingHub2: SignalingHub) = testServer |> connectHub + let! (_, signalingHub3: SignalingHub) = testServer |> connectHub // Create a connection attempt let! offerId = @@ -126,7 +126,7 @@ let tests testServer (connectionAttemptStore: Hubs.Signaling.IConnectionAttemptS } testTask "Join connection attempt as the initiator" { - let! (_, signalingHub: ISignalingHub) = testServer |> connectHub + let! (_, signalingHub: SignalingHub) = testServer |> connectHub // Create connection attempt let! offerId = @@ -149,8 +149,8 @@ let tests testServer (connectionAttemptStore: Hubs.Signaling.IConnectionAttemptS testList "EndConnectionAttempt" [ testTask "Initiator end connection attempt" { - let! (_, hub1: ISignalingHub) = testServer |> connectHub - let! (_, hub2: ISignalingHub) = testServer |> connectHub + let! (_, hub1: SignalingHub) = testServer |> connectHub + let! (_, hub2: SignalingHub) = testServer |> connectHub // Create connection attempt let! offerId = @@ -174,8 +174,8 @@ let tests testServer (connectionAttemptStore: Hubs.Signaling.IConnectionAttemptS } testTask "Answerer end connection attempt" { - let! (_, hub1: ISignalingHub) = testServer |> connectHub - let! (_, hub2: ISignalingHub) = testServer |> connectHub + let! (_, hub1: SignalingHub) = testServer |> connectHub + let! (_, hub2: SignalingHub) = testServer |> connectHub // Create connection attempt let! offerId = @@ -199,7 +199,7 @@ let tests testServer (connectionAttemptStore: Hubs.Signaling.IConnectionAttemptS } testTask "End nonexisting connection attempt" { - let! (_, hub: ISignalingHub) = testServer |> connectHub + let! (_, hub: SignalingHub) = testServer |> connectHub let fakeOfferId = ConnectionAttemptId.create() @@ -214,8 +214,8 @@ let tests testServer (connectionAttemptStore: Hubs.Signaling.IConnectionAttemptS } testTask "End ended connection attempt" { - let! (_, hub1: ISignalingHub) = testServer |> connectHub - let! (_, hub2: ISignalingHub) = testServer |> connectHub + let! (_, hub1: SignalingHub) = testServer |> connectHub + let! (_, hub2: SignalingHub) = testServer |> connectHub // Create connection attempt let! offerId = @@ -246,8 +246,8 @@ let tests testServer (connectionAttemptStore: Hubs.Signaling.IConnectionAttemptS } testTask "End connection attempt as not participant" { - let! (_, hub1: ISignalingHub) = testServer |> connectHub - let! (_, hub2: ISignalingHub) = testServer |> connectHub + let! (_, hub1: SignalingHub) = testServer |> connectHub + let! (_, hub2: SignalingHub) = testServer |> connectHub // Create connection attempt let! offerId = @@ -268,9 +268,9 @@ let tests testServer (connectionAttemptStore: Hubs.Signaling.IConnectionAttemptS } testTask "End answered connection attempt as not participant" { - let! (_, hub1: ISignalingHub) = testServer |> connectHub // Initiator - let! (_, hub2: ISignalingHub) = testServer |> connectHub // Answerer - let! (_, hub3: ISignalingHub) = testServer |> connectHub // Other + let! (_, hub1: SignalingHub) = testServer |> connectHub // Initiator + let! (_, hub2: SignalingHub) = testServer |> connectHub // Answerer + let! (_, hub3: SignalingHub) = testServer |> connectHub // Other // Create connection attempt let! offerId = @@ -298,8 +298,8 @@ let tests testServer (connectionAttemptStore: Hubs.Signaling.IConnectionAttemptS testList "SendAnswer" [ testTask "Send answer" { - let! (conn1: HubConnection, signalingHub1: ISignalingHub) = testServer |> connectHub - let! (_, signalingHub2: ISignalingHub) = testServer |> connectHub + let! (conn1: HubConnection, signalingHub1: SignalingHub) = testServer |> connectHub + let! (_, signalingHub2: SignalingHub) = testServer |> connectHub // Subscribe to SdpAnswerReceived event let sdpAnswerReceivedTcs = Common.TimedTaskCompletionSource(1000) @@ -341,7 +341,7 @@ let tests testServer (connectionAttemptStore: Hubs.Signaling.IConnectionAttemptS } testTask "Send answer to nonexisting connection attempt" { - let! (_, signalingHub: ISignalingHub) = testServer |> connectHub + let! (_, signalingHub: SignalingHub) = testServer |> connectHub let fakeOfferId = ConnectionAttemptId.create() @@ -358,8 +358,8 @@ let tests testServer (connectionAttemptStore: Hubs.Signaling.IConnectionAttemptS } testTask "Send answer to not joined connection attempt" { - let! (_, signalingHub1: ISignalingHub) = testServer |> connectHub - let! (_, signalingHub2: ISignalingHub) = testServer |> connectHub + let! (_, signalingHub1: SignalingHub) = testServer |> connectHub + let! (_, signalingHub2: SignalingHub) = testServer |> connectHub // Create connection attempt let! offerId = @@ -381,9 +381,9 @@ let tests testServer (connectionAttemptStore: Hubs.Signaling.IConnectionAttemptS } testTask "Send answer to joined connection attempt by another player" { - let! (_, signalingHub1: ISignalingHub) = testServer |> connectHub - let! (_, signalingHub2: ISignalingHub) = testServer |> connectHub - let! (_, signalingHub3: ISignalingHub) = testServer |> connectHub + let! (_, signalingHub1: SignalingHub) = testServer |> connectHub + let! (_, signalingHub2: SignalingHub) = testServer |> connectHub + let! (_, signalingHub3: SignalingHub) = testServer |> connectHub // Create connection attempt let! offerId = @@ -412,8 +412,8 @@ let tests testServer (connectionAttemptStore: Hubs.Signaling.IConnectionAttemptS testList "SendIceCandidate" [ testTask "Send ice candidate" { - let! (conn1: HubConnection, signalingHub1: ISignalingHub) = testServer |> connectHub - let! (_, signalingHub2: ISignalingHub) = testServer |> connectHub + let! (conn1: HubConnection, signalingHub1: SignalingHub) = testServer |> connectHub + let! (_, signalingHub2: SignalingHub) = testServer |> connectHub // Subscribe to IceCandidateReceived event let iceCandidateReceivedTcs = Common.TimedTaskCompletionSource(1000) @@ -455,7 +455,7 @@ let tests testServer (connectionAttemptStore: Hubs.Signaling.IConnectionAttemptS } testTask "Send ice candidate to nonexisting connection attempt" { - let! (_, signalingHub: ISignalingHub) = testServer |> connectHub + let! (_, signalingHub: SignalingHub) = testServer |> connectHub let fakeOfferId = ConnectionAttemptId.create() @@ -472,8 +472,8 @@ let tests testServer (connectionAttemptStore: Hubs.Signaling.IConnectionAttemptS } testTask "Send ice candidate to not joined connection attempt" { - let! (_, signalingHub1: ISignalingHub) = testServer |> connectHub - let! (_, signalingHub2: ISignalingHub) = testServer |> connectHub + let! (_, signalingHub1: SignalingHub) = testServer |> connectHub + let! (_, signalingHub2: SignalingHub) = testServer |> connectHub // Create connection attempt let! offerId = @@ -495,9 +495,9 @@ let tests testServer (connectionAttemptStore: Hubs.Signaling.IConnectionAttemptS } testTask "Send ice candidate to joined connection attempt by another player" { - let! (_, signalingHub1: ISignalingHub) = testServer |> connectHub - let! (_, signalingHub2: ISignalingHub) = testServer |> connectHub - let! (_, signalingHub3: ISignalingHub) = testServer |> connectHub + let! (_, signalingHub1: SignalingHub) = testServer |> connectHub + let! (_, signalingHub2: SignalingHub) = testServer |> connectHub + let! (_, signalingHub3: SignalingHub) = testServer |> connectHub // Create connection attempt let! offerId = @@ -524,8 +524,8 @@ let tests testServer (connectionAttemptStore: Hubs.Signaling.IConnectionAttemptS } testTask "Send ice candidate to connection attempt without answerer" { - let! (_, signalingHub1: ISignalingHub) = testServer |> connectHub - let! (_, signalingHub2: ISignalingHub) = testServer |> connectHub + let! (_, signalingHub1: SignalingHub) = testServer |> connectHub + let! (_, signalingHub2: SignalingHub) = testServer |> connectHub // Create connection attempt let! offerId = diff --git a/src/Behide.OnlineServices.Tests/Tests/Types.fs b/src/Behide.OnlineServices.Tests/Tests/Types.fs index 35a94f1..3f1e00e 100644 --- a/src/Behide.OnlineServices.Tests/Tests/Types.fs +++ b/src/Behide.OnlineServices.Tests/Tests/Types.fs @@ -3,7 +3,7 @@ open Behide.OnlineServices open Expecto -[] +[] let tests = testList "Types" [ testList "Pair" [ @@ -31,7 +31,7 @@ let tests = testList "Signaling" [ testList "RoomId" [ - testTheory "correct parsing" + testTheory "Correct parsing" [ "abcd" "ABCD" "0123" @@ -40,7 +40,7 @@ let tests = "A1B3" ] (Signaling.RoomId.tryParse >> Flip.Expect.isSome "Room id should be parsable") - testTheory "incorrect room id not parsable" + testTheory "Incorrect room id not parsable" [ "abc" "abç" "abçd" @@ -49,7 +49,9 @@ let tests = "ä1b3" "A1ü3" "Ü1Ö3" - "01234" ] + "01234" + "😃😃😃😃" + "😃😃" ] (Signaling.RoomId.tryParse >> Flip.Expect.isNone "Room id should not be parsable") ] ] diff --git a/src/Behide.OnlineServices/Hubs/Signaling/RoomManagement.fs b/src/Behide.OnlineServices/Hubs/Signaling/RoomManagement.fs index 719ebdc..0942c86 100644 --- a/src/Behide.OnlineServices/Hubs/Signaling/RoomManagement.fs +++ b/src/Behide.OnlineServices/Hubs/Signaling/RoomManagement.fs @@ -47,7 +47,7 @@ let createRoom (hub: Hub) (playerStore: IPlayerStore) (_connectionAttemptStore: return room.Id } -let joinRoom (hub: Hub) (playerStore: IPlayerStore) (_connectionAttemptStore: IConnectionAttemptStore) (roomStore: IRoomStore) (roomId: RoomId) = // TODO: Add concurrency test +let joinRoom (hub: Hub) (playerStore: IPlayerStore) (_connectionAttemptStore: IConnectionAttemptStore) (roomStore: IRoomStore) (roomId: RoomId) = taskResult { let playerId = hub.Context.ConnectionId |> PlayerId.fromHubConnectionId From 8ef2360476d5beb91a9ad255db8ec5dd97f90e3c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ga=C3=ABl=20Bouquain?= Date: Tue, 8 Jul 2025 17:19:08 +0200 Subject: [PATCH 08/10] Refactor tests --- .../Tests/Signaling/Common.fs | 21 +- .../Tests/Signaling/RoomManagement.fs | 197 ++++++++---------- .../Tests/Signaling/Signaling.fs | 6 +- .../Tests/Signaling/WebRTCSignaling.fs | 85 ++++---- 4 files changed, 151 insertions(+), 158 deletions(-) diff --git a/src/Behide.OnlineServices.Tests/Tests/Signaling/Common.fs b/src/Behide.OnlineServices.Tests/Tests/Signaling/Common.fs index 90b8699..680a75d 100644 --- a/src/Behide.OnlineServices.Tests/Tests/Signaling/Common.fs +++ b/src/Behide.OnlineServices.Tests/Tests/Signaling/Common.fs @@ -1,6 +1,8 @@ module Behide.OnlineServices.Tests.Signaling.Common open Behide.OnlineServices.Signaling +open Expecto +open FsToolkit.ErrorHandling open System.Text.Json.Serialization open System.Threading.Tasks @@ -11,7 +13,16 @@ open Microsoft.AspNetCore.SignalR open Microsoft.AspNetCore.SignalR.Client open Microsoft.AspNetCore.TestHost -type SignalingHub(connection: HubConnection) = +type HandlerSetter(connection: HubConnection) = + member _.ConnectionRequested(handler: int -> Task) = connection.On("ConnectionRequested", handler) + member _.ConnectionRequested(handler: int -> ConnectionAttemptId | null) = connection.On("ConnectionRequested", handler) + member _.SdpAnswerReceived(handler: ConnectionAttemptId -> SdpDescription -> unit) = connection.On("SdpAnswerReceived", handler) + member _.IceCandidateReceived(handler: ConnectionAttemptId -> IceCandidate -> unit) = connection.On("IceCandidateReceived", handler) + +type TestHubClient(connection: HubConnection) = + let playerId = connection.ConnectionId |> PlayerId.fromHubConnectionId + let handlerSetter = HandlerSetter(connection) + interface ISignalingHub with member _.StartConnectionAttempt sdpDescription = connection.InvokeAsync<_>("StartConnectionAttempt", sdpDescription) member _.JoinConnectionAttempt offerId = connection.InvokeAsync<_>("JoinConnectionAttempt", offerId) @@ -35,10 +46,12 @@ type SignalingHub(connection: HubConnection) = member this.LeaveRoom() = (this :> ISignalingHub).LeaveRoom() member this.ConnectToRoomPlayers() = (this :> ISignalingHub).ConnectToRoomPlayers() - member _.PlayerId = connection.ConnectionId |> PlayerId.fromHubConnectionId + member _.PlayerId = playerId + member _.SetHandlerFor = handlerSetter + member _.Connection = connection -let connectHub (testServer: TestServer) : Task = +let connectHub (testServer: TestServer) : Task = let httpConnectionOptions (options: HttpConnectionOptions) = options.HttpMessageHandlerFactory <- fun _ -> testServer.CreateHandler() @@ -57,5 +70,5 @@ let connectHub (testServer: TestServer) : Task = task { do! connection.StartAsync() - return connection, SignalingHub(connection) + return TestHubClient(connection) } diff --git a/src/Behide.OnlineServices.Tests/Tests/Signaling/RoomManagement.fs b/src/Behide.OnlineServices.Tests/Tests/Signaling/RoomManagement.fs index 716db88..d186a82 100644 --- a/src/Behide.OnlineServices.Tests/Tests/Signaling/RoomManagement.fs +++ b/src/Behide.OnlineServices.Tests/Tests/Signaling/RoomManagement.fs @@ -2,7 +2,6 @@ open Expecto open FsToolkit.ErrorHandling -open Microsoft.AspNetCore.SignalR.Client open System open System.Collections.Concurrent @@ -19,10 +18,10 @@ let tests testServer (roomStore: Hubs.Signaling.IRoomStore) = testList "RoomManagement" [ testList "CreateRoom" [ testTask "Create room should success" { - let! (_, signalingHub: SignalingHub) = testServer |> connectHub + let! (hub: TestHubClient) = testServer |> connectHub let! roomId = - signalingHub.CreateRoom() + hub.CreateRoom() |> Task.map (Flip.Expect.wantOk "Room creation should success") let room = @@ -33,13 +32,13 @@ let tests testServer (roomStore: Hubs.Signaling.IRoomStore) = } testTask "Create room while already in a room" { - let! (_, signalingHub: SignalingHub) = testServer |> connectHub + let! (hub: TestHubClient) = testServer |> connectHub - do! signalingHub.CreateRoom() + do! hub.CreateRoom() |> Task.map (Flip.Expect.isOk "Room creation should success") let! (error: Errors.CreateRoomError) = - signalingHub.CreateRoom() + hub.CreateRoom() |> Task.map (Flip.Expect.wantError "Second room creation should return an error") Expect.equal @@ -51,15 +50,15 @@ let tests testServer (roomStore: Hubs.Signaling.IRoomStore) = testList "JoinRoom" [ testTask "Join room should success" { - let! (conn1: HubConnection, signalingHub1: SignalingHub) = testServer |> connectHub - let! (conn2: HubConnection, signalingHub2: SignalingHub) = testServer |> connectHub + let! (hub1: TestHubClient) = testServer |> connectHub + let! (hub2: TestHubClient) = testServer |> connectHub let mutable offerId = None // Add handler for creating offer - conn1.On("ConnectionRequested", fun _playerId -> + hub1.SetHandlerFor.ConnectionRequested(fun _playerId -> Common.fakeSdpDescription - |> signalingHub1.StartConnectionAttempt + |> hub1.StartConnectionAttempt |> Task.map (function | Ok o -> offerId <- Some o; o | Error e -> failwithf "Failed to create offer: %A" e) @@ -69,7 +68,7 @@ let tests testServer (roomStore: Hubs.Signaling.IRoomStore) = // Create a room and check if it was created let! roomId = - signalingHub1.CreateRoom() + hub1.CreateRoom() |> Task.map (Flip.Expect.wantOk "Room creation should success") roomStore.Get roomId @@ -78,7 +77,7 @@ let tests testServer (roomStore: Hubs.Signaling.IRoomStore) = // Join the room let! peerId = - signalingHub2.JoinRoom roomId + hub2.JoinRoom roomId |> Task.map (Flip.Expect.wantOk "Room joining should success") // Retrieve the updated room @@ -92,27 +91,27 @@ let tests testServer (roomStore: Hubs.Signaling.IRoomStore) = // Check if the room contains both players Expect.equal - conn1.ConnectionId - (room.Initiator |> PlayerId.raw) + hub1.PlayerId + room.Initiator "Room initiator should be the first player connection id" Expect.containsAll room.Players - [ KeyValuePair(conn1.ConnectionId |> PlayerId.fromHubConnectionId, 1) - KeyValuePair(conn2.ConnectionId |> PlayerId.fromHubConnectionId, 2) ] + [ KeyValuePair(hub1.PlayerId, 1) + KeyValuePair(hub2.PlayerId, 2) ] "Room should contain both player connection ids" } testTask "Join room while already in a room" { - let! (_, signalingHub: SignalingHub) = testServer |> connectHub + let! (hub: TestHubClient) = testServer |> connectHub let! roomId = - signalingHub.CreateRoom() + hub.CreateRoom() |> Task.map (Flip.Expect.wantOk "Room creation should success") let! (error: Errors.JoinRoomError) = roomId - |> signalingHub.JoinRoom + |> hub.JoinRoom |> Task.map (Flip.Expect.wantError "Joining room should return an error") Expect.equal @@ -122,13 +121,13 @@ let tests testServer (roomStore: Hubs.Signaling.IRoomStore) = } testTask "Join nonexisting room" { - let! (_, signalingHub: SignalingHub) = testServer |> connectHub + let! (hub: TestHubClient) = testServer |> connectHub let fakeRoomId = RoomId.create() let! (error: Errors.JoinRoomError) = fakeRoomId - |> signalingHub.JoinRoom + |> hub.JoinRoom |> Task.map (Flip.Expect.wantError "Joining nonexisting room should return an error") Expect.equal @@ -138,31 +137,31 @@ let tests testServer (roomStore: Hubs.Signaling.IRoomStore) = } testTask "Joining room should give a unique peerId" { - let! (_, signalingHub1: SignalingHub) = testServer |> connectHub - let! (_, signalingHub2: SignalingHub) = testServer |> connectHub - let! (_, signalingHub3: SignalingHub) = testServer |> connectHub + let! (hub1: TestHubClient) = testServer |> connectHub + let! (hub2: TestHubClient) = testServer |> connectHub + let! (hub3: TestHubClient) = testServer |> connectHub // Initialization let! roomId = - signalingHub1.CreateRoom() + hub1.CreateRoom() |> Task.map (Flip.Expect.wantOk "Room creation should success") let peerId1 = 1 // The first player should always have the peerId 1 // Join room let! peerId2 = - signalingHub2.JoinRoom roomId + hub2.JoinRoom roomId |> Task.map (Flip.Expect.wantOk "Room joining should success") Expect.equal peerId2 2 "The second player should have the peerId to 2" Expect.notEqual peerId1 peerId2 "The two players should have different peerId" // Leave and rejoin room - do! signalingHub2.LeaveRoom() + do! hub2.LeaveRoom() |> Task.map (Flip.Expect.wantOk "Leaving room should success") let! peerId3 = - signalingHub2.JoinRoom roomId + hub2.JoinRoom roomId |> Task.map (Flip.Expect.wantOk "Room joining should success") Expect.equal peerId3 2 "The second player should have the peerId to 2" @@ -170,7 +169,7 @@ let tests testServer (roomStore: Hubs.Signaling.IRoomStore) = // Join room with a third player let! peerId4 = - signalingHub3.JoinRoom roomId + hub3.JoinRoom roomId |> Task.map (Flip.Expect.wantOk "Room joining should success") Expect.equal peerId4 3 "The third player should have the peerId to 3" @@ -181,9 +180,9 @@ let tests testServer (roomStore: Hubs.Signaling.IRoomStore) = "Simultaneously joining room should give unique peerIds" [ 3; 5; 10; 100 ] (fun nbOfPlayers -> task { - let! roomCreator = testServer |> connectHub |> Task.map snd + let! roomCreator = testServer |> connectHub let! joiningPlayers = - List.init nbOfPlayers (fun _ -> testServer |> connectHub |> Task.map snd) + List.init nbOfPlayers (fun _ -> testServer |> connectHub) |> Task.WhenAll // Create room @@ -199,7 +198,7 @@ let tests testServer (roomStore: Hubs.Signaling.IRoomStore) = MaxDegreeOfParallelism = nbOfPlayers, CancellationToken = cts.Token ), - Func(fun player ct -> + Func(fun player ct -> player.JoinRoom(roomId) |> Task.map (Flip.Expect.wantOk "Should be able to join the room") |> Task.map (fun peerId -> peerIds.Add (player.PlayerId, peerId)) @@ -226,17 +225,17 @@ let tests testServer (roomStore: Hubs.Signaling.IRoomStore) = testList "ConnectToRoomPlayers" [ testTask "Connect to room players" { // Create room - let! (conn1: HubConnection, signalingHub1: SignalingHub) = testServer |> connectHub + let! (hub1: TestHubClient) = testServer |> connectHub let! roomId = - signalingHub1.CreateRoom() + hub1.CreateRoom() |> Task.map (Flip.Expect.wantOk "Failed to create a room") // Join room - let! (conn2: HubConnection, signalingHub2: SignalingHub) = testServer |> connectHub + let! (hub2: TestHubClient) = testServer |> connectHub let! secondPeerId = - signalingHub2.JoinRoom roomId + hub2.JoinRoom roomId |> Task.map (Flip.Expect.wantOk "Failed to join room") Expect.equal secondPeerId 2 "The second player should have a peerId to 2" @@ -244,9 +243,9 @@ let tests testServer (roomStore: Hubs.Signaling.IRoomStore) = // Register "ConnectionRequested" handler on first player let mutable connAttemptId = None - conn1.On("ConnectionRequested", fun _peerId -> + hub1.SetHandlerFor.ConnectionRequested(fun _peerId -> Common.fakeSdpDescription - |> signalingHub1.StartConnectionAttempt + |> hub1.StartConnectionAttempt |> Task.map (function | Ok c -> connAttemptId <- Some c; c | Error e -> failtestf "Failed to create connection attempt: %A" e) @@ -255,7 +254,7 @@ let tests testServer (roomStore: Hubs.Signaling.IRoomStore) = // Connect players let! (res: RoomConnectionInfo) = - signalingHub2.ConnectToRoomPlayers() + hub2.ConnectToRoomPlayers() |> Task.map (Flip.Expect.wantOk "Failed to create connection information for players") Expect.isEmpty res.FailedCreations "No connection attempt creation should be failed" @@ -271,12 +270,9 @@ let tests testServer (roomStore: Hubs.Signaling.IRoomStore) = roomStore.Get roomId |> Flip.Expect.wantSome "Room should still exist" - let connId1 = conn1.ConnectionId |> PlayerId.fromHubConnectionId - let connId2 = conn2.ConnectionId |> PlayerId.fromHubConnectionId - Expect.containsAll room.Connections - [ Pair.create connId1 connId2 ] + [ Pair.create hub1.PlayerId hub2.PlayerId ] "Room should contain the connection between the two players" } @@ -296,11 +292,11 @@ let tests testServer (roomStore: Hubs.Signaling.IRoomStore) = let connAttemptIdsTask = players |> List.indexed - |> List.choose (fun (idx, (conn, hub)) -> + |> List.choose (fun (idx, hub) -> let connAttemptIdTcs = TaskCompletionSource>() cts.Token.Register(fun _ -> connAttemptIdTcs.TrySetCanceled() |> ignore) |> ignore - conn.On("ConnectionRequested", fun _ -> + hub.SetHandlerFor.ConnectionRequested(fun _ -> Common.fakeSdpDescription |> hub.StartConnectionAttempt |> Task.map (fun res -> @@ -322,20 +318,19 @@ let tests testServer (roomStore: Hubs.Signaling.IRoomStore) = // Create room let! roomId = - (players |> List.head |> snd).CreateRoom() + (players |> List.head).CreateRoom() |> Task.map (Flip.Expect.wantOk "Failed to create room") // Join room do! players |> List.tail - |> List.map (fun (_, hub) -> hub.JoinRoom roomId) + |> List.map (fun hub -> hub.JoinRoom roomId) |> List.sequenceTaskResultA |> Task.map (Flip.Expect.isOk "All players should be able to join the room") // Connect players - let hub = players |> List.item peerThatConnects |> snd let! res = - hub.ConnectToRoomPlayers() + players[peerThatConnects].ConnectToRoomPlayers() |> Task.map (Flip.Expect.wantOk "Failed to create connection information for players") cts.CancelAfter 1000 @@ -356,21 +351,17 @@ let tests testServer (roomStore: Hubs.Signaling.IRoomStore) = roomStore.Get roomId |> Flip.Expect.wantSome "Room should still exist" - let connIdThatConnects = + let playerIdThatConnects = players |> List.item peerThatConnects - |> fst - |> _.ConnectionId - |> PlayerId.fromHubConnectionId + |> _.PlayerId let expectedConnections = players - |> List.choose (fun (conn, _) -> - let connId = conn.ConnectionId |> PlayerId.fromHubConnectionId - - match connId <> connIdThatConnects with + |> List.choose (fun hub -> + match hub.PlayerId <> playerIdThatConnects with | false -> None // The player that connects should not be connected to himself - | true -> Some <| Pair.create connIdThatConnects connId + | true -> Some <| Pair.create playerIdThatConnects hub.PlayerId ) Expect.containsAll @@ -393,8 +384,8 @@ let tests testServer (roomStore: Hubs.Signaling.IRoomStore) = // Register handlers let connectionAttemptIds = ConcurrentBag() - players |> List.iter (fun (hubConnection, hub) -> - hubConnection.On("ConnectionRequested", fun requestingPeerId -> + players |> List.iter (fun hub -> + hub.SetHandlerFor.ConnectionRequested(fun _ -> Task.Run(fun () -> Common.fakeSdpDescription |> hub.StartConnectionAttempt @@ -409,7 +400,6 @@ let tests testServer (roomStore: Hubs.Signaling.IRoomStore) = let! roomId = players |> List.head - |> snd |> _.CreateRoom() |> Task.map (Flip.Expect.wantOk "Failed to create room") @@ -417,7 +407,7 @@ let tests testServer (roomStore: Hubs.Signaling.IRoomStore) = let! playerIndexByPlayerPeerId = players |> List.tail - |> List.mapi (fun idx (_, hub) -> + |> List.mapi (fun idx hub -> let playerIdx = idx + 1 hub.JoinRoom roomId |> TaskResult.map (fun peerId -> peerId, playerIdx) @@ -441,7 +431,7 @@ let tests testServer (roomStore: Hubs.Signaling.IRoomStore) = ), Func(fun playerIdx _ -> task { - let hub = players[playerIdx] |> snd + let hub = players[playerIdx] let! res = hub.ConnectToRoomPlayers() let connectionInfo = Expect.wantOk res "ConnectToRoomPlayers method should succeed" @@ -480,38 +470,29 @@ let tests testServer (roomStore: Hubs.Signaling.IRoomStore) = let expectedConnections = playersThatConnect |> List.collect (fun playerIdx -> - let requestingConnectionId = - players[playerIdx] - |> fst - |> _.ConnectionId - |> PlayerId.fromHubConnectionId - - List.init nbOfPlayers (fun otherPlayerIdx -> - players[otherPlayerIdx] - |> fst - |> _.ConnectionId - |> PlayerId.fromHubConnectionId - ) - |> List.filter ((<>) requestingConnectionId) - |> List.map (Pair.create requestingConnectionId) + let requestingPlayerId = players[playerIdx].PlayerId + + List.init nbOfPlayers (fun otherPlayerIdx -> players[otherPlayerIdx].PlayerId) + |> List.filter ((<>) requestingPlayerId) + |> List.map (Pair.create requestingPlayerId) ) Expect.containsAll room.Connections expectedConnections "All connections should be present" } testTask "Connect to players without a ConnectionRequested handler" { - let! (_, signalingHub1: SignalingHub) = testServer |> connectHub - let! (_, signalingHub2: SignalingHub) = testServer |> connectHub + let! (hub1: TestHubClient) = testServer |> connectHub + let! (hub2: TestHubClient) = testServer |> connectHub let! roomId = - signalingHub1.CreateRoom() + hub1.CreateRoom() |> Task.map (Flip.Expect.wantOk "Room creation should success") - do! signalingHub2.JoinRoom roomId + do! hub2.JoinRoom roomId |> Task.map (Flip.Expect.wantOk "Room joining should success") let! (res: RoomConnectionInfo) = - signalingHub2.ConnectToRoomPlayers() + hub2.ConnectToRoomPlayers() |> Task.map (Flip.Expect.wantOk "Connecting to room players should return an \"Ok\" result") Expect.sequenceEqual @@ -523,27 +504,27 @@ let tests testServer (roomStore: Hubs.Signaling.IRoomStore) = } testTask "Connect to players with a blocking ConnectionRequested handler" { - let! (conn1: HubConnection, signalingHub1: SignalingHub) = testServer |> connectHub - let! (_, signalingHub2: SignalingHub) = testServer |> connectHub + let! (hub1: TestHubClient) = testServer |> connectHub + let! (hub2: TestHubClient) = testServer |> connectHub // Create room let! roomId = - signalingHub1.CreateRoom() + hub1.CreateRoom() |> Task.map (Flip.Expect.wantOk "Room creation should success") // Join room - do! signalingHub2.JoinRoom roomId + do! hub2.JoinRoom roomId |> Task.map (Flip.Expect.wantOk "Room joining should success") // Register blocking "ConnectionRequested" handler on first player - conn1.On("ConnectionRequested", fun _playerId -> + hub1.SetHandlerFor.ConnectionRequested(fun _playerId -> while true do () // Never returns ConnectionAttemptId.create() ) |> ignore let! (res: RoomConnectionInfo) = - signalingHub2.ConnectToRoomPlayers() + hub2.ConnectToRoomPlayers() |> Task.map (Flip.Expect.wantOk "Connecting to room players should return an \"Ok\" result") Expect.sequenceEqual @@ -555,10 +536,10 @@ let tests testServer (roomStore: Hubs.Signaling.IRoomStore) = } testTask "Connect to players while not in a room" { - let! (_, signalingHub: SignalingHub) = testServer |> connectHub + let! (hub: TestHubClient) = testServer |> connectHub let! (error: Errors.ConnectToRoomPlayersError) = - signalingHub.ConnectToRoomPlayers() + hub.ConnectToRoomPlayers() |> Task.map (Flip.Expect.wantError "Connecting to room players while not in a room should return an error") Expect.equal @@ -571,21 +552,21 @@ let tests testServer (roomStore: Hubs.Signaling.IRoomStore) = testList "LeaveRoom" [ testTask "Leave room" { // Create room - let! (conn1: HubConnection, signalingHub1: SignalingHub) = testServer |> connectHub + let! (hub1: TestHubClient) = testServer |> connectHub let! roomId = - signalingHub1.CreateRoom() + hub1.CreateRoom() |> Task.map (Flip.Expect.wantOk "Room creation should success") // Join room - let! (_, signalingHub2: SignalingHub) = testServer |> connectHub + let! (hub2: TestHubClient) = testServer |> connectHub do! roomId - |> signalingHub2.JoinRoom + |> hub2.JoinRoom |> Task.map (Flip.Expect.isOk "Room joining should success") // Leave room - do! signalingHub2.LeaveRoom() + do! hub2.LeaveRoom() |> Task.map (Flip.Expect.wantOk "Leaving room should success") // Check if the player is not in the room anymore @@ -595,7 +576,7 @@ let tests testServer (roomStore: Hubs.Signaling.IRoomStore) = Expect.sequenceEqual room.Players - [ KeyValuePair(conn1.ConnectionId |> PlayerId.fromHubConnectionId, 1) ] + [ KeyValuePair(hub1.PlayerId, 1) ] "Room should not contain the second player" Expect.isEmpty room.Connections "Room should not contain any connection" @@ -603,34 +584,34 @@ let tests testServer (roomStore: Hubs.Signaling.IRoomStore) = testTask "Leave room where we are connected to players" { // Create room - let! (conn1: HubConnection, signalingHub1: SignalingHub) = testServer |> connectHub + let! (hub1: TestHubClient) = testServer |> connectHub let! roomId = - signalingHub1.CreateRoom() + hub1.CreateRoom() |> Task.map (Flip.Expect.wantOk "Room creation should success") // Join room - let! (_, signalingHub2: SignalingHub) = testServer |> connectHub + let! (hub2: TestHubClient) = testServer |> connectHub do! roomId - |> signalingHub2.JoinRoom + |> hub2.JoinRoom |> Task.map (Flip.Expect.isOk "Room joining should success") // Connect to room players - conn1.On("ConnectionRequested", fun _playerId -> + hub1.SetHandlerFor.ConnectionRequested(fun _playerId -> Common.fakeSdpDescription - |> signalingHub1.StartConnectionAttempt + |> hub1.StartConnectionAttempt |> Task.map (function | Ok c -> c | Error e -> failwithf "Failed to create connection attempt: %A" e) ) |> ignore - do! signalingHub2.ConnectToRoomPlayers() + do! hub2.ConnectToRoomPlayers() |> Task.map (Flip.Expect.isOk "Connecting to room players should success") // Leave room - do! signalingHub2.LeaveRoom() + do! hub2.LeaveRoom() |> Task.map (Flip.Expect.isOk "Leaving room should success") // Check if the player is not in the room anymore @@ -640,17 +621,17 @@ let tests testServer (roomStore: Hubs.Signaling.IRoomStore) = Expect.sequenceEqual room.Players - [ KeyValuePair(conn1.ConnectionId |> PlayerId.fromHubConnectionId, 1) ] + [ KeyValuePair(hub1.PlayerId, 1) ] "Room should not contain the second player" Expect.isEmpty room.Connections "Room should not contain any connection" } testTask "Leave room while not in a room" { - let! (_, signalingHub: SignalingHub) = testServer |> connectHub + let! (hub: TestHubClient) = testServer |> connectHub let! (error: Errors.LeaveRoomError) = - signalingHub.LeaveRoom() + hub.LeaveRoom() |> Task.map (Flip.Expect.wantError "Leaving room while not in a room should return an error") Expect.equal @@ -660,7 +641,7 @@ let tests testServer (roomStore: Hubs.Signaling.IRoomStore) = } testTask "Leave room while being the last player delete the room" { - let! (_, hub1: SignalingHub) = testServer |> connectHub + let! (hub1: TestHubClient) = testServer |> connectHub // Create room let! roomId = diff --git a/src/Behide.OnlineServices.Tests/Tests/Signaling/Signaling.fs b/src/Behide.OnlineServices.Tests/Tests/Signaling/Signaling.fs index cd050de..cebf8e4 100644 --- a/src/Behide.OnlineServices.Tests/Tests/Signaling/Signaling.fs +++ b/src/Behide.OnlineServices.Tests/Tests/Signaling/Signaling.fs @@ -14,11 +14,11 @@ let signalingTests = testList "Signaling" [ testTask "Signaling hub connection should success" { - let! (connection: HubConnection, _) = testServer |> connectHub + let! (hub: TestHubClient) = testServer |> connectHub - Expect.equal connection.State HubConnectionState.Connected "Should be connected to the hub" + Expect.equal hub.Connection.State HubConnectionState.Connected "Should be connected to the hub" - let playerId = connection.ConnectionId |> PlayerId.fromHubConnectionId + let playerId = hub.Connection.ConnectionId |> PlayerId.fromHubConnectionId let player = playerConnStore.Get playerId |> Flip.Expect.wantSome "Client should be registered in the player connections store" diff --git a/src/Behide.OnlineServices.Tests/Tests/Signaling/WebRTCSignaling.fs b/src/Behide.OnlineServices.Tests/Tests/Signaling/WebRTCSignaling.fs index 50dea7f..a74a2cc 100644 --- a/src/Behide.OnlineServices.Tests/Tests/Signaling/WebRTCSignaling.fs +++ b/src/Behide.OnlineServices.Tests/Tests/Signaling/WebRTCSignaling.fs @@ -2,7 +2,6 @@ open Expecto open FsToolkit.ErrorHandling -open Microsoft.AspNetCore.SignalR.Client open Behide.OnlineServices open Behide.OnlineServices.Signaling @@ -13,7 +12,7 @@ let tests testServer (connectionAttemptStore: Hubs.Signaling.IConnectionAttemptS testList "WebRTC Signaling" [ testList "StartConnectionAttempt" [ testTask "Create connection attempt" { - let! (conn: HubConnection, signalingHub: SignalingHub) = testServer |> connectHub + let! (signalingHub: TestHubClient) = testServer |> connectHub let! offerId = Common.fakeSdpDescription @@ -27,7 +26,7 @@ let tests testServer (connectionAttemptStore: Hubs.Signaling.IConnectionAttemptS Expect.equal offer.InitiatorConnectionId - (conn.ConnectionId |> PlayerId.fromHubConnectionId) + signalingHub.PlayerId "Offer initiator should be the player connection id" Expect.equal @@ -40,7 +39,7 @@ let tests testServer (connectionAttemptStore: Hubs.Signaling.IConnectionAttemptS testList "JoinConnectionAttempt" [ testTask "Join connection attempt" { // Create a connection attempt - let! (conn1: HubConnection, signalingHub1: SignalingHub) = testServer |> connectHub + let! (signalingHub1: TestHubClient) = testServer |> connectHub let originalSdpDesc = Common.fakeSdpDescription @@ -56,7 +55,7 @@ let tests testServer (connectionAttemptStore: Hubs.Signaling.IConnectionAttemptS |> Flip.Expect.isNone "Offer should be marked as not answered" // Join connection attempt - let! (_, signalingHub2: SignalingHub) = testServer |> connectHub + let! (signalingHub2: TestHubClient) = testServer |> connectHub let! sdpDescription = offerId @@ -77,12 +76,12 @@ let tests testServer (connectionAttemptStore: Hubs.Signaling.IConnectionAttemptS Expect.equal offer.InitiatorConnectionId - (conn1.ConnectionId |> PlayerId.fromHubConnectionId) + signalingHub1.PlayerId "Offer initiator should be the first player connection id" } testTask "Join nonexisting connection attempt" { - let! (_, signalingHub: SignalingHub) = testServer |> connectHub + let! (signalingHub: TestHubClient) = testServer |> connectHub let fakeOfferId = ConnectionAttemptId.create() @@ -98,9 +97,9 @@ let tests testServer (connectionAttemptStore: Hubs.Signaling.IConnectionAttemptS } testTask "Join already answered connection attempt" { - let! (_, signalingHub1: SignalingHub) = testServer |> connectHub - let! (_, signalingHub2: SignalingHub) = testServer |> connectHub - let! (_, signalingHub3: SignalingHub) = testServer |> connectHub + let! (signalingHub1: TestHubClient) = testServer |> connectHub + let! (signalingHub2: TestHubClient) = testServer |> connectHub + let! (signalingHub3: TestHubClient) = testServer |> connectHub // Create a connection attempt let! offerId = @@ -126,7 +125,7 @@ let tests testServer (connectionAttemptStore: Hubs.Signaling.IConnectionAttemptS } testTask "Join connection attempt as the initiator" { - let! (_, signalingHub: SignalingHub) = testServer |> connectHub + let! (signalingHub: TestHubClient) = testServer |> connectHub // Create connection attempt let! offerId = @@ -149,8 +148,8 @@ let tests testServer (connectionAttemptStore: Hubs.Signaling.IConnectionAttemptS testList "EndConnectionAttempt" [ testTask "Initiator end connection attempt" { - let! (_, hub1: SignalingHub) = testServer |> connectHub - let! (_, hub2: SignalingHub) = testServer |> connectHub + let! (hub1: TestHubClient) = testServer |> connectHub + let! (hub2: TestHubClient) = testServer |> connectHub // Create connection attempt let! offerId = @@ -174,8 +173,8 @@ let tests testServer (connectionAttemptStore: Hubs.Signaling.IConnectionAttemptS } testTask "Answerer end connection attempt" { - let! (_, hub1: SignalingHub) = testServer |> connectHub - let! (_, hub2: SignalingHub) = testServer |> connectHub + let! (hub1: TestHubClient) = testServer |> connectHub + let! (hub2: TestHubClient) = testServer |> connectHub // Create connection attempt let! offerId = @@ -199,7 +198,7 @@ let tests testServer (connectionAttemptStore: Hubs.Signaling.IConnectionAttemptS } testTask "End nonexisting connection attempt" { - let! (_, hub: SignalingHub) = testServer |> connectHub + let! (hub: TestHubClient) = testServer |> connectHub let fakeOfferId = ConnectionAttemptId.create() @@ -214,8 +213,8 @@ let tests testServer (connectionAttemptStore: Hubs.Signaling.IConnectionAttemptS } testTask "End ended connection attempt" { - let! (_, hub1: SignalingHub) = testServer |> connectHub - let! (_, hub2: SignalingHub) = testServer |> connectHub + let! (hub1: TestHubClient) = testServer |> connectHub + let! (hub2: TestHubClient) = testServer |> connectHub // Create connection attempt let! offerId = @@ -246,8 +245,8 @@ let tests testServer (connectionAttemptStore: Hubs.Signaling.IConnectionAttemptS } testTask "End connection attempt as not participant" { - let! (_, hub1: SignalingHub) = testServer |> connectHub - let! (_, hub2: SignalingHub) = testServer |> connectHub + let! (hub1: TestHubClient) = testServer |> connectHub + let! (hub2: TestHubClient) = testServer |> connectHub // Create connection attempt let! offerId = @@ -268,9 +267,9 @@ let tests testServer (connectionAttemptStore: Hubs.Signaling.IConnectionAttemptS } testTask "End answered connection attempt as not participant" { - let! (_, hub1: SignalingHub) = testServer |> connectHub // Initiator - let! (_, hub2: SignalingHub) = testServer |> connectHub // Answerer - let! (_, hub3: SignalingHub) = testServer |> connectHub // Other + let! (hub1: TestHubClient) = testServer |> connectHub // Initiator + let! (hub2: TestHubClient) = testServer |> connectHub // Answerer + let! (hub3: TestHubClient) = testServer |> connectHub // Other // Create connection attempt let! offerId = @@ -298,13 +297,13 @@ let tests testServer (connectionAttemptStore: Hubs.Signaling.IConnectionAttemptS testList "SendAnswer" [ testTask "Send answer" { - let! (conn1: HubConnection, signalingHub1: SignalingHub) = testServer |> connectHub - let! (_, signalingHub2: SignalingHub) = testServer |> connectHub + let! (signalingHub1: TestHubClient) = testServer |> connectHub + let! (signalingHub2: TestHubClient) = testServer |> connectHub // Subscribe to SdpAnswerReceived event let sdpAnswerReceivedTcs = Common.TimedTaskCompletionSource(1000) - conn1.On("SdpAnswerReceived", fun (offerId: ConnectionAttemptId) (sdpDescription: SdpDescription) -> + signalingHub1.SetHandlerFor.SdpAnswerReceived(fun offerId sdpDescription -> sdpAnswerReceivedTcs.SetResult(offerId, sdpDescription) ) |> ignore @@ -341,7 +340,7 @@ let tests testServer (connectionAttemptStore: Hubs.Signaling.IConnectionAttemptS } testTask "Send answer to nonexisting connection attempt" { - let! (_, signalingHub: SignalingHub) = testServer |> connectHub + let! (signalingHub: TestHubClient) = testServer |> connectHub let fakeOfferId = ConnectionAttemptId.create() @@ -358,8 +357,8 @@ let tests testServer (connectionAttemptStore: Hubs.Signaling.IConnectionAttemptS } testTask "Send answer to not joined connection attempt" { - let! (_, signalingHub1: SignalingHub) = testServer |> connectHub - let! (_, signalingHub2: SignalingHub) = testServer |> connectHub + let! (signalingHub1: TestHubClient) = testServer |> connectHub + let! (signalingHub2: TestHubClient) = testServer |> connectHub // Create connection attempt let! offerId = @@ -381,9 +380,9 @@ let tests testServer (connectionAttemptStore: Hubs.Signaling.IConnectionAttemptS } testTask "Send answer to joined connection attempt by another player" { - let! (_, signalingHub1: SignalingHub) = testServer |> connectHub - let! (_, signalingHub2: SignalingHub) = testServer |> connectHub - let! (_, signalingHub3: SignalingHub) = testServer |> connectHub + let! (signalingHub1: TestHubClient) = testServer |> connectHub + let! (signalingHub2: TestHubClient) = testServer |> connectHub + let! (signalingHub3: TestHubClient) = testServer |> connectHub // Create connection attempt let! offerId = @@ -412,13 +411,13 @@ let tests testServer (connectionAttemptStore: Hubs.Signaling.IConnectionAttemptS testList "SendIceCandidate" [ testTask "Send ice candidate" { - let! (conn1: HubConnection, signalingHub1: SignalingHub) = testServer |> connectHub - let! (_, signalingHub2: SignalingHub) = testServer |> connectHub + let! (signalingHub1: TestHubClient) = testServer |> connectHub + let! (signalingHub2: TestHubClient) = testServer |> connectHub // Subscribe to IceCandidateReceived event let iceCandidateReceivedTcs = Common.TimedTaskCompletionSource(1000) - conn1.On("IceCandidateReceived", fun (offerId: ConnectionAttemptId) (iceCandidate: IceCandidate) -> + signalingHub1.SetHandlerFor.IceCandidateReceived(fun offerId iceCandidate -> iceCandidateReceivedTcs.SetResult(offerId, iceCandidate) ) |> ignore @@ -455,7 +454,7 @@ let tests testServer (connectionAttemptStore: Hubs.Signaling.IConnectionAttemptS } testTask "Send ice candidate to nonexisting connection attempt" { - let! (_, signalingHub: SignalingHub) = testServer |> connectHub + let! (signalingHub: TestHubClient) = testServer |> connectHub let fakeOfferId = ConnectionAttemptId.create() @@ -472,8 +471,8 @@ let tests testServer (connectionAttemptStore: Hubs.Signaling.IConnectionAttemptS } testTask "Send ice candidate to not joined connection attempt" { - let! (_, signalingHub1: SignalingHub) = testServer |> connectHub - let! (_, signalingHub2: SignalingHub) = testServer |> connectHub + let! (signalingHub1: TestHubClient) = testServer |> connectHub + let! (signalingHub2: TestHubClient) = testServer |> connectHub // Create connection attempt let! offerId = @@ -495,9 +494,9 @@ let tests testServer (connectionAttemptStore: Hubs.Signaling.IConnectionAttemptS } testTask "Send ice candidate to joined connection attempt by another player" { - let! (_, signalingHub1: SignalingHub) = testServer |> connectHub - let! (_, signalingHub2: SignalingHub) = testServer |> connectHub - let! (_, signalingHub3: SignalingHub) = testServer |> connectHub + let! (signalingHub1: TestHubClient) = testServer |> connectHub + let! (signalingHub2: TestHubClient) = testServer |> connectHub + let! (signalingHub3: TestHubClient) = testServer |> connectHub // Create connection attempt let! offerId = @@ -524,8 +523,8 @@ let tests testServer (connectionAttemptStore: Hubs.Signaling.IConnectionAttemptS } testTask "Send ice candidate to connection attempt without answerer" { - let! (_, signalingHub1: SignalingHub) = testServer |> connectHub - let! (_, signalingHub2: SignalingHub) = testServer |> connectHub + let! (signalingHub1: TestHubClient) = testServer |> connectHub + let! (signalingHub2: TestHubClient) = testServer |> connectHub // Create connection attempt let! offerId = From 119130941edc85815b782bf58df4fe26aaf7dafa Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ga=C3=ABl=20Bouquain?= Date: Tue, 8 Jul 2025 17:27:43 +0200 Subject: [PATCH 09/10] Remove room initiator --- .../Tests/Signaling/RoomManagement.fs | 7 +------ src/Behide.OnlineServices.Types/Signaling.fs | 4 +--- src/Behide.OnlineServices/Hubs/Signaling/RoomManagement.fs | 3 +-- 3 files changed, 3 insertions(+), 11 deletions(-) diff --git a/src/Behide.OnlineServices.Tests/Tests/Signaling/RoomManagement.fs b/src/Behide.OnlineServices.Tests/Tests/Signaling/RoomManagement.fs index d186a82..3aa93ef 100644 --- a/src/Behide.OnlineServices.Tests/Tests/Signaling/RoomManagement.fs +++ b/src/Behide.OnlineServices.Tests/Tests/Signaling/RoomManagement.fs @@ -1,4 +1,4 @@ -module Behide.OnlineServices.Tests.Signaling.RoomManagement +module Behide.OnlineServices.Tests.Signaling.RoomManagement open Expecto open FsToolkit.ErrorHandling @@ -90,11 +90,6 @@ let tests testServer (roomStore: Hubs.Signaling.IRoomStore) = Expect.equal peerId 2 "Peer ID should be 2 in that case" // Check if the room contains both players - Expect.equal - hub1.PlayerId - room.Initiator - "Room initiator should be the first player connection id" - Expect.containsAll room.Players [ KeyValuePair(hub1.PlayerId, 1) diff --git a/src/Behide.OnlineServices.Types/Signaling.fs b/src/Behide.OnlineServices.Types/Signaling.fs index 4aab151..786000d 100644 --- a/src/Behide.OnlineServices.Types/Signaling.fs +++ b/src/Behide.OnlineServices.Types/Signaling.fs @@ -68,9 +68,7 @@ type RoomId = /// A room, also a group of players that are connected to each other type Room = { Id: RoomId - Initiator: PlayerId - /// Player peer ids by player id - /// Contains the initiator + /// Player peer ids by player id Players: Dictionary /// A list of the connections between the peers Connections: HashSet diff --git a/src/Behide.OnlineServices/Hubs/Signaling/RoomManagement.fs b/src/Behide.OnlineServices/Hubs/Signaling/RoomManagement.fs index 0942c86..c6ff7a8 100644 --- a/src/Behide.OnlineServices/Hubs/Signaling/RoomManagement.fs +++ b/src/Behide.OnlineServices/Hubs/Signaling/RoomManagement.fs @@ -1,4 +1,4 @@ -module Behide.OnlineServices.Hubs.Signaling.RoomManagement +module Behide.OnlineServices.Hubs.Signaling.RoomManagement open Behide.OnlineServices open Behide.OnlineServices.Signaling @@ -27,7 +27,6 @@ let createRoom (hub: Hub) (playerStore: IPlayerStore) (_connectionAttemptStore: // Create room let room = { Id = RoomId.create () - Initiator = playerId Players = [ KeyValuePair(playerId, 1) ] |> Dictionary // Host peer id should always be 1 Connections = HashSet() ConnectionsInProgress = HashSet() From 8f57b58954dd78ae4a8fd57e479d4909af573789 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ga=C3=ABl=20Bouquain?= Date: Tue, 8 Jul 2025 18:11:21 +0200 Subject: [PATCH 10/10] Add concurrency test for leaving a room --- .../Tests/Signaling/RoomManagement.fs | 84 ++++++++++++++++++- .../Hubs/Signaling/RoomManagement.fs | 4 +- 2 files changed, 82 insertions(+), 6 deletions(-) diff --git a/src/Behide.OnlineServices.Tests/Tests/Signaling/RoomManagement.fs b/src/Behide.OnlineServices.Tests/Tests/Signaling/RoomManagement.fs index 3aa93ef..267cd5e 100644 --- a/src/Behide.OnlineServices.Tests/Tests/Signaling/RoomManagement.fs +++ b/src/Behide.OnlineServices.Tests/Tests/Signaling/RoomManagement.fs @@ -1,4 +1,4 @@ -module Behide.OnlineServices.Tests.Signaling.RoomManagement +module Behide.OnlineServices.Tests.Signaling.RoomManagement open Expecto open FsToolkit.ErrorHandling @@ -184,7 +184,7 @@ let tests testServer (roomStore: Hubs.Signaling.IRoomStore) = let! roomId = roomCreator.CreateRoom() |> Task.map (Flip.Expect.wantOk "Room creation should success") // Join room - let cts = new CancellationTokenSource(TimeSpan.FromSeconds 20.) + use cts = new CancellationTokenSource(TimeSpan.FromSeconds 20.) let peerIds = ConcurrentBag([ roomCreator.PlayerId, 1 ]) do! Parallel.ForEachAsync( @@ -283,7 +283,7 @@ let tests testServer (roomStore: Hubs.Signaling.IRoomStore) = |> Task.map List.ofArray // Register handlers - let cts = new CancellationTokenSource() + use cts = new CancellationTokenSource() let connAttemptIdsTask = players |> List.indexed @@ -415,7 +415,7 @@ let tests testServer (roomStore: Hubs.Signaling.IRoomStore) = ) // Connect players - let cts = new CancellationTokenSource(TimeSpan.FromSeconds 10.) + use cts = new CancellationTokenSource(TimeSpan.FromSeconds 10.) let connectionsMade = Array.init nbOfPlayers (fun _ -> Array.create nbOfPlayers false) do! Parallel.ForEachAsync( @@ -651,5 +651,81 @@ let tests testServer (roomStore: Hubs.Signaling.IRoomStore) = roomStore.Get roomId |> Flip.Expect.isNone "Room should be removed" } + + testTheoryTask + "Leave room while other players are joining" + [ (3, 1, 1) + (3, 3, 2) + (3, 2, 3) + (100, 50, 50) + (100, 100, 42) ] + (fun (initialPlayerCount, leavingPlayerCount, joiningPlayerCount) -> task { + let! inRoomPlayers = List.init initialPlayerCount (fun _ -> testServer |> connectHub) |> Task.WhenAll + let! joiningPlayers = List.init joiningPlayerCount (fun _ -> testServer |> connectHub) |> Task.WhenAll + let leavingPlayers = ArraySegment(inRoomPlayers, initialPlayerCount - leavingPlayerCount, leavingPlayerCount) + + let! roomId = inRoomPlayers[0].CreateRoom() |> Task.map (Flip.Expect.wantOk "Room creation should success") + do! inRoomPlayers + |> Array.tail + |> Array.map _.JoinRoom(roomId) + |> Task.WhenAll + |> Task.map (Array.iter (Flip.Expect.isOk "Should be able to join room")) + + use cts = new CancellationTokenSource() + let joinTcs = TaskCompletionSource() + let leaveTcs = TaskCompletionSource() + + let join () = + Parallel.ForEachAsync( + joiningPlayers, + ParallelOptions( + MaxDegreeOfParallelism = joiningPlayers.Length, + CancellationToken = cts.Token + ), + Func(fun player ct -> + player.JoinRoom(roomId) + |> Task.map (Flip.Expect.isOk "Should be able to join the room") + |> ValueTask + ) + ).ContinueWith(fun _ -> joinTcs.SetResult()) + |> ignore + let leave () = + Parallel.ForEachAsync( + leavingPlayers, + ParallelOptions( + MaxDegreeOfParallelism = joiningPlayers.Length, + CancellationToken = cts.Token + ), + Func(fun player ct -> + player.LeaveRoom() + |> Task.map (Flip.Expect.isOk "Should be able to leave the room") + |> ValueTask + ) + ).ContinueWith(fun _ -> leaveTcs.SetResult()) + |> ignore + + cts.CancelAfter(TimeSpan.FromSeconds 20.) + Parallel.Invoke(join, leave) + do! Task.WhenAll(joinTcs.Task, leaveTcs.Task) + + let room = roomStore.Get roomId |> Flip.Expect.wantSome "Room should still exist" + + // Check if players correctly leaved + Expect.all + leavingPlayers + (fun player -> room.Players.ContainsKey player.PlayerId |> not) + "All leaving players id should be absent from the room dict" + + // Check if players correctly joined + Expect.all + joiningPlayers + (fun player -> room.Players.ContainsKey player.PlayerId) + "All joining players id should be present in the room dict" + + Expect.equal + room.Players.Count + (initialPlayerCount - leavingPlayerCount + joiningPlayerCount) + "The room should have the correct number of players" + }) ] ] diff --git a/src/Behide.OnlineServices/Hubs/Signaling/RoomManagement.fs b/src/Behide.OnlineServices/Hubs/Signaling/RoomManagement.fs index c6ff7a8..23945e3 100644 --- a/src/Behide.OnlineServices/Hubs/Signaling/RoomManagement.fs +++ b/src/Behide.OnlineServices/Hubs/Signaling/RoomManagement.fs @@ -1,4 +1,4 @@ -module Behide.OnlineServices.Hubs.Signaling.RoomManagement +module Behide.OnlineServices.Hubs.Signaling.RoomManagement open Behide.OnlineServices open Behide.OnlineServices.Signaling @@ -189,7 +189,7 @@ let connectToRoomPlayers (hub: Hub) (playerStore: IPlayerStore) (_connectionAtte FailedCreations = failed |> List.toArray } } -let leaveRoom (hub: Hub) (playerStore: IPlayerStore) (_connectionAttemptStore: IConnectionAttemptStore) (roomStore: IRoomStore) = // TODO: Add tests (ex: Leave while another is connecting) +let leaveRoom (hub: Hub) (playerStore: IPlayerStore) (_connectionAttemptStore: IConnectionAttemptStore) (roomStore: IRoomStore) = taskResult { let playerId = hub.Context.ConnectionId |> PlayerId.fromHubConnectionId