Distinguish "room full" from "connection lost" in WebSocket error handling

Fix race condition where onerror clearing m_connectedFlag before onclose
caused any network drop to be misidentified as a room-full rejection.
Add m_wasEverConnected flag set once in onopen, use it in onclose to
assign exit code 10 (room full) vs 11 (connection lost). Rename
rejected API surface to disconnected to reflect the generalized meaning.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Christian Semmler 2026-03-14 09:00:07 -07:00
parent 74271aa189
commit 680c7c28fe
No known key found for this signature in database
GPG Key ID: 086DAA1360BEEE5C
8 changed files with 33 additions and 28 deletions

View File

@ -1300,7 +1300,7 @@ inline bool IsleApp::Tick()
} }
#ifdef EXTENSIONS #ifdef EXTENSIONS
if (Extensions::IsMultiplayerRejected()) { if (Extensions::IsMultiplayerDisconnected()) {
g_closed = TRUE; g_closed = TRUE;
return true; return true;
} }

View File

@ -45,7 +45,7 @@ class MultiplayerExt {
static MxBool IsClonedCharacter(const char* p_name); static MxBool IsClonedCharacter(const char* p_name);
static void HandleBeforeSaveLoad(); static void HandleBeforeSaveLoad();
static void HandleSaveLoaded(); static void HandleSaveLoaded();
static MxBool CheckRejected(); static MxBool CheckDisconnected();
static Multiplayer::NetworkManager* GetNetworkManager(); static Multiplayer::NetworkManager* GetNetworkManager();
@ -58,7 +58,7 @@ class MultiplayerExt {
}; };
#ifdef EXTENSIONS #ifdef EXTENSIONS
LEGO1_EXPORT bool IsMultiplayerRejected(); LEGO1_EXPORT bool IsMultiplayerDisconnected();
#endif #endif
namespace MP namespace MP
@ -72,7 +72,7 @@ constexpr auto HandleROIClick = &MultiplayerExt::HandleROIClick;
constexpr auto IsClonedCharacter = &MultiplayerExt::IsClonedCharacter; constexpr auto IsClonedCharacter = &MultiplayerExt::IsClonedCharacter;
constexpr auto HandleBeforeSaveLoad = &MultiplayerExt::HandleBeforeSaveLoad; constexpr auto HandleBeforeSaveLoad = &MultiplayerExt::HandleBeforeSaveLoad;
constexpr auto HandleSaveLoaded = &MultiplayerExt::HandleSaveLoaded; constexpr auto HandleSaveLoaded = &MultiplayerExt::HandleSaveLoaded;
constexpr auto CheckRejected = &MultiplayerExt::CheckRejected; constexpr auto CheckDisconnected = &MultiplayerExt::CheckDisconnected;
#else #else
constexpr decltype(&MultiplayerExt::HandleCreate) HandleCreate = nullptr; constexpr decltype(&MultiplayerExt::HandleCreate) HandleCreate = nullptr;
constexpr decltype(&MultiplayerExt::HandleWorldEnable) HandleWorldEnable = nullptr; constexpr decltype(&MultiplayerExt::HandleWorldEnable) HandleWorldEnable = nullptr;
@ -82,7 +82,7 @@ constexpr decltype(&MultiplayerExt::HandleROIClick) HandleROIClick = nullptr;
constexpr decltype(&MultiplayerExt::IsClonedCharacter) IsClonedCharacter = nullptr; constexpr decltype(&MultiplayerExt::IsClonedCharacter) IsClonedCharacter = nullptr;
constexpr decltype(&MultiplayerExt::HandleBeforeSaveLoad) HandleBeforeSaveLoad = nullptr; constexpr decltype(&MultiplayerExt::HandleBeforeSaveLoad) HandleBeforeSaveLoad = nullptr;
constexpr decltype(&MultiplayerExt::HandleSaveLoaded) HandleSaveLoaded = nullptr; constexpr decltype(&MultiplayerExt::HandleSaveLoaded) HandleSaveLoaded = nullptr;
constexpr decltype(&MultiplayerExt::CheckRejected) CheckRejected = nullptr; constexpr decltype(&MultiplayerExt::CheckDisconnected) CheckDisconnected = nullptr;
#endif #endif
} // namespace MP } // namespace MP

View File

@ -49,7 +49,7 @@ class NetworkManager : public MxCore {
void Connect(const char* p_roomId); void Connect(const char* p_roomId);
void Disconnect(); void Disconnect();
bool IsConnected() const; bool IsConnected() const;
bool WasRejected() const; bool WasDisconnected() const;
void SetWalkAnimation(uint8_t p_walkAnimId); void SetWalkAnimation(uint8_t p_walkAnimId);
void SetIdleAnimation(uint8_t p_idleAnimId); void SetIdleAnimation(uint8_t p_idleAnimId);
@ -151,6 +151,7 @@ class NetworkManager : public MxCore {
static const uint32_t BROADCAST_INTERVAL_MS = 66; // ~15Hz static const uint32_t BROADCAST_INTERVAL_MS = 66; // ~15Hz
static const uint32_t TIMEOUT_MS = 5000; // 5 second timeout static const uint32_t TIMEOUT_MS = 5000; // 5 second timeout
static const int EXIT_ROOM_FULL = 10; static const int EXIT_ROOM_FULL = 10;
static const int EXIT_CONNECTION_LOST = 11;
}; };
} // namespace Multiplayer } // namespace Multiplayer

View File

@ -14,7 +14,7 @@ class NetworkTransport {
virtual void Connect(const char* p_roomId) = 0; virtual void Connect(const char* p_roomId) = 0;
virtual void Disconnect() = 0; virtual void Disconnect() = 0;
virtual bool IsConnected() const = 0; virtual bool IsConnected() const = 0;
virtual bool WasRejected() const = 0; virtual bool WasDisconnected() const = 0;
// Send binary data to all peers via relay // Send binary data to all peers via relay
virtual void Send(const uint8_t* p_data, size_t p_length) = 0; virtual void Send(const uint8_t* p_data, size_t p_length) = 0;

View File

@ -17,7 +17,7 @@ class WebSocketTransport : public NetworkTransport {
void Connect(const char* p_roomId) override; void Connect(const char* p_roomId) override;
void Disconnect() override; void Disconnect() override;
bool IsConnected() const override; bool IsConnected() const override;
bool WasRejected() const override; bool WasDisconnected() const override;
void Send(const uint8_t* p_data, size_t p_length) override; void Send(const uint8_t* p_data, size_t p_length) override;
size_t Receive(std::function<void(const uint8_t*, size_t)> p_callback) override; size_t Receive(std::function<void(const uint8_t*, size_t)> p_callback) override;
@ -25,7 +25,8 @@ class WebSocketTransport : public NetworkTransport {
std::string m_relayBaseUrl; std::string m_relayBaseUrl;
int m_socketId; int m_socketId;
volatile int32_t m_connectedFlag; // Shared with JS main thread via Atomics volatile int32_t m_connectedFlag; // Shared with JS main thread via Atomics
volatile int32_t m_rejectedFlag; // Set by JS when connection is rejected (e.g. room full) volatile int32_t m_disconnectedFlag; // Set by JS when connection closes (room full or lost)
volatile int32_t m_wasEverConnected; // Set once in onopen, never cleared by error/close
uint8_t m_recvBuf[8192]; uint8_t m_recvBuf[8192];
}; };

View File

@ -243,9 +243,9 @@ MxBool MultiplayerExt::IsClonedCharacter(const char* p_name)
return s_networkManager->IsClonedCharacter(p_name) ? TRUE : FALSE; return s_networkManager->IsClonedCharacter(p_name) ? TRUE : FALSE;
} }
MxBool MultiplayerExt::CheckRejected() MxBool MultiplayerExt::CheckDisconnected()
{ {
if (s_networkManager && s_networkManager->WasRejected()) { if (s_networkManager && s_networkManager->WasDisconnected()) {
return TRUE; return TRUE;
} }
@ -257,7 +257,7 @@ Multiplayer::NetworkManager* MultiplayerExt::GetNetworkManager()
return s_networkManager; return s_networkManager;
} }
bool Extensions::IsMultiplayerRejected() bool Extensions::IsMultiplayerDisconnected()
{ {
return Extension<MultiplayerExt>::Call(MP::CheckRejected).value_or(FALSE); return Extension<MultiplayerExt>::Call(MP::CheckDisconnected).value_or(FALSE);
} }

View File

@ -185,9 +185,9 @@ bool NetworkManager::IsConnected() const
return m_transport && m_transport->IsConnected(); return m_transport && m_transport->IsConnected();
} }
bool NetworkManager::WasRejected() const bool NetworkManager::WasDisconnected() const
{ {
return m_transport && m_transport->WasRejected(); return m_transport && m_transport->WasDisconnected();
} }
void NetworkManager::OnWorldEnabled(LegoWorld* p_world) void NetworkManager::OnWorldEnabled(LegoWorld* p_world)

View File

@ -9,7 +9,7 @@ namespace Multiplayer
{ {
WebSocketTransport::WebSocketTransport(const std::string& p_relayBaseUrl) WebSocketTransport::WebSocketTransport(const std::string& p_relayBaseUrl)
: m_relayBaseUrl(p_relayBaseUrl), m_socketId(-1), m_connectedFlag(0), m_rejectedFlag(0) : m_relayBaseUrl(p_relayBaseUrl), m_socketId(-1), m_connectedFlag(0), m_disconnectedFlag(0), m_wasEverConnected(0)
{ {
// clang-format off // clang-format off
MAIN_THREAD_EM_ASM({ MAIN_THREAD_EM_ASM({
@ -35,13 +35,15 @@ void WebSocketTransport::Connect(const char* p_roomId)
std::string url = m_relayBaseUrl + "/room/" + p_roomId; std::string url = m_relayBaseUrl + "/room/" + p_roomId;
m_rejectedFlag = 0; m_disconnectedFlag = 0;
m_wasEverConnected = 0;
// clang-format off // clang-format off
m_socketId = MAIN_THREAD_EM_ASM_INT({ m_socketId = MAIN_THREAD_EM_ASM_INT({
var url = UTF8ToString($0); var url = UTF8ToString($0);
var connPtr = $1; var connPtr = $1;
var rejPtr = $2; var discPtr = $2;
var everConnPtr = $3;
var socketId = Module._mpNextSocketId++; var socketId = Module._mpNextSocketId++;
Module._mpMessageQueues[socketId] = []; Module._mpMessageQueues[socketId] = [];
@ -51,6 +53,7 @@ void WebSocketTransport::Connect(const char* p_roomId)
ws.onopen = function() { ws.onopen = function() {
Atomics.store(HEAP32, connPtr >> 2, 1); Atomics.store(HEAP32, connPtr >> 2, 1);
Atomics.store(HEAP32, everConnPtr >> 2, 1);
}; };
ws.onmessage = function(event) { ws.onmessage = function(event) {
@ -61,13 +64,12 @@ void WebSocketTransport::Connect(const char* p_roomId)
}; };
ws.onclose = function() { ws.onclose = function() {
var wasConnected = Atomics.load(HEAP32, connPtr >> 2); var wasEverConnected = Atomics.load(HEAP32, everConnPtr >> 2);
Atomics.store(HEAP32, connPtr >> 2, 0); Atomics.store(HEAP32, connPtr >> 2, 0);
if (!wasConnected) { Atomics.store(HEAP32, discPtr >> 2, 1);
// Never connected — server rejected (room full / 503) // 10 = room full / server rejected before connecting
Atomics.store(HEAP32, rejPtr >> 2, 1); // 11 = connection lost after successful connect
Module._exitCode = 10; Module._exitCode = wasEverConnected ? 11 : 10;
}
}; };
ws.onerror = function() { ws.onerror = function() {
@ -81,7 +83,7 @@ void WebSocketTransport::Connect(const char* p_roomId)
} }
return socketId; return socketId;
}, url.c_str(), &m_connectedFlag, &m_rejectedFlag); }, url.c_str(), &m_connectedFlag, &m_disconnectedFlag, &m_wasEverConnected);
// clang-format on // clang-format on
if (m_socketId <= 0) { if (m_socketId <= 0) {
@ -105,6 +107,7 @@ void WebSocketTransport::Disconnect()
m_socketId = -1; m_socketId = -1;
m_connectedFlag = 0; m_connectedFlag = 0;
m_wasEverConnected = 0;
} }
} }
@ -113,9 +116,9 @@ bool WebSocketTransport::IsConnected() const
return m_socketId > 0 && m_connectedFlag != 0; return m_socketId > 0 && m_connectedFlag != 0;
} }
bool WebSocketTransport::WasRejected() const bool WebSocketTransport::WasDisconnected() const
{ {
return m_rejectedFlag != 0; return m_disconnectedFlag != 0;
} }
void WebSocketTransport::Send(const uint8_t* p_data, size_t p_length) void WebSocketTransport::Send(const uint8_t* p_data, size_t p_length)