diff --git a/Source/include/mafianet/peer.h b/Source/include/mafianet/peer.h index 16408dd58..94f08ff98 100644 --- a/Source/include/mafianet/peer.h +++ b/Source/include/mafianet/peer.h @@ -42,6 +42,7 @@ #include "mafianet/lockless_types.h" #include "mafianet/ds_queue.h" #include +#include namespace MafiaNet { /// Forward declarations @@ -994,6 +995,19 @@ class RAK_DLL_EXPORT RakPeer : public RakPeerInterface, public RNS2EventHandler DataStructures::List banList; // Threadsafe, and not thread safe DataStructures::List pluginListTS, pluginListNTS; + // Every attached plugin again, for OnReceiveNetworkThread. The mutex lets AttachPlugin and + // DetachPlugin run while the update thread is dispatching. + DataStructures::List pluginListNetworkThread; + SimpleMutex pluginListNetworkThreadMutex; + // The thread inside DispatchNetworkThreadReceive. AttachPlugin/DetachPlugin called from a hook + // on it are ignored: they would deadlock on the lock that thread holds, and they also change + // the plugin lists Receive() walks on the user thread. + std::atomic networkThreadDispatcher; + // Set once Shutdown() starts telling plugins, cleared again once its update thread has exited. + // Starts false, so the first Startup() offers messages. Guarded by the mutex above. + bool networkThreadReceiveStopped; + bool IsInsideNetworkThreadReceive(void) const; + void SetNetworkThreadReceiveStopped(bool stopped); DataStructures::Queue requestedConnectionQueue; SimpleMutex requestedConnectionQueueMutex; @@ -1151,6 +1165,8 @@ class RAK_DLL_EXPORT RakPeer : public RakPeerInterface, public RNS2EventHandler void ResetSendReceipt(void); void OnConnectedPong(MafiaNet::Time sendPingTime, MafiaNet::Time sendPongTime, RemoteSystemStruct *remoteSystem); void CallPluginCallbacks(DataStructures::List &pluginList, Packet *packet); + // Offers a message to the network-thread plugins; true if one of them took it. + bool DispatchNetworkThreadReceive(Packet *packet); #if LIBCAT_SECURITY==1 // Encryption and security diff --git a/Source/include/mafianet/plugin_interface2.h b/Source/include/mafianet/plugin_interface2.h index 6a14a5854..c7acce9cf 100644 --- a/Source/include/mafianet/plugin_interface2.h +++ b/Source/include/mafianet/plugin_interface2.h @@ -139,6 +139,22 @@ class RAK_DLL_EXPORT PluginInterface2 /// If true, then you cannot call RakPeer::AttachPlugin() or RakPeer::DetachPlugin() for this plugin, while RakPeer is active virtual bool UsesReliabilityLayer(void) const {return false;} + /// Called on RakPeer's network update thread for each message from a connected system, before + /// it is queued for RakPeer::Receive(). Use it for traffic that must not wait for the + /// application to call Receive(), such as voice. Connection notifications are not routed here. + /// Runs concurrently with the user thread, so anything the plugin shares with its other + /// callbacks must be synchronized. RakPeer::Send() is safe to call from here; AttachPlugin() and + /// DetachPlugin() do nothing when called from here, and Shutdown() must not be called from here. + /// Not called once Shutdown() has begun calling OnRakPeerShutdown(). ID_TIMESTAMP payloads arrive + /// unshifted. A sender's messages can arrive here before the user thread has processed its + /// connection notification, so do not assume the application already knows the sender. + /// DetachPlugin() and Shutdown() wait for a call in progress, so this must never wait on a + /// thread that may be inside either of them. + /// \param[in] packet The message. RR_STOP_PROCESSING_AND_DEALLOCATE frees it, RR_STOP_PROCESSING + /// hands it to the plugin (free it with RakPeer::DeallocatePacket), and + /// RR_CONTINUE_PROCESSING queues it for Receive() as usual. + virtual PluginReceiveResult OnReceiveNetworkThread(Packet *packet) {(void) packet; return RR_CONTINUE_PROCESSING;} + /// Called on a send to the socket, per datagram, that does not go through the reliability layer /// \pre To be called, UsesReliabilityLayer() must return true /// \param[in] data The data being sent diff --git a/Source/include/mafianet/voice.h b/Source/include/mafianet/voice.h index f8e909319..cd9149847 100644 --- a/Source/include/mafianet/voice.h +++ b/Source/include/mafianet/voice.h @@ -24,6 +24,11 @@ #include "mafianet/plugin_interface2.h" #include "mafianet/ds_ordered_list.h" #include "mafianet/native_types.h" +#include "mafianet/simple_mutex.h" + +#include +#include +#include // Forward declarations for Opus and RNNoise struct OpusEncoder; @@ -306,11 +311,20 @@ class RAK_DLL_EXPORT RakVoice : public PluginInterface2 /// \return false, leaving both unchanged, if either value is outside 0..NUMBER_OF_ORDERED_STREAMS-1. bool SetOrderingChannels(char frameChannel, char controlChannel); - /// \brief Marks this peer as the relay host (the server). - /// A relay host never decodes: incoming ID_RAKVOICE_RELAY_DATA packets are passed - /// through to the application loop so they can be handed back to RelayFrame(). + /// \brief Makes this peer the relay host (the server), relaying with \a relay. + /// A relay host never decodes. Each incoming ID_RAKVOICE_RELAY_DATA packet is handed to + /// \a relay on RakPeer's network update thread and freed afterwards, so frames are forwarded + /// even while the application's main thread is busy, and never reach Receive(). + /// \a relay typically picks recipients and calls RelayFrame(), which is safe there; it must + /// not keep the packet, and whatever it reads must be safe to read from that thread. It can see + /// a sender before the application has processed that sender's connection, and must never + /// wait on a thread that may be inside SetRelayHost(), DetachPlugin() or Shutdown(), which + /// wait for it. + /// Pass nullptr to stop being a relay host. Once this returns, the previous \a relay is not + /// running and will not be called again. Called from inside \a relay, the change takes + /// effect as soon as that call returns. /// Distinct from SetRelayMode(), which is what a talking/listening client enables. - void SetRelayHost(bool enable); + void SetRelayHost(std::function relay); /// \brief Forwards a received relay frame to a set of recipients without decoding it. /// Costs one Send per recipient; no codec state is touched. @@ -352,6 +366,7 @@ class RAK_DLL_EXPORT RakVoice : public PluginInterface2 virtual void OnShutdown(void); virtual void Update(void); virtual PluginReceiveResult OnReceive(Packet *packet); + virtual PluginReceiveResult OnReceiveNetworkThread(Packet *packet); virtual void OnClosedConnection(const SystemAddress &systemAddress, RakNetGUID rakNetGUID, PI2_LostConnectionReason lostConnectionReason ); protected: @@ -405,7 +420,15 @@ class RAK_DLL_EXPORT RakVoice : public PluginInterface2 int defaultSignalType; bool loopbackMode; bool relayMode; - bool relayHost; + // Set together with relayFrameHandler; read on the network thread by OnReceiveNetworkThread. + std::atomic relayHost; + std::function relayFrameHandler; + MafiaNet::SimpleMutex relayFrameHandlerMutex; + // The thread running relayFrameHandler, and a SetRelayHost() it made from inside: applied once + // the handler returns, since replacing a std::function while it runs destroys it mid-call. + std::atomic relayThread; + std::function pendingRelay; + bool hasPendingRelay; bool perSpeakerOutput; /// Concurrent relay decoders the application allows; 0 means only the memory backstop. unsigned maxDecodedSpeakers; diff --git a/Source/src/peer.cpp b/Source/src/peer.cpp index 47e49a2be..62733002b 100644 --- a/Source/src/peer.cpp +++ b/Source/src/peer.cpp @@ -245,6 +245,7 @@ RakPeer::RakPeer() remoteSystemLookup=0; bytesSentPerSecond = bytesReceivedPerSecond = 0; endThreads = true; + networkThreadReceiveStopped = false; isMainLoopThreadActive = false; socketFactory = nullptr; manualUpdate = false; @@ -1055,6 +1056,10 @@ void RakPeer::Shutdown( unsigned int blockDuration, unsigned char orderingChanne time = MafiaNet::GetTimeMS(); } } + // Plugins tear down in OnRakPeerShutdown while the update thread may still be running; stop + // offering them messages there first, so no hook is mid-call or starts after this point. + SetNetworkThreadReceiveStopped(true); + for (i=0; i < pluginListTS.Size(); i++) { pluginListTS[i]->OnRakPeerShutdown(); @@ -1093,6 +1098,11 @@ void RakPeer::Shutdown( unsigned int blockDuration, unsigned char orderingChanne RakSleep(15); } + // The update thread has exited, so nothing can be in or enter the hook: re-arm it for the next + // Startup(). Re-arming here rather than in Startup() means the constructor's value is the one + // the first Startup() runs with. + SetNetworkThreadReceiveStopped(false); + activeSystemListSize = 0; /* @@ -2910,6 +2920,9 @@ void RakPeer::SendTTL( const char* host, unsigned short remotePort, int ttl, uns // -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- void RakPeer::AttachPlugin( PluginInterface2 *plugin ) { + if (IsInsideNetworkThreadReceive()) + return; + bool isNotThreadsafe = plugin->UsesReliabilityLayer(); if (isNotThreadsafe) { @@ -2929,6 +2942,11 @@ void RakPeer::AttachPlugin( PluginInterface2 *plugin ) pluginListTS.Insert(plugin, _FILE_AND_LINE_); } } + + pluginListNetworkThreadMutex.Lock(); + if (pluginListNetworkThread.GetIndexOf(plugin)==MAX_UNSIGNED_LONG) + pluginListNetworkThread.Insert(plugin, _FILE_AND_LINE_); + pluginListNetworkThreadMutex.Unlock(); } // -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- @@ -2938,7 +2956,7 @@ void RakPeer::AttachPlugin( PluginInterface2 *plugin ) // -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- void RakPeer::DetachPlugin( PluginInterface2 *plugin ) { - if (plugin==0) + if (plugin==0 || IsInsideNetworkThreadReceive()) return; unsigned int index; @@ -2964,6 +2982,17 @@ void RakPeer::DetachPlugin( PluginInterface2 *plugin ) pluginListTS.RemoveFromEnd(); } } + // Removed under the lock the update thread dispatches under, so once this returns the plugin + // is not inside OnReceiveNetworkThread and will not be called again. + pluginListNetworkThreadMutex.Lock(); + index = pluginListNetworkThread.GetIndexOf(plugin); + if (index!=MAX_UNSIGNED_LONG) + { + pluginListNetworkThread[index]=pluginListNetworkThread[pluginListNetworkThread.Size()-1]; + pluginListNetworkThread.RemoveFromEnd(); + } + pluginListNetworkThreadMutex.Unlock(); + plugin->OnDetach(); plugin->SetRakPeerInterface(0); } @@ -4835,6 +4864,39 @@ void RakPeer::ClearRequestedConnectionList(void) MafiaNet::OP_DELETE(freeQueue[i], _FILE_AND_LINE_ ); } } +bool RakPeer::IsInsideNetworkThreadReceive(void) const +{ + return networkThreadDispatcher.load()==std::this_thread::get_id(); +} + +void RakPeer::SetNetworkThreadReceiveStopped(bool stopped) +{ + pluginListNetworkThreadMutex.Lock(); + networkThreadReceiveStopped = stopped; + pluginListNetworkThreadMutex.Unlock(); +} + +bool RakPeer::DispatchNetworkThreadReceive(Packet *packet) +{ + pluginListNetworkThreadMutex.Lock(); + if (networkThreadReceiveStopped) + { + pluginListNetworkThreadMutex.Unlock(); + return false; + } + + networkThreadDispatcher = std::this_thread::get_id(); + PluginReceiveResult result = RR_CONTINUE_PROCESSING; + for (unsigned int i=0; i < pluginListNetworkThread.Size() && result==RR_CONTINUE_PROCESSING; i++) + result = pluginListNetworkThread[i]->OnReceiveNetworkThread(packet); + networkThreadDispatcher = std::thread::id(); + pluginListNetworkThreadMutex.Unlock(); + + if (result==RR_STOP_PROCESSING_AND_DEALLOCATE) + DeallocatePacket(packet); + return result!=RR_CONTINUE_PROCESSING; +} + inline void RakPeer::AddPacketToProducer(MafiaNet::Packet *p) { packetReturnMutex.Lock(); @@ -6935,7 +6997,8 @@ bool RakPeer::RunUpdateCycle(BitStream &updateBitStream ) packet->systemAddress.systemIndex = remoteSystem->remoteSystemIndex; packet->guid = remoteSystem->guid; packet->guid.systemIndex=packet->systemAddress.systemIndex; - AddPacketToProducer(packet); + if (!DispatchNetworkThreadReceive(packet)) + AddPacketToProducer(packet); } else { diff --git a/Source/src/voice.cpp b/Source/src/voice.cpp index 41b7f7a55..449b7c95a 100644 --- a/Source/src/voice.cpp +++ b/Source/src/voice.cpp @@ -66,6 +66,7 @@ RakVoice::RakVoice() bufferSizeBytes = 0; relayMode = false; relayHost = false; + hasPendingRelay = false; perSpeakerOutput = false; maxDecodedSpeakers = 0; relayTarget = UNASSIGNED_RAKNET_GUID; @@ -158,9 +159,22 @@ void RakVoice::SetRelayTarget(RakNetGUID server) relayTarget = server; } -void RakVoice::SetRelayHost(bool enable) +void RakVoice::SetRelayHost(std::function relay) { - relayHost = enable; + // Swapped under the lock OnReceiveNetworkThread calls it under, so a relay being replaced is + // never mid-call when this returns. + if (relayThread.load() == std::this_thread::get_id()) + { + // From inside the running relay, which holds the lock: swap once it returns. + pendingRelay = std::move(relay); + hasPendingRelay = true; + return; + } + + relayFrameHandlerMutex.Lock(); + relayHost = static_cast(relay); + relayFrameHandler = std::move(relay); + relayFrameHandlerMutex.Unlock(); } bool RakVoice::SetOrderingChannels(char frameChannel, char controlChannel) @@ -741,11 +755,10 @@ PluginReceiveResult RakVoice::OnReceive(Packet *packet) OnVoiceData(packet); return RR_STOP_PROCESSING_AND_DEALLOCATE; case ID_RAKVOICE_RELAY_DATA: - // A relay host never decodes. Plugin OnReceive runs inside RakPeer::Receive, before - // the packet reaches the application loop, so swallowing it here would leave the - // server with nothing to hand to RelayFrame(). Pass it through instead. + // A relay host never decodes. OnReceiveNetworkThread relays these before they are + // queued; one only gets here if it was queued just before SetRelayHost() took effect. if (relayHost) - return RR_CONTINUE_PROCESSING; + return RR_STOP_PROCESSING_AND_DEALLOCATE; OnRelayVoiceData(packet); return RR_STOP_PROCESSING_AND_DEALLOCATE; } @@ -753,6 +766,32 @@ PluginReceiveResult RakVoice::OnReceive(Packet *packet) return RR_CONTINUE_PROCESSING; } +PluginReceiveResult RakVoice::OnReceiveNetworkThread(Packet *packet) +{ + if (!relayHost || packet == nullptr || packet->length == 0 || packet->data[0] != ID_RAKVOICE_RELAY_DATA) + return RR_CONTINUE_PROCESSING; + + relayFrameHandlerMutex.Lock(); + // Re-checked under the lock: SetRelayHost(nullptr) may have run since the test above. + if (!relayFrameHandler) + { + relayFrameHandlerMutex.Unlock(); + return RR_CONTINUE_PROCESSING; + } + relayThread = std::this_thread::get_id(); + relayFrameHandler(packet); + relayThread = std::thread::id(); + if (hasPendingRelay) + { + relayHost = static_cast(pendingRelay); + relayFrameHandler = std::move(pendingRelay); + pendingRelay = nullptr; + hasPendingRelay = false; + } + relayFrameHandlerMutex.Unlock(); + return RR_STOP_PROCESSING_AND_DEALLOCATE; +} + void RakVoice::OnClosedConnection(const SystemAddress &systemAddress, RakNetGUID rakNetGUID, PI2_LostConnectionReason lostConnectionReason) { (void)systemAddress; diff --git a/Tests/Integration/NetworkThreadReceiveLiveTests.cpp b/Tests/Integration/NetworkThreadReceiveLiveTests.cpp new file mode 100644 index 000000000..9148fe8c4 --- /dev/null +++ b/Tests/Integration/NetworkThreadReceiveLiveTests.cpp @@ -0,0 +1,226 @@ +/* + * Copyright (c) 2026, MafiaHub + * + * This source code is licensed under the MIT-style license found in the + * license.txt file in the root directory of this source tree. + */ + +// PluginInterface2::OnReceiveNetworkThread against the real threaded update loop: +// the guarantees that only mean something when the hook runs on another thread +// than the one calling DetachPlugin() or Shutdown(). + +#include + +#include "mafianet/bit_stream.h" +#include "mafianet/get_time.h" +#include "mafianet/message_identifiers.h" +#include "mafianet/peer_handle.h" +#include "mafianet/plugin_interface2.h" +#include "mafianet/sleep.h" + +#include +#include +#include + +using namespace MafiaNet; + +namespace +{ + const MessageID ID_LIVE_NT = ID_USER_PACKET_ENUM + 30; + + // Blocks inside the hook until released, so a test can act while it is mid-call. + class BlockingProbe : public PluginInterface2 + { + public: + PluginReceiveResult OnReceiveNetworkThread(Packet *packet) override + { + if (packet->data[0] != ID_LIVE_NT) + return RR_CONTINUE_PROCESSING; + ++calls; + if (block.exchange(false)) + { + entered.set_value(); + release.get_future().wait(); + } + // Lingering inside the call makes it likely one is in flight when Shutdown() starts, + // which is the case the guarantee is about. + if (dwellMs > 0) + RakSleep(dwellMs); + if (shutdownStarted.load()) + ++callsAfterShutdownStarted; + return RR_STOP_PROCESSING_AND_DEALLOCATE; + } + + void OnRakPeerShutdown(void) override { shutdownStarted = true; } + + std::atomic block {false}; + unsigned dwellMs = 0; + std::promise entered; + std::promise release; + std::atomic calls {0}; + std::atomic shutdownStarted {false}; + std::atomic callsAfterShutdownStarted {0}; + }; + + struct LivePair + { + Peer server; + Peer client; + SystemAddress serverAddress; + + bool Start() + { + SocketDescriptor serverSd(0, "127.0.0.1"); + if (server->Startup(1, &serverSd, 1) != RAKNET_STARTED) + return false; + server->SetMaximumIncomingConnections(1); + const unsigned short port = server->GetInternalID().GetPort(); + serverAddress.SetBinaryAddress("127.0.0.1"); + serverAddress.SetPortHostOrder(port); + + RakSleep(2); // distinct microsecond-seeded GUIDs on POSIX + SocketDescriptor clientSd; + if (client->Startup(1, &clientSd, 1) != RAKNET_STARTED || + client->Connect("127.0.0.1", port, 0, 0) != CONNECTION_ATTEMPT_STARTED) + return false; + + const TimeMS start = GetTimeMS(); + while (GetTimeMS() - start < 10000 && + !(server->NumberOfConnections() == 1 && client->GetConnectionState(serverAddress) == IS_CONNECTED)) + { + for (auto pkt : client.incoming()) (void) pkt; + RakSleep(30); + } + return client->GetConnectionState(serverAddress) == IS_CONNECTED; + } + + void Send() + { + BitStream bs; + bs.Write(ID_LIVE_NT); + client->Send(&bs, Priority::High, Reliability::ReliableOrdered, 0, serverAddress, false); + } + }; +} + +class NetworkThreadReceiveLive : public ::testing::Test +{ + // Peers are RAII MafiaNet::Peer handles inside LivePair; each test declares its + // plugin before the pair so the plugin outlives the peer it is attached to. +}; + +TEST_F(NetworkThreadReceiveLive, DetachWaitsForAHookThatIsMidCall) +{ + BlockingProbe probe; + LivePair pair; + ASSERT_TRUE(pair.Start()); + probe.block = true; + pair.server->AttachPlugin(&probe); + std::future entered = probe.entered.get_future(); + + pair.Send(); + ASSERT_EQ(std::future_status::ready, entered.wait_for(std::chrono::seconds(10))); + + std::atomic detached {false}; + std::thread detacher([&] { + pair.server->DetachPlugin(&probe); + detached = true; + }); + // The hook is still running, so DetachPlugin must not have returned. + RakSleep(200); + EXPECT_FALSE(detached.load()); + + probe.release.set_value(); + detacher.join(); + EXPECT_TRUE(detached.load()); + + const int callsAtDetach = probe.calls.load(); + for (int i = 0; i < 20; ++i) + pair.Send(); + RakSleep(300); + EXPECT_EQ(callsAtDetach, probe.calls.load()); +} + +TEST_F(NetworkThreadReceiveLive, HookNeverRunsOnceShutdownHasStarted) +{ + // Plugins learn of shutdown in OnRakPeerShutdown, which the shutting-down thread + // calls while the update thread may still be running. From then on the hook must + // not be called, or a plugin tearing down its state would race its own hook. + BlockingProbe probe; + probe.dwellMs = 2; + LivePair pair; + pair.server->AttachPlugin(&probe); + ASSERT_TRUE(pair.Start()); + + std::atomic flooding {true}; + std::thread flooder([&] { + while (flooding.load()) + { + pair.Send(); + RakSleep(1); + } + }); + const TimeMS start = GetTimeMS(); + while (GetTimeMS() - start < 5000 && probe.calls.load() < 20) + RakSleep(5); + ASSERT_GE(probe.calls.load(), 20); + + pair.server->Shutdown(0); + flooding = false; + flooder.join(); + EXPECT_TRUE(probe.shutdownStarted.load()); + EXPECT_EQ(0, probe.callsAfterShutdownStarted.load()); + pair.server->DetachPlugin(&probe); +} + +TEST_F(NetworkThreadReceiveLive, HookRunsAgainAfterARestart) +{ + BlockingProbe probe; + LivePair pair; + pair.server->AttachPlugin(&probe); + ASSERT_TRUE(pair.Start()); + pair.server->Shutdown(0); + pair.client->Shutdown(0); + probe.shutdownStarted = false; + + ASSERT_TRUE(pair.Start()); + const int before = probe.calls.load(); + pair.Send(); + const TimeMS start = GetTimeMS(); + while (GetTimeMS() - start < 5000 && probe.calls.load() == before) + RakSleep(10); + EXPECT_GT(probe.calls.load(), before); + pair.server->DetachPlugin(&probe); +} + +TEST_F(NetworkThreadReceiveLive, AttachAndDetachWhileMessagesFlowIsSafe) +{ + // The user thread attaches and detaches while the update thread dispatches. + // Nothing may crash or be called after its final detach; TSan checks the locking. + BlockingProbe probe; + LivePair pair; + ASSERT_TRUE(pair.Start()); + + std::atomic flooding {true}; + std::thread flooder([&] { + while (flooding.load()) + { + pair.Send(); + RakSleep(1); + } + }); + for (int i = 0; i < 300; ++i) + { + pair.server->AttachPlugin(&probe); + RakSleep(1); + pair.server->DetachPlugin(&probe); + } + const int callsAtLastDetach = probe.calls.load(); + RakSleep(200); + flooding = false; + flooder.join(); + + EXPECT_GT(callsAtLastDetach, 0); + EXPECT_EQ(callsAtLastDetach, probe.calls.load()); + EXPECT_EQ(nullptr, probe.GetRakPeerInterface()); +} diff --git a/Tests/Integration/RakVoiceNetworkThreadRelayLiveTests.cpp b/Tests/Integration/RakVoiceNetworkThreadRelayLiveTests.cpp new file mode 100644 index 000000000..7972270b1 --- /dev/null +++ b/Tests/Integration/RakVoiceNetworkThreadRelayLiveTests.cpp @@ -0,0 +1,299 @@ +/* + * Copyright (c) 2026, MafiaHub + * + * This source code is licensed under the MIT-style license found in the + * license.txt file in the root directory of this source tree. + */ + +// A RakVoice relay host forwards voice on RakPeer's own +// update thread. The server here never calls Receive() at all -- the shape of an +// application whose main thread is stalled -- and the listener must still hear the +// talker. The unit suite proves the routing on the simulated network; this proves it +// on the real threaded update loop. + +#include + +#include "mafianet/get_time.h" +#include "mafianet/message_identifiers.h" +#include "mafianet/peer_handle.h" +#include "mafianet/sleep.h" +#include "mafianet/voice.h" + +#include +#include +#include +#include +#include + +using namespace MafiaNet; + +namespace +{ + std::vector RelayFrameFrom(RakNetGUID origin, unsigned short sequence) + { + std::vector f(RAKVOICE_RELAY_HEADER_SIZE + 16, 0x5A); + f[0] = ID_RAKVOICE_RELAY_DATA; + f[RAKVOICE_RELAY_OFFSET_VERSION] = RAKVOICE_RELAY_FORMAT_VERSION; + std::memcpy(f.data() + RAKVOICE_RELAY_OFFSET_ORIGIN, &origin.g, sizeof(uint64_t)); + const uint16_t channelId = 0; + std::memcpy(f.data() + RAKVOICE_RELAY_OFFSET_CHANNEL_ID, &channelId, sizeof(uint16_t)); + std::memcpy(f.data() + RAKVOICE_RELAY_OFFSET_SEQUENCE, &sequence, sizeof(unsigned short)); + return f; + } +} + +class RakVoiceNetworkThreadRelayLive : public ::testing::Test +{ + // Peers are RAII MafiaNet::Peer locals; the RakVoice is declared first so it + // outlives the server it is attached to. +}; + +TEST_F(RakVoiceNetworkThreadRelayLive, RelaysWhileTheServerNeverCallsReceive) +{ + RakVoice voice; + Peer server; + RakSleep(2); // distinct microsecond-seeded GUIDs on POSIX + Peer talker; + RakSleep(2); + Peer listener; + + SocketDescriptor serverSd(0, "127.0.0.1"); + ASSERT_EQ(server->Startup(2, &serverSd, 1), RAKNET_STARTED); + server->SetMaximumIncomingConnections(2); + const unsigned short serverPort = server->GetInternalID().GetPort(); + + std::atomic listenerGuid {UNASSIGNED_RAKNET_GUID.g}; + std::atomic handled {0}; + voice.SetRelayHost([&](Packet *packet) { + RakNetGUID to; + to.g = listenerGuid.load(); + voice.RelayFrame(packet, &to, 1); + ++handled; + }); + server->AttachPlugin(&voice); + + SocketDescriptor clientSd; + ASSERT_EQ(talker->Startup(1, &clientSd, 1), RAKNET_STARTED); + ASSERT_EQ(listener->Startup(1, &clientSd, 1), RAKNET_STARTED); + listenerGuid = listener->GetMyGUID().g; + ASSERT_EQ(talker->Connect("127.0.0.1", serverPort, 0, 0), CONNECTION_ATTEMPT_STARTED); + ASSERT_EQ(listener->Connect("127.0.0.1", serverPort, 0, 0), CONNECTION_ATTEMPT_STARTED); + + SystemAddress serverAddress; + serverAddress.SetBinaryAddress("127.0.0.1"); + serverAddress.SetPortHostOrder(serverPort); + + // The server is never pumped; the handshake completes on its update thread. Wait for + // both clients too: the server counts a connection before the client has seen it accepted. + auto allConnected = [&] { + return server->NumberOfConnections() == 2 && talker->GetConnectionState(serverAddress) == IS_CONNECTED && + listener->GetConnectionState(serverAddress) == IS_CONNECTED; + }; + TimeMS start = GetTimeMS(); + while (GetTimeMS() - start < 10000 && !allConnected()) + { + for (auto pkt : talker.incoming()) (void) pkt; + for (auto pkt : listener.incoming()) (void) pkt; + RakSleep(30); + } + ASSERT_TRUE(allConnected()); + + unsigned short sequence = 0; + bool heard = false; + start = GetTimeMS(); + while (GetTimeMS() - start < 10000 && !heard) + { + const std::vector frame = RelayFrameFrom(talker->GetMyGUID(), ++sequence); + talker->Send((const char *) frame.data(), (int) frame.size(), Priority::High, Reliability::Unreliable, 0, + serverAddress, false); + for (auto pkt : talker.incoming()) (void) pkt; + for (auto pkt : listener.incoming()) + { + if (pkt->length > 0 && pkt->data[0] == ID_RAKVOICE_RELAY_DATA) + { + heard = true; + EXPECT_EQ(talker->GetMyGUID(), RakVoice::ReadRelayOrigin(pkt.get())); + } + } + RakSleep(20); + } + EXPECT_TRUE(heard) << "listener never heard a frame relayed on the server's network thread"; + EXPECT_GT(handled.load(), 0); + + // Stop relaying before the locals the relay captures can go away. + voice.SetRelayHost(nullptr); + server->DetachPlugin(&voice); +} + +TEST_F(RakVoiceNetworkThreadRelayLive, ClearingTheRelayWaitsForOneThatIsMidCall) +{ + // After SetRelayHost(nullptr) returns the application may free what its relay + // captured, so the call must not return while that relay is still running. + RakVoice voice; + Peer server; + RakSleep(2); + Peer talker; + + SocketDescriptor serverSd(0, "127.0.0.1"); + ASSERT_EQ(server->Startup(1, &serverSd, 1), RAKNET_STARTED); + server->SetMaximumIncomingConnections(1); + const unsigned short serverPort = server->GetInternalID().GetPort(); + + std::promise entered, release; + std::atomic blockOnce {true}; + voice.SetRelayHost([&](Packet *) { + if (blockOnce.exchange(false)) + { + entered.set_value(); + release.get_future().wait(); + } + }); + server->AttachPlugin(&voice); + + SocketDescriptor clientSd; + ASSERT_EQ(talker->Startup(1, &clientSd, 1), RAKNET_STARTED); + ASSERT_EQ(talker->Connect("127.0.0.1", serverPort, 0, 0), CONNECTION_ATTEMPT_STARTED); + SystemAddress serverAddress; + serverAddress.SetBinaryAddress("127.0.0.1"); + serverAddress.SetPortHostOrder(serverPort); + TimeMS start = GetTimeMS(); + while (GetTimeMS() - start < 10000 && talker->GetConnectionState(serverAddress) != IS_CONNECTED) + { + for (auto pkt : talker.incoming()) (void) pkt; + RakSleep(30); + } + ASSERT_EQ(IS_CONNECTED, talker->GetConnectionState(serverAddress)); + + std::future inRelay = entered.get_future(); + unsigned short sequence = 0; + start = GetTimeMS(); + while (GetTimeMS() - start < 10000 && inRelay.wait_for(std::chrono::milliseconds(20)) != std::future_status::ready) + { + const std::vector frame = RelayFrameFrom(talker->GetMyGUID(), ++sequence); + talker->Send((const char *) frame.data(), (int) frame.size(), Priority::High, Reliability::Unreliable, 0, + serverAddress, false); + } + ASSERT_EQ(std::future_status::ready, inRelay.wait_for(std::chrono::seconds(0))); + + std::atomic cleared {false}; + std::thread clearer([&] { + voice.SetRelayHost(nullptr); + cleared = true; + }); + RakSleep(200); + EXPECT_FALSE(cleared.load()); + + release.set_value(); + clearer.join(); + EXPECT_TRUE(cleared.load()); + server->DetachPlugin(&voice); +} + +TEST_F(RakVoiceNetworkThreadRelayLive, TogglingTheRelayWhileFramesArriveIsSafe) +{ + // One thread plays the network thread, another toggles the relay as fast as it + // can. The hook must never call an emptied relay (std::bad_function_call) or see a + // half-swapped one; under TSan this also checks the state is properly guarded. + RakVoice voice; + std::atomic relayed {0}; + std::atomic threw {false}; + std::atomic done {false}; + + std::thread network([&] { + std::vector frame = RelayFrameFrom(RakNetGUID(7), 1); + Packet p {}; + p.data = frame.data(); + p.length = (unsigned int) frame.size(); + try + { + for (int i = 0; i < 200000; ++i) + voice.OnReceiveNetworkThread(&p); + } + catch (...) + { + threw = true; + } + done = true; + }); + + int toggles = 0; + while (!done.load()) + { + voice.SetRelayHost([&](Packet *) { ++relayed; }); + voice.SetRelayHost(nullptr); + ++toggles; + } + network.join(); + EXPECT_FALSE(threw.load()); + EXPECT_GT(toggles, 0); + + voice.SetRelayHost(nullptr); + const int after = relayed.load(); + std::vector frame = RelayFrameFrom(RakNetGUID(7), 1); + Packet p {}; + p.data = frame.data(); + p.length = (unsigned int) frame.size(); + EXPECT_EQ(RR_CONTINUE_PROCESSING, voice.OnReceiveNetworkThread(&p)); + EXPECT_EQ(after, relayed.load()); +} + +namespace +{ + // Reaches the relay lock so a test can hold it from another thread, the way a + // relay call in progress on the network thread would. + class RelayLockProbe : public RakVoice + { + public: + void LockRelay() { relayFrameHandlerMutex.Lock(); } + void UnlockRelay() { relayFrameHandlerMutex.Unlock(); } + }; + + // Runs voice.OnReceiveNetworkThread(frame) while another thread holds the relay + // lock. Returns whether the call finished without waiting for that lock. + bool FinishesWhileTheRelayLockIsHeld(RelayLockProbe &voice, std::vector frame) + { + std::promise locked, release; + std::thread holder([&] { + voice.LockRelay(); + locked.set_value(); + release.get_future().wait(); + voice.UnlockRelay(); + }); + locked.get_future().wait(); + + std::promise finished; + std::future done = finished.get_future(); + std::thread caller([&] { + Packet p {}; + p.data = frame.data(); + p.length = (unsigned int) frame.size(); + voice.OnReceiveNetworkThread(&p); + finished.set_value(); + }); + const bool unblocked = done.wait_for(std::chrono::seconds(2)) == std::future_status::ready; + release.set_value(); + holder.join(); + caller.join(); + return unblocked; + } +} + +TEST_F(RakVoiceNetworkThreadRelayLive, AClientNeverWaitsOnTheRelayLock) +{ + // A peer that is not a relay host must not serialize its traffic behind the relay + // lock: every message it receives goes through this hook. + RelayLockProbe voice; + EXPECT_TRUE(FinishesWhileTheRelayLockIsHeld(voice, RelayFrameFrom(RakNetGUID(7), 1))); +} + +TEST_F(RakVoiceNetworkThreadRelayLive, ARelayHostOnlyWaitsOnTheRelayLockForRelayFrames) +{ + RelayLockProbe voice; + voice.SetRelayHost([](Packet *) {}); + std::vector other {ID_RAKVOICE_DATA, 1, 2, 3}; + EXPECT_TRUE(FinishesWhileTheRelayLockIsHeld(voice, other)); + // Control: a relay frame on a relay host does wait, which is what proves the two + // checks above are exercising the lock at all. + EXPECT_FALSE(FinishesWhileTheRelayLockIsHeld(voice, RelayFrameFrom(RakNetGUID(7), 1))); + voice.SetRelayHost(nullptr); +} diff --git a/Tests/Support/SimulatedNetwork.cpp b/Tests/Support/SimulatedNetwork.cpp index 455716e93..4a85b8ec5 100644 --- a/Tests/Support/SimulatedNetwork.cpp +++ b/Tests/Support/SimulatedNetwork.cpp @@ -11,6 +11,7 @@ #include #include #include +#include #include "mafianet/assert.h" #include "mafianet/message_identifiers.h" @@ -61,7 +62,10 @@ namespace MafiaNet for (RakPeer *peer : peers) { peer->Shutdown(0); - RakPeerInterface::DestroyInstance(peer); + if (peersInStorage.count(peer) != 0) + peer->~RakPeer(); + else + RakPeerInterface::DestroyInstance(peer); } SetTimeSourceOverride(nullptr); g_networkAlive = false; @@ -79,7 +83,20 @@ namespace MafiaNet RakPeer *SimulatedNetwork::AddPeer(unsigned int maxConnections, unsigned short maxIncoming, unsigned short port) { - RakPeer *peer = static_cast(RakPeerInterface::GetInstance()); + return StartPeer(static_cast(RakPeerInterface::GetInstance()), maxConnections, maxIncoming, port); + } + + RakPeer *SimulatedNetwork::AddPeerInStorage(void *storage, unsigned int maxConnections, unsigned short maxIncoming, + unsigned short port) + { + RakPeer *peer = new (storage) RakPeer(); + peersInStorage.insert(peer); + return StartPeer(peer, maxConnections, maxIncoming, port); + } + + RakPeer *SimulatedNetwork::StartPeer(RakPeer *peer, unsigned int maxConnections, unsigned short maxIncoming, + unsigned short port) + { peer->SetSocketFactory(this); peer->SetManualUpdate(true); @@ -232,6 +249,8 @@ namespace MafiaNet for (RakPeer *peer : peers) { peer->RunManualUpdate(); + if (receiveDisabled.count(peer) != 0) + continue; for (Packet *packet = peer->Receive(); packet; packet = peer->Receive()) { Message message; @@ -246,6 +265,14 @@ namespace MafiaNet } } + void SimulatedNetwork::SetReceiveEnabled(const RakPeer *peer, bool enabled) + { + if (enabled) + receiveDisabled.erase(peer); + else + receiveDisabled.insert(peer); + } + bool SimulatedNetwork::RunUntil(const std::function &pred, uint32_t maxSimMs, uint32_t stepMs) { for (uint32_t elapsed = 0; elapsed < maxSimMs; elapsed += stepMs) diff --git a/Tests/Support/SimulatedNetwork.h b/Tests/Support/SimulatedNetwork.h index d7a0868ce..b67531c16 100644 --- a/Tests/Support/SimulatedNetwork.h +++ b/Tests/Support/SimulatedNetwork.h @@ -28,6 +28,7 @@ #include #include #include +#include #include #include "mafianet/get_time.h" @@ -66,6 +67,11 @@ namespace MafiaNet // A started peer bound to 10.0.0.: (port 0 picks one). RakPeer *AddPeer(unsigned int maxConnections, unsigned short maxIncoming = 0, unsigned short port = 0); + // The same, but the peer is constructed in caller-owned storage (at least + // sizeof(RakPeer) bytes, suitably aligned) that must outlive this network. + // Lets a test pre-fill that memory to prove the constructor sets what it must. + RakPeer *AddPeerInStorage(void *storage, unsigned int maxConnections, unsigned short maxIncoming = 0, + unsigned short port = 0); SystemAddress AddressOf(const RakPeer *peer) const; // Rules apply per direction; unset links use the default rule. @@ -80,6 +86,11 @@ namespace MafiaNet // Step until pred() holds or maxSimMs of simulated time passed. bool RunUntil(const std::function &pred, uint32_t maxSimMs, uint32_t stepMs = 10); + // While disabled, Step() still runs the peer's update cycle (its "network + // thread") but never calls its Receive(): models an application whose main + // thread has stalled. Packets queue inside the peer until re-enabled. + void SetReceiveEnabled(const RakPeer *peer, bool enabled); + std::vector &Inbox(const RakPeer *peer); // First message with this id in peer's inbox, or null. const Message *Find(const RakPeer *peer, MessageID id) const; @@ -121,12 +132,15 @@ namespace MafiaNet void Enqueue(const SystemAddress &from, const SystemAddress &to, const char *data, int length); void DeliverDue(); + RakPeer *StartPeer(RakPeer *peer, unsigned int maxConnections, unsigned short maxIncoming, unsigned short port); const LinkRule &RuleFor(const SystemAddress &from, const SystemAddress &to) const; uint32_t NextRandom(); static TimeUS VirtualClock(); std::vector peers; + std::set peersInStorage; std::map > inboxes; + std::set receiveDisabled; std::map sockets; std::map, LinkRule> links; LinkRule defaultRule; diff --git a/Tests/Unit/NetworkThreadReceiveTests.cpp b/Tests/Unit/NetworkThreadReceiveTests.cpp new file mode 100644 index 000000000..91701c16a --- /dev/null +++ b/Tests/Unit/NetworkThreadReceiveTests.cpp @@ -0,0 +1,505 @@ +/* + * Copyright (c) 2026, MafiaHub + * + * This source code is licensed under the MIT-style license found in the + * license.txt file in the root directory of this source tree. + * + * PluginInterface2::OnReceiveNetworkThread: every attached plugin sees each + * message from a connected system inside the update cycle, before it is queued + * for RakPeer::Receive(). These run on the SimulatedNetwork with the server's + * Receive() switched off, which is what a stalled application main thread looks + * like to the peer: the update cycle keeps running, Receive() is never called. + */ + +#include + +#include +#include +#include + +#include "SimulatedNetwork.h" + +#include "mafianet/bit_stream.h" +#include "mafianet/message_identifiers.h" +#include "mafianet/plugin_interface2.h" + +using namespace MafiaNet; + +namespace +{ + const MessageID ID_NT_CONSUMED = ID_USER_PACKET_ENUM + 20; + const MessageID ID_NT_PASSED = ID_USER_PACKET_ENUM + 21; + const MessageID ID_NT_KEPT = ID_USER_PACKET_ENUM + 22; + const MessageID ID_NT_ECHO = ID_USER_PACKET_ENUM + 23; + + // Consumes ID_NT_CONSUMED, keeps ID_NT_KEPT (frees it later itself), echoes + // ID_NT_ECHO back to its sender, passes everything else through. + class NetworkThreadProbe : public PluginInterface2 + { + public: + PluginReceiveResult OnReceiveNetworkThread(Packet *packet) override + { + networkThreadIds.push_back(packet->data[0]); + switch (packet->data[0]) + { + case ID_NT_CONSUMED: + return RR_STOP_PROCESSING_AND_DEALLOCATE; + case ID_NT_KEPT: + kept.push_back(packet); + return RR_STOP_PROCESSING; + case ID_NT_ECHO: + GetRakPeerInterface()->Send((const char *) packet->data, (int) packet->length, Priority::High, + Reliability::Reliable, 0, packet->guid, false); + return RR_STOP_PROCESSING_AND_DEALLOCATE; + default: + return RR_CONTINUE_PROCESSING; + } + } + + PluginReceiveResult OnReceive(Packet *packet) override + { + userThreadIds.push_back(packet->data[0]); + return RR_CONTINUE_PROCESSING; + } + + size_t NetworkThreadCount(MessageID id) const { return Count(networkThreadIds, id); } + size_t UserThreadCount(MessageID id) const { return Count(userThreadIds, id); } + + std::vector kept; + + private: + static size_t Count(const std::vector &ids, MessageID id) + { + size_t n = 0; + for (MessageID seen : ids) + n += seen == id ? 1 : 0; + return n; + } + + std::vector networkThreadIds; + std::vector userThreadIds; + }; + + // Overrides only OnReceive, like every plugin written before the hook existed. + class ReceiveOnlyPlugin : public PluginInterface2 + { + public: + PluginReceiveResult OnReceive(Packet *packet) override + { + ids.push_back(packet->data[0]); + return RR_CONTINUE_PROCESSING; + } + + std::vector ids; + }; + + void SendId(RakPeer *from, const SystemAddress &to, MessageID id) + { + BitStream bs; + bs.Write(id); + bs.Write((uint32_t) 0xC0FFEEu); + ASSERT_NE(0u, from->Send(&bs, Priority::High, Reliability::ReliableOrdered, 0, to, false)); + } + + size_t InboxCount(SimulatedNetwork &net, const RakPeer *peer, MessageID id) + { + size_t n = 0; + for (const auto &message : net.Inbox(peer)) + n += message.id == id ? 1 : 0; + return n; + } + + struct Pair + { + SimulatedNetwork net; + RakPeer *server = net.AddPeer(2, 2); + RakPeer *client = net.AddPeer(1); + + bool Start() { return net.Connect(client, server); } + }; +} + +TEST(NetworkThreadReceive, ConsumedMessageIsSeenWithoutReceiveAndNeverQueued) +{ + Pair p; + NetworkThreadProbe probe; + p.server->AttachPlugin(&probe); + ASSERT_TRUE(p.Start()); + + p.net.SetReceiveEnabled(p.server, false); + SendId(p.client, p.net.AddressOf(p.server), ID_NT_CONSUMED); + ASSERT_TRUE(p.net.RunUntil([&] { return probe.NetworkThreadCount(ID_NT_CONSUMED) == 1; }, 2000)); + + p.net.SetReceiveEnabled(p.server, true); + p.net.Step(50); + EXPECT_EQ(0u, InboxCount(p.net, p.server, ID_NT_CONSUMED)); + EXPECT_EQ(0u, probe.UserThreadCount(ID_NT_CONSUMED)); + p.server->DetachPlugin(&probe); +} + +TEST(NetworkThreadReceive, ContinuedMessageStillReachesReceiveAndOnReceive) +{ + Pair p; + NetworkThreadProbe probe; + p.server->AttachPlugin(&probe); + ASSERT_TRUE(p.Start()); + + p.net.SetReceiveEnabled(p.server, false); + SendId(p.client, p.net.AddressOf(p.server), ID_NT_PASSED); + ASSERT_TRUE(p.net.RunUntil([&] { return probe.NetworkThreadCount(ID_NT_PASSED) == 1; }, 2000)); + EXPECT_EQ(0u, InboxCount(p.net, p.server, ID_NT_PASSED)); + + p.net.SetReceiveEnabled(p.server, true); + ASSERT_TRUE(p.net.RunUntilReceived(p.server, ID_NT_PASSED, 2000)); + EXPECT_EQ(1u, InboxCount(p.net, p.server, ID_NT_PASSED)); + EXPECT_EQ(1u, probe.UserThreadCount(ID_NT_PASSED)); + EXPECT_EQ(1u, probe.NetworkThreadCount(ID_NT_PASSED)); + p.server->DetachPlugin(&probe); +} + +TEST(NetworkThreadReceive, StopProcessingHandsOwnershipToThePlugin) +{ + Pair p; + NetworkThreadProbe probe; + p.server->AttachPlugin(&probe); + ASSERT_TRUE(p.Start()); + + SendId(p.client, p.net.AddressOf(p.server), ID_NT_KEPT); + ASSERT_TRUE(p.net.RunUntil([&] { return probe.kept.size() == 1; }, 2000)); + p.net.Step(50); + EXPECT_EQ(0u, InboxCount(p.net, p.server, ID_NT_KEPT)); + + // Still valid: the peer did not free it. + Packet *kept = probe.kept.front(); + EXPECT_EQ(ID_NT_KEPT, kept->data[0]); + EXPECT_EQ(p.client->GetMyGUID(), kept->guid); + p.server->DeallocatePacket(kept); + p.server->DetachPlugin(&probe); +} + +TEST(NetworkThreadReceive, PluginCanReplyFromTheHookWhileReceiveIsStalled) +{ + Pair p; + NetworkThreadProbe probe; + p.server->AttachPlugin(&probe); + ASSERT_TRUE(p.Start()); + + p.net.SetReceiveEnabled(p.server, false); + SendId(p.client, p.net.AddressOf(p.server), ID_NT_ECHO); + EXPECT_TRUE(p.net.RunUntilReceived(p.client, ID_NT_ECHO, 2000)); + p.server->DetachPlugin(&probe); +} + +TEST(NetworkThreadReceive, PluginWithoutTheHookStillGetsMessagesOnReceive) +{ + Pair p; + ReceiveOnlyPlugin plugin; + p.server->AttachPlugin(&plugin); + ASSERT_TRUE(p.Start()); + + SendId(p.client, p.net.AddressOf(p.server), ID_NT_CONSUMED); + ASSERT_TRUE(p.net.RunUntilReceived(p.server, ID_NT_CONSUMED, 2000)); + size_t seen = 0; + for (MessageID id : plugin.ids) + seen += id == ID_NT_CONSUMED ? 1 : 0; + EXPECT_EQ(1u, seen); + p.server->DetachPlugin(&plugin); +} + +TEST(NetworkThreadReceive, DetachedPluginIsNoLongerCalled) +{ + Pair p; + NetworkThreadProbe probe; + p.server->AttachPlugin(&probe); + ASSERT_TRUE(p.Start()); + p.server->DetachPlugin(&probe); + + SendId(p.client, p.net.AddressOf(p.server), ID_NT_CONSUMED); + ASSERT_TRUE(p.net.RunUntilReceived(p.server, ID_NT_CONSUMED, 2000)); + EXPECT_EQ(0u, probe.NetworkThreadCount(ID_NT_CONSUMED)); +} + +TEST(NetworkThreadReceive, ConnectionEventsAreNotRoutedThroughTheHook) +{ + // Only application messages from connected systems go through the hook; + // lifecycle notifications stay on Receive() where plugins already expect them. + Pair p; + NetworkThreadProbe probe; + p.server->AttachPlugin(&probe); + ASSERT_TRUE(p.Start()); + EXPECT_EQ(0u, probe.NetworkThreadCount(ID_NEW_INCOMING_CONNECTION)); + EXPECT_EQ(1u, probe.UserThreadCount(ID_NEW_INCOMING_CONNECTION)); + p.server->DetachPlugin(&probe); +} + +namespace +{ + // Records the order plugins are offered a message in, and does one thing to a + // chosen id: consume it, or attach/detach a plugin from inside the hook. + class OrderProbe : public PluginInterface2 + { + public: + OrderProbe(std::vector &order, int tag) : order(order), tag(tag) {} + + PluginReceiveResult OnReceiveNetworkThread(Packet *packet) override + { + if (packet->data[0] == ID_TIMESTAMP) + sawTimestamp = true; + if (packet->data[0] < ID_USER_PACKET_ENUM && packet->data[0] != ID_TIMESTAMP) + return RR_CONTINUE_PROCESSING; + order.push_back(tag); + if (packet->data[0] == consumeId) + return RR_STOP_PROCESSING_AND_DEALLOCATE; + if (packet->data[0] == detachSelfOn) + GetRakPeerInterface()->DetachPlugin(this); + if (packet->data[0] == attachOn && toAttach != nullptr) + GetRakPeerInterface()->AttachPlugin(toAttach); + return RR_CONTINUE_PROCESSING; + } + + MessageID consumeId = 0; + MessageID detachSelfOn = 0; + MessageID attachOn = 0; + PluginInterface2 *toAttach = nullptr; + bool sawTimestamp = false; + + private: + std::vector ℴ + int tag; + }; +} + +TEST(NetworkThreadReceive, PluginsAreOfferedInAttachOrderAndTheFirstToConsumeWins) +{ + Pair p; + std::vector order; + OrderProbe first(order, 1), second(order, 2), third(order, 3); + second.consumeId = ID_NT_CONSUMED; + p.server->AttachPlugin(&first); + p.server->AttachPlugin(&second); + p.server->AttachPlugin(&third); + ASSERT_TRUE(p.Start()); + + SendId(p.client, p.net.AddressOf(p.server), ID_NT_CONSUMED); + ASSERT_TRUE(p.net.RunUntil([&] { return order.size() >= 2; }, 2000)); + p.net.Step(50); + EXPECT_EQ((std::vector {1, 2}), order); + EXPECT_EQ(0u, InboxCount(p.net, p.server, ID_NT_CONSUMED)); + p.server->DetachPlugin(&first); + p.server->DetachPlugin(&second); + p.server->DetachPlugin(&third); +} + +TEST(NetworkThreadReceive, MessageNoPluginTakesIsOfferedToEachOnceAndQueuedOnce) +{ + Pair p; + std::vector order; + OrderProbe first(order, 1), second(order, 2); + p.server->AttachPlugin(&first); + p.server->AttachPlugin(&second); + ASSERT_TRUE(p.Start()); + + SendId(p.client, p.net.AddressOf(p.server), ID_NT_PASSED); + ASSERT_TRUE(p.net.RunUntilReceived(p.server, ID_NT_PASSED, 2000)); + p.net.Step(50); + EXPECT_EQ((std::vector {1, 2}), order); + EXPECT_EQ(1u, InboxCount(p.net, p.server, ID_NT_PASSED)); + p.server->DetachPlugin(&first); + p.server->DetachPlugin(&second); +} + +TEST(NetworkThreadReceive, AttachingTheSamePluginTwiceOffersEachMessageOnce) +{ + Pair p; + std::vector order; + OrderProbe probe(order, 1); + p.server->AttachPlugin(&probe); + p.server->AttachPlugin(&probe); + ASSERT_TRUE(p.Start()); + + SendId(p.client, p.net.AddressOf(p.server), ID_NT_PASSED); + ASSERT_TRUE(p.net.RunUntilReceived(p.server, ID_NT_PASSED, 2000)); + EXPECT_EQ((std::vector {1}), order); + p.server->DetachPlugin(&probe); +} + +TEST(NetworkThreadReceive, PluginAttachedAfterTheConnectionGetsTheHook) +{ + Pair p; + ASSERT_TRUE(p.Start()); + NetworkThreadProbe probe; + p.server->AttachPlugin(&probe); + + p.net.SetReceiveEnabled(p.server, false); + SendId(p.client, p.net.AddressOf(p.server), ID_NT_CONSUMED); + EXPECT_TRUE(p.net.RunUntil([&] { return probe.NetworkThreadCount(ID_NT_CONSUMED) == 1; }, 2000)); + p.server->DetachPlugin(&probe); +} + +TEST(NetworkThreadReceive, TimestampedMessageIsOfferedUnshiftedAndStillReachesReceive) +{ + Pair p; + std::vector order; + OrderProbe probe(order, 1); + p.server->AttachPlugin(&probe); + ASSERT_TRUE(p.Start()); + + BitStream bs; + bs.Write((MessageID) ID_TIMESTAMP); + bs.Write(GetTime()); + bs.Write(ID_NT_PASSED); + ASSERT_NE(0u, p.client->Send(&bs, Priority::High, Reliability::ReliableOrdered, 0, p.net.AddressOf(p.server), false)); + ASSERT_TRUE(p.net.RunUntilReceived(p.server, ID_TIMESTAMP, 2000)); + EXPECT_TRUE(probe.sawTimestamp); + p.server->DetachPlugin(&probe); +} + +TEST(NetworkThreadReceive, DetachFromInsideTheHookIsIgnored) +{ + // Detaching there would deadlock on the dispatch lock, and would change plugin lists + // Receive() walks on the user thread. It is ignored: the plugin stays attached. + Pair p; + std::vector order; + OrderProbe probe(order, 1); + probe.detachSelfOn = ID_NT_PASSED; + p.server->AttachPlugin(&probe); + ASSERT_TRUE(p.Start()); + + SendId(p.client, p.net.AddressOf(p.server), ID_NT_PASSED); + ASSERT_TRUE(p.net.RunUntilReceived(p.server, ID_NT_PASSED, 2000)); + probe.consumeId = ID_NT_CONSUMED; + SendId(p.client, p.net.AddressOf(p.server), ID_NT_CONSUMED); + ASSERT_TRUE(p.net.RunUntil([&] { return order.size() == 2; }, 2000)); + EXPECT_EQ(p.server, probe.GetRakPeerInterface()); + p.server->DetachPlugin(&probe); + EXPECT_EQ(nullptr, probe.GetRakPeerInterface()); +} + +TEST(NetworkThreadReceive, AttachFromInsideTheHookIsIgnored) +{ + Pair p; + std::vector order; + OrderProbe attacher(order, 1), attached(order, 2); + attacher.attachOn = ID_NT_PASSED; + attacher.toAttach = &attached; + p.server->AttachPlugin(&attacher); + ASSERT_TRUE(p.Start()); + + SendId(p.client, p.net.AddressOf(p.server), ID_NT_PASSED); + ASSERT_TRUE(p.net.RunUntilReceived(p.server, ID_NT_PASSED, 2000)); + SendId(p.client, p.net.AddressOf(p.server), ID_NT_CONSUMED); + ASSERT_TRUE(p.net.RunUntilReceived(p.server, ID_NT_CONSUMED, 2000)); + EXPECT_EQ((std::vector {1, 1}), order); + EXPECT_EQ(nullptr, attached.GetRakPeerInterface()); + p.server->DetachPlugin(&attacher); +} + +namespace +{ + // RakPeer::packetsOutstanding counts packets allocated and not yet freed. Read + // through a pointer-to-member formed in a derived class, which is how a protected + // member of another object may legally be reached. + struct PacketCount : RakPeer + { + static unsigned int Outstanding(const RakPeer *peer) { return peer->*(&PacketCount::packetsOutstanding); } + }; +} + +TEST(NetworkThreadReceive, ConsumedMessagesAreFreed) +{ + Pair p; + NetworkThreadProbe probe; + p.server->AttachPlugin(&probe); + ASSERT_TRUE(p.Start()); + p.net.Step(100); + const unsigned int baseline = PacketCount::Outstanding(p.server); + + for (int i = 0; i < 20; ++i) + SendId(p.client, p.net.AddressOf(p.server), ID_NT_CONSUMED); + ASSERT_TRUE(p.net.RunUntil([&] { return probe.NetworkThreadCount(ID_NT_CONSUMED) == 20; }, 5000)); + p.net.Step(50); + EXPECT_EQ(baseline, PacketCount::Outstanding(p.server)); + p.server->DetachPlugin(&probe); +} + +TEST(NetworkThreadReceive, KeptMessageStaysAllocatedUntilThePluginFreesIt) +{ + Pair p; + NetworkThreadProbe probe; + p.server->AttachPlugin(&probe); + ASSERT_TRUE(p.Start()); + p.net.Step(100); + const unsigned int baseline = PacketCount::Outstanding(p.server); + + SendId(p.client, p.net.AddressOf(p.server), ID_NT_KEPT); + ASSERT_TRUE(p.net.RunUntil([&] { return probe.kept.size() == 1; }, 2000)); + p.net.Step(50); + EXPECT_EQ(baseline + 1, PacketCount::Outstanding(p.server)); + p.server->DeallocatePacket(probe.kept.front()); + EXPECT_EQ(baseline, PacketCount::Outstanding(p.server)); + p.server->DetachPlugin(&probe); +} + +TEST(NetworkThreadReceive, ContinuedMessagesAreFreedByReceiveAsUsual) +{ + Pair p; + NetworkThreadProbe probe; + p.server->AttachPlugin(&probe); + ASSERT_TRUE(p.Start()); + p.net.Step(100); + const unsigned int baseline = PacketCount::Outstanding(p.server); + + for (int i = 0; i < 20; ++i) + SendId(p.client, p.net.AddressOf(p.server), ID_NT_PASSED); + ASSERT_TRUE(p.net.RunUntil([&] { return InboxCount(p.net, p.server, ID_NT_PASSED) == 20; }, 5000)); + EXPECT_EQ(baseline, PacketCount::Outstanding(p.server)); + p.server->DetachPlugin(&probe); +} + +TEST(NetworkThreadReceive, HookCanSeeASenderBeforeTheApplicationSeesItConnect) +{ + // The connection notification waits in the Receive() queue, but the sender's + // messages are offered on the network thread as they arrive, so a hook must not + // assume the application already knows the sender. + Pair p; + NetworkThreadProbe probe; + p.server->AttachPlugin(&probe); + p.net.SetReceiveEnabled(p.server, false); + char host[64]; + p.net.AddressOf(p.server).ToString(false, host, sizeof(host)); + ASSERT_EQ(CONNECTION_ATTEMPT_STARTED, p.client->Connect(host, p.net.AddressOf(p.server).GetPort(), nullptr, 0)); + ASSERT_TRUE(p.net.RunUntilReceived(p.client, ID_CONNECTION_REQUEST_ACCEPTED, 5000)); + + SendId(p.client, p.net.AddressOf(p.server), ID_NT_CONSUMED); + ASSERT_TRUE(p.net.RunUntil([&] { return probe.NetworkThreadCount(ID_NT_CONSUMED) == 1; }, 2000)); + EXPECT_FALSE(p.net.Received(p.server, ID_NEW_INCOMING_CONNECTION)); + + p.net.SetReceiveEnabled(p.server, true); + EXPECT_TRUE(p.net.RunUntilReceived(p.server, ID_NEW_INCOMING_CONNECTION, 2000)); + p.server->DetachPlugin(&probe); +} + +TEST(NetworkThreadReceive, FirstStartupOffersMessagesWhateverTheMemoryHeldBefore) +{ + // The constructor must clear the hook's stop flag: the first Startup() runs with + // that value. Building the peer over memory full of set bits makes a missing + // initialisation fail every time, instead of only when the allocator leaves junk. + struct Storage + { + void *bytes = ::operator new(sizeof(RakPeer)); + Storage() { std::memset(bytes, 0xFF, sizeof(RakPeer)); } + ~Storage() { ::operator delete(bytes); } + } storage; // declared first: it must outlive the network that destroys the peer + + SimulatedNetwork net; + RakPeer *server = net.AddPeerInStorage(storage.bytes, 2, 2); + RakPeer *client = net.AddPeer(1); + NetworkThreadProbe probe; + server->AttachPlugin(&probe); + ASSERT_TRUE(net.Connect(client, server)); + + net.SetReceiveEnabled(server, false); + SendId(client, net.AddressOf(server), ID_NT_CONSUMED); + EXPECT_TRUE(net.RunUntil([&] { return probe.NetworkThreadCount(ID_NT_CONSUMED) == 1; }, 2000)); + server->DetachPlugin(&probe); +} diff --git a/Tests/Unit/RakVoiceNetworkThreadRelayTests.cpp b/Tests/Unit/RakVoiceNetworkThreadRelayTests.cpp new file mode 100644 index 000000000..1c13eafcb --- /dev/null +++ b/Tests/Unit/RakVoiceNetworkThreadRelayTests.cpp @@ -0,0 +1,432 @@ +/* + * Copyright (c) 2026, MafiaHub + * + * This source code is licensed under the MIT-style license found in the + * license.txt file in the root directory of this source tree. + * + * RakVoice::SetRelayHost: a relay host takes ID_RAKVOICE_RELAY_DATA on the + * network thread and forwards it there, so voice + * keeps flowing while the server application's main thread is stalled. The + * server's Receive() is switched off on the SimulatedNetwork to show it. + */ + +#include + +#include +#include +#include + +#include "SimulatedNetwork.h" + +#include "mafianet/message_identifiers.h" +#include "mafianet/voice.h" + +using namespace MafiaNet; + +namespace +{ + std::vector RelayFrameFrom(RakNetGUID origin, unsigned payloadBytes = 16) + { + std::vector f(RAKVOICE_RELAY_HEADER_SIZE + payloadBytes, 0x5A); + f[0] = ID_RAKVOICE_RELAY_DATA; + f[RAKVOICE_RELAY_OFFSET_VERSION] = RAKVOICE_RELAY_FORMAT_VERSION; + std::memcpy(f.data() + RAKVOICE_RELAY_OFFSET_ORIGIN, &origin.g, sizeof(uint64_t)); + const uint16_t channelId = 0; + std::memcpy(f.data() + RAKVOICE_RELAY_OFFSET_CHANNEL_ID, &channelId, sizeof(uint16_t)); + const unsigned short sequence = 1; + std::memcpy(f.data() + RAKVOICE_RELAY_OFFSET_SEQUENCE, &sequence, sizeof(unsigned short)); + return f; + } + + struct RelayRig + { + SimulatedNetwork net; + RakVoice voice; + RakPeer *server = nullptr; + RakPeer *talker = nullptr; + RakPeer *listener = nullptr; + + bool Start() + { + server = net.AddPeer(4, 4); + server->AttachPlugin(&voice); + talker = net.AddPeer(1); + listener = net.AddPeer(1); + return net.Connect(talker, server) && net.Connect(listener, server); + } + + void Talk() + { + const std::vector frame = RelayFrameFrom(talker->GetMyGUID()); + talker->Send((const char *) frame.data(), (int) frame.size(), Priority::High, Reliability::Unreliable, 0, + net.AddressOf(server), false); + } + + ~RelayRig() { server->DetachPlugin(&voice); } + }; +} + +TEST(RakVoiceNetworkThreadRelay, RelaysFramesWhileServerReceiveIsStalled) +{ + RelayRig rig; + ASSERT_TRUE(rig.Start()); + const RakNetGUID listener = rig.listener->GetMyGUID(); + rig.voice.SetRelayHost([&](Packet *packet) { rig.voice.RelayFrame(packet, &listener, 1); }); + + rig.net.SetReceiveEnabled(rig.server, false); + rig.Talk(); + ASSERT_TRUE(rig.net.RunUntilReceived(rig.listener, ID_RAKVOICE_RELAY_DATA, 2000)); + + const SimulatedNetwork::Message *relayed = rig.net.Find(rig.listener, ID_RAKVOICE_RELAY_DATA); + Packet view {}; + view.data = const_cast(relayed->data.data()); + view.length = (unsigned int) relayed->data.size(); + EXPECT_EQ(rig.talker->GetMyGUID(), RakVoice::ReadRelayOrigin(&view)); + EXPECT_EQ(rig.server->GetMyGUID(), relayed->guid); +} + +TEST(RakVoiceNetworkThreadRelay, RelayedFramesNeverReachTheApplication) +{ + RelayRig rig; + ASSERT_TRUE(rig.Start()); + std::atomic relayed {0}; + rig.voice.SetRelayHost([&](Packet *) { ++relayed; }); + + rig.Talk(); + ASSERT_TRUE(rig.net.RunUntil([&] { return relayed.load() == 1; }, 2000)); + rig.net.Step(50); + EXPECT_FALSE(rig.net.Received(rig.server, ID_RAKVOICE_RELAY_DATA)); +} + +TEST(RakVoiceNetworkThreadRelay, ClearingTheRelayHostStopsRelaying) +{ + RelayRig rig; + ASSERT_TRUE(rig.Start()); + const RakNetGUID listener = rig.listener->GetMyGUID(); + std::atomic relayed {0}; + rig.voice.SetRelayHost([&](Packet *packet) { + ++relayed; + rig.voice.RelayFrame(packet, &listener, 1); + }); + rig.voice.SetRelayHost(nullptr); + + rig.Talk(); + rig.net.Step(200); + EXPECT_EQ(0, relayed.load()); + EXPECT_FALSE(rig.net.Received(rig.listener, ID_RAKVOICE_RELAY_DATA)); +} + +// --- Direct calls: the hooks without a network ------------------------------ + +namespace +{ + Packet View(std::vector &bytes, RakNetGUID from) + { + Packet p {}; + p.data = bytes.data(); + p.length = (unsigned int) bytes.size(); + p.guid = from; + return p; + } + + RakNetGUID Guid(uint64_t g) + { + RakNetGUID guid; + guid.g = g; + return guid; + } +} + +TEST(RakVoiceNetworkThreadRelay, OnlyRelayFramesAreTakenOnTheNetworkThread) +{ + RakVoice voice; + int relayed = 0; + voice.SetRelayHost([&](Packet *) { ++relayed; }); + + for (unsigned char id : {(unsigned char) ID_RAKVOICE_DATA, (unsigned char) ID_RAKVOICE_OPEN_CHANNEL_REQUEST, + (unsigned char) ID_RAKVOICE_CLOSE_CHANNEL, (unsigned char) ID_USER_PACKET_ENUM}) + { + std::vector bytes {id, 1, 2, 3}; + Packet p = View(bytes, Guid(7)); + EXPECT_EQ(RR_CONTINUE_PROCESSING, voice.OnReceiveNetworkThread(&p)) << "id=" << int(id); + } + EXPECT_EQ(0, relayed); + + std::vector frame = RelayFrameFrom(Guid(7)); + Packet p = View(frame, Guid(7)); + EXPECT_EQ(RR_STOP_PROCESSING_AND_DEALLOCATE, voice.OnReceiveNetworkThread(&p)); + EXPECT_EQ(1, relayed); +} + +TEST(RakVoiceNetworkThreadRelay, EmptyOrNullPacketIsLeftAlone) +{ + RakVoice voice; + int relayed = 0; + voice.SetRelayHost([&](Packet *) { ++relayed; }); + + Packet empty {}; + EXPECT_EQ(RR_CONTINUE_PROCESSING, voice.OnReceiveNetworkThread(&empty)); + EXPECT_EQ(RR_CONTINUE_PROCESSING, voice.OnReceiveNetworkThread(nullptr)); + EXPECT_EQ(0, relayed); +} + +TEST(RakVoiceNetworkThreadRelay, NotARelayHostLeavesRelayFramesForReceive) +{ + RakVoice voice; + std::vector frame = RelayFrameFrom(Guid(7)); + Packet p = View(frame, Guid(7)); + EXPECT_EQ(RR_CONTINUE_PROCESSING, voice.OnReceiveNetworkThread(&p)); +} + +TEST(RakVoiceNetworkThreadRelay, TheRelayIsHandedThePacketItWasGiven) +{ + RakVoice voice; + Packet *seen = nullptr; + voice.SetRelayHost([&](Packet *packet) { seen = packet; }); + + std::vector frame = RelayFrameFrom(Guid(7)); + Packet p = View(frame, Guid(7)); + voice.OnReceiveNetworkThread(&p); + EXPECT_EQ(&p, seen); +} + +TEST(RakVoiceNetworkThreadRelay, ReplacingTheRelayRoutesLaterFramesToTheNewOne) +{ + RakVoice voice; + int first = 0, second = 0; + voice.SetRelayHost([&](Packet *) { ++first; }); + voice.SetRelayHost([&](Packet *) { ++second; }); + + std::vector frame = RelayFrameFrom(Guid(7)); + Packet p = View(frame, Guid(7)); + voice.OnReceiveNetworkThread(&p); + EXPECT_EQ(0, first); + EXPECT_EQ(1, second); +} + +TEST(RakVoiceNetworkThreadRelay, RelayCanClearItselfFromInside) +{ + // Must not deadlock, and must not destroy the running relay under itself. + RakVoice voice; + int relayed = 0; + std::vector captured(64, 1); // gives the lambda state worth corrupting + voice.SetRelayHost([&voice, &relayed, captured](Packet *) { + voice.SetRelayHost(nullptr); + relayed += captured[63]; + }); + + std::vector frame = RelayFrameFrom(Guid(7)); + Packet p = View(frame, Guid(7)); + EXPECT_EQ(RR_STOP_PROCESSING_AND_DEALLOCATE, voice.OnReceiveNetworkThread(&p)); + EXPECT_EQ(1, relayed); + EXPECT_EQ(RR_CONTINUE_PROCESSING, voice.OnReceiveNetworkThread(&p)); + EXPECT_EQ(1, relayed); +} + +TEST(RakVoiceNetworkThreadRelay, RelayCanReplaceItselfFromInside) +{ + RakVoice voice; + int first = 0, second = 0; + voice.SetRelayHost([&](Packet *) { + ++first; + voice.SetRelayHost([&](Packet *) { ++second; }); + }); + + std::vector frame = RelayFrameFrom(Guid(7)); + Packet p = View(frame, Guid(7)); + voice.OnReceiveNetworkThread(&p); + voice.OnReceiveNetworkThread(&p); + voice.OnReceiveNetworkThread(&p); + EXPECT_EQ(1, first); + EXPECT_EQ(2, second); // the swap happened once, and the new relay stays in place +} + +TEST(RakVoiceNetworkThreadRelay, RelayHostDropsAStragglerFrameThatReachedReceive) +{ + // A frame queued just before SetRelayHost took effect still reaches OnReceive; + // a relay host never decodes, so it is dropped rather than decoded or passed on. + RakVoice voice; + voice.SetRelayHost([](Packet *) {}); + std::vector frame = RelayFrameFrom(Guid(7)); + Packet p = View(frame, Guid(7)); + EXPECT_EQ(RR_STOP_PROCESSING_AND_DEALLOCATE, voice.OnReceive(&p)); +} + +// --- On the simulated network ----------------------------------------------- + +TEST(RakVoiceNetworkThreadRelay, SpoofedOriginIsNotRelayed) +{ + // The anti-impersonation check in RelayFrame must hold on the network thread too. + RelayRig rig; + ASSERT_TRUE(rig.Start()); + const RakNetGUID listener = rig.listener->GetMyGUID(); + std::atomic relayed {0}; + rig.voice.SetRelayHost([&](Packet *packet) { + ++relayed; + rig.voice.RelayFrame(packet, &listener, 1); + }); + + const std::vector spoofed = RelayFrameFrom(listener); // talker claims to be the listener + rig.talker->Send((const char *) spoofed.data(), (int) spoofed.size(), Priority::High, Reliability::Unreliable, 0, + rig.net.AddressOf(rig.server), false); + ASSERT_TRUE(rig.net.RunUntil([&] { return relayed.load() == 1; }, 2000)); + rig.net.Step(200); + EXPECT_FALSE(rig.net.Received(rig.listener, ID_RAKVOICE_RELAY_DATA)); +} + +TEST(RakVoiceNetworkThreadRelay, FrameQueuedBeforeBecomingRelayHostIsDropped) +{ + RelayRig rig; + ASSERT_TRUE(rig.Start()); + rig.net.SetReceiveEnabled(rig.server, false); + rig.Talk(); + rig.net.Step(200); // the frame is now queued for the server's Receive() + + std::atomic relayed {0}; + rig.voice.SetRelayHost([&](Packet *) { ++relayed; }); + rig.net.SetReceiveEnabled(rig.server, true); + rig.net.Step(50); + EXPECT_EQ(0, relayed.load()); + EXPECT_FALSE(rig.net.Received(rig.server, ID_RAKVOICE_RELAY_DATA)); +} + +TEST(RakVoiceNetworkThreadRelay, EveryFrameOfABurstIsRelayedInOrder) +{ + RelayRig rig; + ASSERT_TRUE(rig.Start()); + const RakNetGUID listener = rig.listener->GetMyGUID(); + rig.voice.SetRelayHost([&](Packet *packet) { rig.voice.RelayFrame(packet, &listener, 1); }); + rig.net.SetReceiveEnabled(rig.server, false); + + const unsigned short kFrames = 50; + for (unsigned short seq = 1; seq <= kFrames; ++seq) + { + std::vector frame = RelayFrameFrom(rig.talker->GetMyGUID()); + std::memcpy(frame.data() + RAKVOICE_RELAY_OFFSET_SEQUENCE, &seq, sizeof(seq)); + rig.talker->Send((const char *) frame.data(), (int) frame.size(), Priority::High, Reliability::Unreliable, 0, + rig.net.AddressOf(rig.server), false); + rig.net.Step(20); + } + rig.net.Step(200); + + std::vector sequences; + for (const auto &m : rig.net.Inbox(rig.listener)) + { + if (m.id != ID_RAKVOICE_RELAY_DATA) + continue; + unsigned short seq = 0; + std::memcpy(&seq, m.data.data() + RAKVOICE_RELAY_OFFSET_SEQUENCE, sizeof(seq)); + sequences.push_back(seq); + } + ASSERT_EQ(kFrames, sequences.size()); + for (unsigned short i = 0; i < kFrames; ++i) + EXPECT_EQ(i + 1, sequences[i]); +} + +TEST(RakVoiceNetworkThreadRelay, SetRelayHostAfterARelayCallTakesEffectAtOnce) +{ + // The relay's thread marker must be cleared when it returns, or a later + // SetRelayHost from that same thread would be parked as if made from inside. + RakVoice voice; + int first = 0, second = 0; + voice.SetRelayHost([&](Packet *) { ++first; }); + + std::vector frame = RelayFrameFrom(Guid(7)); + Packet p = View(frame, Guid(7)); + voice.OnReceiveNetworkThread(&p); + voice.SetRelayHost([&](Packet *) { ++second; }); + voice.OnReceiveNetworkThread(&p); + EXPECT_EQ(1, first); + EXPECT_EQ(1, second); + + voice.SetRelayHost(nullptr); + EXPECT_EQ(RR_CONTINUE_PROCESSING, voice.OnReceiveNetworkThread(&p)); +} + +TEST(RakVoiceNetworkThreadRelay, LastSetRelayHostFromInsideWins) +{ + RakVoice voice; + int first = 0, second = 0, third = 0; + voice.SetRelayHost([&](Packet *) { + ++first; + voice.SetRelayHost([&](Packet *) { ++second; }); + voice.SetRelayHost([&](Packet *) { ++third; }); + }); + + std::vector frame = RelayFrameFrom(Guid(7)); + Packet p = View(frame, Guid(7)); + voice.OnReceiveNetworkThread(&p); + voice.OnReceiveNetworkThread(&p); + EXPECT_EQ(1, first); + EXPECT_EQ(0, second); + EXPECT_EQ(1, third); +} + +// --- Relay host vs decoding client, observed through decoder channels ---------- +// +// An initialized RakVoice that is not a relay host decodes relay frames, which opens +// a decoder channel for the frame's origin. A relay host never does. That makes the +// relay-host state visible from outside. + +namespace +{ + bool HasSpeaker(RakVoice &voice, RakNetGUID who) + { + DataStructures::List speakers; + voice.GetActiveSpeakers(speakers); + for (unsigned i = 0; i < speakers.Size(); ++i) + if (speakers[i] == who) + return true; + return false; + } +} + +TEST(RakVoiceNetworkThreadRelay, ClearedRelayHostDecodesRelayFramesAgain) +{ + RelayRig rig; + rig.voice.Init(48000, 960 * sizeof(short)); + ASSERT_TRUE(rig.voice.IsInitialized()); + ASSERT_TRUE(rig.Start()); + rig.voice.SetRelayHost([](Packet *) {}); + rig.voice.SetRelayHost(nullptr); + + rig.Talk(); + ASSERT_TRUE(rig.net.RunUntil([&] { return HasSpeaker(rig.voice, rig.talker->GetMyGUID()); }, 2000)); +} + +TEST(RakVoiceNetworkThreadRelay, RelayHostClearedFromInsideDecodesRelayFramesAgain) +{ + RelayRig rig; + rig.voice.Init(48000, 960 * sizeof(short)); + ASSERT_TRUE(rig.voice.IsInitialized()); + ASSERT_TRUE(rig.Start()); + std::atomic relayed {0}; + rig.voice.SetRelayHost([&](Packet *) { + ++relayed; + rig.voice.SetRelayHost(nullptr); + }); + + rig.Talk(); + ASSERT_TRUE(rig.net.RunUntil([&] { return relayed.load() == 1; }, 2000)); + EXPECT_FALSE(HasSpeaker(rig.voice, rig.talker->GetMyGUID())); + rig.Talk(); + ASSERT_TRUE(rig.net.RunUntil([&] { return HasSpeaker(rig.voice, rig.talker->GetMyGUID()); }, 2000)); + EXPECT_EQ(1, relayed.load()); +} + +TEST(RakVoiceNetworkThreadRelay, RelayHostNeverDecodesEvenAStraggler) +{ + RelayRig rig; + rig.voice.Init(48000, 960 * sizeof(short)); + ASSERT_TRUE(rig.voice.IsInitialized()); + ASSERT_TRUE(rig.Start()); + rig.net.SetReceiveEnabled(rig.server, false); + rig.Talk(); + rig.net.Step(200); // queued for Receive() before this peer became a relay host + + rig.voice.SetRelayHost([](Packet *) {}); + rig.net.SetReceiveEnabled(rig.server, true); + rig.Talk(); + rig.net.Step(200); + EXPECT_FALSE(HasSpeaker(rig.voice, rig.talker->GetMyGUID())); +}