From fd770dc6293be67298f582de5800f2ca6fe24a8b Mon Sep 17 00:00:00 2001 From: Christophe Besson Date: Sun, 16 Aug 2026 15:28:53 +0200 Subject: feat(common): MNP 0.2 — liveness, and chat history read the way it is written MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `get_messages` pages forward from the oldest message. That is the right shape for "what happened since I last looked" and the wrong one for opening a conversation, and the browser asked it for `since=0, limit=200` — so a group with more than two hundred messages showed its first two hundred and the exchange anyone came for was unreachable. Demonstrated on 300 messages: the newest was simply absent from the answer. `get_recent` and `get_before` page backwards, cursored on the row id rather than the timestamp. Nothing makes a `time.time()` float unique, and a cursor on a value two rows can share eventually skips a message or repeats it. PING/PONG covers liveness on an already-open channel: a DataChannel whose peer vanished without closing still reads as connected, and nothing noticed until a real request hung. It is not a discovery mechanism — opening a connection to ping costs a full ICE/DTLS handshake, measured at 0.6-7 s across two ISPs — so presence in the group list comes from the hub's registry instead. Both additions are backward compatible: an 0.1 peer sends no `before` and is answered with the newest page, which is what it wanted. Co-Authored-By: Claude Opus 5 --- .../meshbay-node/tests/test_chat_pagination.py | 150 +++++++++++++++++++++ 1 file changed, 150 insertions(+) create mode 100644 packages/meshbay-node/tests/test_chat_pagination.py (limited to 'packages/meshbay-node/tests/test_chat_pagination.py') diff --git a/packages/meshbay-node/tests/test_chat_pagination.py b/packages/meshbay-node/tests/test_chat_pagination.py new file mode 100644 index 0000000..caf1f73 --- /dev/null +++ b/packages/meshbay-node/tests/test_chat_pagination.py @@ -0,0 +1,150 @@ +""" +Reading a chat backwards. + +`get_messages(since, limit)` pages *forward* from the oldest message, which is +the right shape for "what happened since I last looked" and the wrong shape for +opening a conversation. The browser asked it for `since=0, limit=200`, so a +group with more than 200 messages showed its first 200 and never the exchange +the reader came for. These tests pin the direction, because the bug was not a +crash — it was a plausible-looking screen full of the wrong messages. + +The cursor is the row id rather than the timestamp. `timestamp` is a float from +`time.time()`, so two messages saved in the same tick can share one, and a +timestamp cursor would then skip a message or return it twice. +""" + +import asyncio +from pathlib import Path + +import pytest +import pytest_asyncio + +from meshbay_node.chat.store import ChatStore + + +@pytest_asyncio.fixture +async def store(tmp_path: Path): + async with ChatStore(tmp_path / "chat.db") as s: + yield s + + +async def _fill(store, n: int) -> None: + for i in range(n): + await store.save_message(sender_id="u1", iteration=i, + payload=f"msg-{i:03d}".encode()) + + +def _texts(msgs) -> list[str]: + return [m.payload.decode() for m in msgs] + + +@pytest.mark.asyncio +async def test_recent_returns_the_newest_not_the_oldest(store): + await _fill(store, 300) + got = _texts(await store.get_recent(limit=100)) + + assert len(got) == 100 + assert got[-1] == "msg-299", "the newest message must be in the answer" + assert got[0] == "msg-200" + + +@pytest.mark.asyncio +async def test_recent_is_ordered_oldest_first_for_rendering(store): + await _fill(store, 10) + got = _texts(await store.get_recent(limit=5)) + assert got == ["msg-005", "msg-006", "msg-007", "msg-008", "msg-009"] + + +@pytest.mark.asyncio +async def test_recent_handles_fewer_messages_than_the_limit(store): + await _fill(store, 3) + assert _texts(await store.get_recent(limit=100)) == \ + ["msg-000", "msg-001", "msg-002"] + + +@pytest.mark.asyncio +async def test_recent_on_an_empty_group(store): + assert await store.get_recent(limit=100) == [] + + +@pytest.mark.asyncio +async def test_before_walks_backwards_without_gap_or_repeat(store): + """Paging to the start must show every message exactly once.""" + await _fill(store, 250) + + page = await store.get_recent(limit=100) + seen = _texts(page) + while await store.has_before(page[0].id): + page = await store.get_before(page[0].id, limit=50) + assert page, "has_before said there was more" + seen = _texts(page) + seen + + assert seen == [f"msg-{i:03d}" for i in range(250)] + assert len(seen) == len(set(seen)), "no message returned twice" + + +@pytest.mark.asyncio +async def test_before_is_ordered_oldest_first(store): + await _fill(store, 20) + recent = await store.get_recent(limit=5) + older = _texts(await store.get_before(recent[0].id, limit=5)) + assert older == ["msg-010", "msg-011", "msg-012", "msg-013", "msg-014"] + + +@pytest.mark.asyncio +async def test_before_excludes_the_cursor_message(store): + await _fill(store, 10) + recent = await store.get_recent(limit=3) + older = await store.get_before(recent[0].id, limit=10) + assert recent[0].id not in [m.id for m in older] + + +@pytest.mark.asyncio +async def test_has_before_is_false_at_the_start_of_history(store): + await _fill(store, 5) + page = await store.get_recent(limit=100) + assert await store.has_before(page[0].id) is False + + +@pytest.mark.asyncio +async def test_has_before_is_true_when_older_messages_exist(store): + await _fill(store, 200) + page = await store.get_recent(limit=100) + assert await store.has_before(page[0].id) is True + + +@pytest.mark.asyncio +async def test_messages_sharing_a_timestamp_are_still_paged_exactly_once(store): + """The reason the cursor is the row id rather than the timestamp. + + Nothing makes `timestamp` unique — it is a float from `time.time()`. In + practice `save_message` commits per row and they rarely tie, so the tie is + forced here instead of hoped for: what matters is that a shared timestamp + cannot make a page skip a message or repeat one, not how often it happens. + """ + for i in range(40): + await store._db.execute( + "INSERT INTO messages (sender_id, iteration, payload, timestamp, " + "thread_id, sender_name) VALUES (?, ?, ?, ?, ?, ?)", + ("u1", i, f"msg-{i:03d}".encode(), 1000.0, None, "")) + await store._db.commit() + + rows = await store.get_recent(limit=40) + assert len({m.timestamp for m in rows}) == 1, "the tie should be in place" + + page = await store.get_recent(limit=10) + seen = _texts(page) + while await store.has_before(page[0].id): + page = await store.get_before(page[0].id, limit=10) + seen = _texts(page) + seen + assert seen == [f"msg-{i:03d}" for i in range(40)] + + +@pytest.mark.asyncio +async def test_forward_paging_still_works_for_callers_that_want_it(store): + """`get_messages` keeps its meaning — the node UI reads "since" from it.""" + await _fill(store, 10) + all_msgs = await store.get_messages(limit=100) + cutoff = all_msgs[4].timestamp + after = await store.get_messages(since=cutoff, limit=100) + assert all(m.timestamp > cutoff for m in after) -- cgit v1.2.3