diff --git a/tests/test_sync.py b/tests/test_sync.py index e1368b1..8e535cb 100644 --- a/tests/test_sync.py +++ b/tests/test_sync.py @@ -334,6 +334,50 @@ def test_sync_updated_content(): shutil.rmtree(dir_b) +def test_sync_peer_discovery(): + """Peers should discover each other through sync gossip.""" + dir_a = tempfile.mkdtemp() + dir_b = tempfile.mkdtemp() + dir_c = tempfile.mkdtemp() + try: + db_a, ha = make_handler(dir_a) + db_b, hb = make_handler(dir_b) + db_c, hc = make_handler(dir_c) + + now = "2026-06-05T12:00:00" + db_a.create_thread("t1", "From A", "", "", "", "aaa", "", now) + db_b.create_thread("t2", "From B", "", "", "", "bbb", "", now) + db_c.create_thread("t3", "From C", "", "", "", "ccc", "", now) + + # Seed: A knows B, B knows C + db_a.add_known_peer("bbb") + db_b.add_known_peer("ccc") + # C doesn't know anyone yet + + # B syncs with C — B sends its known peers (ccc's hash not in B's known peers since B is talking to C) + # Actually, B knows C (bbb -> ccc), so when B sends sync request to C, + # B includes known_peers. C learns about B. + hc.handle_sync_request({ + "query": {}, + "threads": [dict(db_b.get_thread("t2"))], + "posts": [], "upvotes": [], + "from_hash": "bbb", + "blocks": {"mine": [], "peers": []}, + "retractions": [], + "known_peers": ["aaa"], # B tells C about A + }) + + # C should now know about B (from auto-discovery of from_hash) and A (from known_peers) + known = [r["instance_hash"] for r in db_c.get_synced_instances()] + assert "bbb" in known, "C should auto-discover B from from_hash" + assert "aaa" in known, "C should discover A from known_peers gossip" + finally: + import shutil + shutil.rmtree(dir_a) + shutil.rmtree(dir_b) + shutil.rmtree(dir_c) + + if __name__ == "__main__": test_sync_thread() test_sync_reply() @@ -343,4 +387,5 @@ if __name__ == "__main__": test_sync_bidirectional() test_sync_block_gossip() test_sync_updated_content() + test_sync_peer_discovery() print("all sync tests passed") diff --git a/tinyweb_forum/db.py b/tinyweb_forum/db.py index 087d8e3..b81c8e7 100644 --- a/tinyweb_forum/db.py +++ b/tinyweb_forum/db.py @@ -246,6 +246,28 @@ class ForumDB: finally: self.return_db(db) + def add_known_peer(self, instance_hash): + """Add a discovered peer to the sync list (auto-discovery).""" + db = self.get_db() + try: + db.execute( + "INSERT OR IGNORE INTO synced_instances (instance_hash) VALUES (?)", + (instance_hash,), + ) + db.commit() + finally: + self.return_db(db) + + def get_all_known_hashes(self): + """Get all known instance hashes for peer discovery gossip.""" + db = self.get_db() + try: + return [r["instance_hash"] for r in db.execute( + "SELECT instance_hash FROM synced_instances" + ).fetchall()] + finally: + self.return_db(db) + def upsert_synced_instance(self, instance_hash, name=""): db = self.get_db() try: diff --git a/tinyweb_forum/handlers.py b/tinyweb_forum/handlers.py index 874ced7..8e45f9a 100644 --- a/tinyweb_forum/handlers.py +++ b/tinyweb_forum/handlers.py @@ -604,6 +604,14 @@ class ForumHandlers: if r.get("id") and r.get("type") and r.get("author") and r.get("at"): self.fdb.merge_retraction(r["id"], r["type"], r["author"], r["at"]) + # Auto-discover the peer that synced with us and their known peers + from_hash = data.get("from_hash", "") + if from_hash and from_hash not in blocked: + self.fdb.add_known_peer(from_hash) + for peer_hash in data.get("known_peers", []): + if peer_hash and peer_hash != from_hash and peer_hash not in blocked: + self.fdb.add_known_peer(peer_hash) + my_blocks = list(blocked) my_peer_blocks = self.fdb.get_peer_block_list() threads, posts, upvote_threads = [], [], [] @@ -616,6 +624,8 @@ class ForumHandlers: retracted = [{"id": cid, "type": ct, "author": ai, "at": ra} for cid, ct, ai, ra in self.fdb.get_raw_retractions()] + known_peers = [h for h in self.fdb.get_all_known_hashes() if h != from_hash] + return { "status": 200, "content_type": "application/json", @@ -625,6 +635,7 @@ class ForumHandlers: "upvote_threads": upvote_threads, "blocks": {"mine": my_blocks, "peers": my_peer_blocks}, "retractions": retracted, + "known_peers": known_peers, }), "headers": {}, } diff --git a/tinyweb_forum/sync.py b/tinyweb_forum/sync.py index 024aa2d..05e6f5c 100644 --- a/tinyweb_forum/sync.py +++ b/tinyweb_forum/sync.py @@ -116,14 +116,18 @@ class ForumSync: retracted = [{"id": cid, "type": ct, "author": ai, "at": ra} for cid, ct, ai, ra in self.fdb.get_raw_retractions()] + my_hash = self.identity.hash.hex() if self.identity else "local" + known_peers = [h for h in self.fdb.get_all_known_hashes() if h != instance_hash and h != my_hash] + request_data = { "query": {"since": [since]} if since else {}, "threads": threads, "posts": posts, "upvotes": upvotes, - "from_hash": self.identity.hash.hex() if self.identity else "local", + "from_hash": my_hash, "blocks": {"mine": my_blocks, "peers": my_peer_blocks}, "retractions": retracted, + "known_peers": known_peers, } receipt = link.request("/forum", data=request_data, timeout=REQUEST_TIMEOUT) @@ -162,6 +166,10 @@ class ForumSync: for r in data.get("retractions", []): if r.get("id") and r.get("type") and r.get("author") and r.get("at"): self.fdb.merge_retraction(r["id"], r["type"], r["author"], r["at"]) + # Discover new peers from gossip + for peer_hash in data.get("known_peers", []): + if peer_hash and peer_hash != my_hash and peer_hash != instance_hash: + self.fdb.add_known_peer(peer_hash) now = time.strftime("%Y-%m-%dT%H:%M:%S") self.fdb.set_last_sync(instance_hash, now) else: