aboutsummaryrefslogtreecommitdiffstats
path: root/packages
diff options
context:
space:
mode:
authorChristophe Besson <cbesson@gmail.com>2026-10-15 00:56:04 +0200
committerChristophe Besson <cbesson@gmail.com>2026-10-15 00:56:04 +0200
commit16d92c96bde3a65fc54a93b4662df30cafc831e4 (patch)
tree4dcdfccc8ed10d8ad28e2fc1935bdb5267c3698d /packages
parent2acd1b17ad94536e4a0504087adcd9dc936896ea (diff)
downloadmeshbay-16d92c96bde3a65fc54a93b4662df30cafc831e4.tar.gz
fix(node): a chat-epoch notice answers no request, not even the asker's
Revoking a device or removing a member opens a chat epoch, announced to everyone connected to the group, the asker included. Sent inside the handler, the asker's copy was stamped with its request's id and arrived as the answer to device_revoke, before the real one. It now leaves outside the reply context. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Diffstat (limited to 'packages')
-rw-r--r--packages/meshbay-node/src/meshbay_node/transport/webrtc/chat.py26
-rw-r--r--packages/meshbay-node/tests/test_reply_correlation.py27
2 files changed, 44 insertions, 9 deletions
diff --git a/packages/meshbay-node/src/meshbay_node/transport/webrtc/chat.py b/packages/meshbay-node/src/meshbay_node/transport/webrtc/chat.py
index f200884..ed2c43a 100644
--- a/packages/meshbay-node/src/meshbay_node/transport/webrtc/chat.py
+++ b/packages/meshbay-node/src/meshbay_node/transport/webrtc/chat.py
@@ -20,6 +20,7 @@ from meshbay_common.protocol import MNP
from meshbay_node import linkpreview, ops
from meshbay_node.chat import FORMAT_SEALED_V1, ReplayedMessage
+from meshbay_node.transport.webrtc.channel import _REPLY_TO
log = logging.getLogger("meshbay_node.transport.webrtc_server")
@@ -115,15 +116,22 @@ class ChatMixin:
self._audit("chat_epoch_failed", reason)
return
self._audit("chat_epoch", f"{reason}:{result['epoch']}")
- # Everyone still connected picks the new key up without reconnecting.
- for session in list(
- (self._ctx.get("groups") or {}).get(group_id, {})
- .get("_peers", {}).values()):
- try:
- session._send({"type": MNP.CHAT_EPOCH_ACK, "v": MNP_VERSION,
- "epoch": result["epoch"]})
- except Exception:
- pass
+ # Everyone still connected picks the new key up without reconnecting —
+ # this session too, which is why the notice leaves outside the reply
+ # context: stamped with the id of the request that caused it (a device
+ # revoked, a member removed), it would arrive as that request's answer.
+ token = _REPLY_TO.set((None, None))
+ try:
+ for session in list(
+ (self._ctx.get("groups") or {}).get(group_id, {})
+ .get("_peers", {}).values()):
+ try:
+ session._send({"type": MNP.CHAT_EPOCH_ACK, "v": MNP_VERSION,
+ "epoch": result["epoch"]})
+ except Exception:
+ pass
+ finally:
+ _REPLY_TO.reset(token)
def _do_chat_directory(self, msg: dict) -> None:
"""
diff --git a/packages/meshbay-node/tests/test_reply_correlation.py b/packages/meshbay-node/tests/test_reply_correlation.py
index bfb336e..a97ec1e 100644
--- a/packages/meshbay-node/tests/test_reply_correlation.py
+++ b/packages/meshbay-node/tests/test_reply_correlation.py
@@ -28,6 +28,7 @@ import base64
import msgpack
import pytest
from meshbay_common.protocol import MNP
+from meshbay_node.transport.webrtc.channel import _REPLY_TO
from meshbay_node.transport.webrtc_server import WebRTCPeerSession
pytestmark = pytest.mark.asyncio
@@ -163,3 +164,29 @@ async def test_work_handed_to_a_task_still_answers_under_the_right_id():
by_id = {m["req_id"]: m["detail"] for m in s._channel.sent}
assert by_id == {11: "first", 12: "second"}, (
"a late reply answered under whichever request arrived most recently")
+
+
+async def test_a_new_chat_epoch_answers_no_request_not_even_the_askers():
+ """Revoking a device or removing a member opens a chat epoch, announced to
+ everyone connected — the asker included. Stamped with the asker's request,
+ that notice arrived as the answer to `device_revoke` before the real one."""
+ asker, other = _session("asker"), _session("other")
+ asker._ctx = {"groups": {"g": {"_peers": {"a": asker, "b": other}}}}
+ asker._audit = lambda *a, **k: None
+
+ async def run_op(fn, *args):
+ return {"epoch": 2}
+ asker._run_op = run_op
+
+ token = _REPLY_TO.set((asker, 9)) # inside the handler of request 9
+ try:
+ await asker._new_chat_epoch("g", "device_revoke")
+ asker._send({"type": MNP.DEVICE_ADD_ACK})
+ finally:
+ _REPLY_TO.reset(token)
+
+ notice, reply = asker._channel.sent
+ assert notice["type"] == MNP.CHAT_EPOCH_ACK and "req_id" not in notice
+ assert reply["type"] == MNP.DEVICE_ADD_ACK and reply["req_id"] == 9
+ (seen,) = other._channel.sent
+ assert "req_id" not in seen