diff --git a/meshchatx.rsm b/meshchatx.rsm index 7fe8e347..7a958cde 100644 Binary files a/meshchatx.rsm and b/meshchatx.rsm differ diff --git a/meshchatx/src/backend/rrc/manager.py b/meshchatx/src/backend/rrc/manager.py index 63207d3f..6a28895f 100644 --- a/meshchatx/src/backend/rrc/manager.py +++ b/meshchatx/src/backend/rrc/manager.py @@ -606,6 +606,25 @@ class RRCHub: raise RuntimeError(msg) RNS.Packet(link, payload).send() + def _send_env_then_maybe_record(self, env, local_msg): + """Send on the wire, then record local history if send succeeded. + + Mesh send is async, so the local echo still lands before a remote + reply. Loopback delivery is synchronous, so the echo is recorded + first or the hub notice would appear above the typed command. + """ + with self._lock: + link = self.link + loopback = isinstance(link, _LoopbackEndpoint) + if link is None or link.status != RNS.Link.ACTIVE: + msg = "not connected" + raise RuntimeError(msg) + if local_msg is not None and loopback: + self._record_message(local_msg, local=True) + self._send_env(env) + if local_msg is not None and not loopback: + self._record_message(local_msg, local=True) + def join_room(self, room, key=None, silent=False): r = proto.normalize_room(room) body = None @@ -654,20 +673,17 @@ class RRCHub: ) if nick: env[proto.K_NICK] = nick - self._send_env(env) + local_msg = None if record_local: - history_text = self._redact_command_for_history(text) - self._record_message( - proto.RRCMessage( - "msg", - r, - self.manager.identity.hash, - nick, - history_text, - proto.now_ms(), - ), - local=True, + local_msg = proto.RRCMessage( + "msg", + r, + self.manager.identity.hash, + nick, + self._redact_command_for_history(text), + proto.now_ms(), ) + self._send_env_then_maybe_record(env, local_msg) @staticmethod def _redact_command_for_history(text): @@ -735,8 +751,8 @@ class RRCHub: mid = env[proto.K_ID] if isinstance(mid, (bytes, bytearray)): self._sent_ids.append(bytes(mid)) - self._send_env(env) - self._record_message( + self._send_env_then_maybe_record( + env, proto.RRCMessage( "msg", r, @@ -745,7 +761,6 @@ class RRCHub: text, proto.now_ms(), ), - local=True, ) return mid @@ -769,8 +784,8 @@ class RRCHub: mid = env[proto.K_ID] if isinstance(mid, (bytes, bytearray)): self._sent_ids.append(bytes(mid)) - self._send_env(env) - self._record_message( + self._send_env_then_maybe_record( + env, proto.RRCMessage( "action", r, @@ -779,7 +794,6 @@ class RRCHub: text, proto.now_ms(), ), - local=True, ) return mid diff --git a/tests/backend/test_rrc_server.py b/tests/backend/test_rrc_server.py index 2eb2d30d..0f05d1b2 100644 --- a/tests/backend/test_rrc_server.py +++ b/tests/backend/test_rrc_server.py @@ -400,6 +400,32 @@ def test_loopback_nick_command_updates_override(tmp_path): assert msgs[-1].text == "nickname set to bob" +def test_mesh_command_not_recorded_when_send_fails(tmp_path): + _, hub = _loopback_client_hub(tmp_path) + hub.messages["general"] = [] + status = hub.link.status + + class MeshLink: + pass + + mesh = MeshLink() + mesh.status = status + hub.link = mesh + + def boom(_env): + raise RuntimeError("link down") + + hub._send_env = boom + raised = None + try: + hub.send_command("/help", room="general") + except RuntimeError as exc: + raised = exc + assert raised is not None + assert str(raised) == "link down" + assert hub.messages["general"] == [] + + def test_server_members_dict_all_and_per_room(tmp_path): server = make_running_server() server.register_room("general")