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
if (Extensions::IsMultiplayerRejected()) {
if (Extensions::IsMultiplayerDisconnected()) {
g_closed = TRUE;
return true;
}

View File

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

View File

@ -49,7 +49,7 @@ class NetworkManager : public MxCore {
void Connect(const char* p_roomId);
void Disconnect();
bool IsConnected() const;
bool WasRejected() const;
bool WasDisconnected() const;
void SetWalkAnimation(uint8_t p_walkAnimId);
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 TIMEOUT_MS = 5000; // 5 second timeout
static const int EXIT_ROOM_FULL = 10;
static const int EXIT_CONNECTION_LOST = 11;
};
} // namespace Multiplayer

View File

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

View File

@ -17,15 +17,16 @@ class WebSocketTransport : public NetworkTransport {
void Connect(const char* p_roomId) override;
void Disconnect() 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;
size_t Receive(std::function<void(const uint8_t*, size_t)> p_callback) override;
private:
std::string m_relayBaseUrl;
int m_socketId;
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_connectedFlag; // Shared with JS main thread via Atomics
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];
};

View File

@ -243,9 +243,9 @@ MxBool MultiplayerExt::IsClonedCharacter(const char* p_name)
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;
}
@ -257,7 +257,7 @@ Multiplayer::NetworkManager* MultiplayerExt::GetNetworkManager()
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();
}
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)

View File

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