summaryrefslogtreecommitdiff
path: root/tests/integration
diff options
context:
space:
mode:
authorDirk Engling <erdgeist@erdgeist.org>2026-11-03 02:40:38 +0100
committerDirk Engling <erdgeist@erdgeist.org>2026-11-03 02:40:38 +0100
commit0fcf290ba2db320287163f142a83da0f9fe48b77 (patch)
treef1737d669a0510eff32ea6e714f14804524fcb5c /tests/integration
parent21f4d0875aad382d021ff37ece09999a61fb4129 (diff)
Add integration tests for live sync
Diffstat (limited to 'tests/integration')
-rw-r--r--tests/integration/harness.py34
-rw-r--r--tests/integration/test_livesync.py121
2 files changed, 154 insertions, 1 deletions
diff --git a/tests/integration/harness.py b/tests/integration/harness.py
index b98ce83..a9d38ca 100644
--- a/tests/integration/harness.py
+++ b/tests/integration/harness.py
@@ -20,6 +20,9 @@ import urllib.error
20import urllib.parse 20import urllib.parse
21import urllib.request 21import urllib.request
22 22
23# groupip_1 in src/ot_livesync.c
24LIVESYNC_GROUP = "224.0.23.5"
25
23TRACKER = os.environ.get("OPENTRACKER", os.path.join(os.path.dirname(__file__), "..", "..", "opentracker.debug")) 26TRACKER = os.environ.get("OPENTRACKER", os.path.join(os.path.dirname(__file__), "..", "..", "opentracker.debug"))
24 27
25 28
@@ -76,6 +79,9 @@ class Tracker:
76 79
77 def __init__(self, extra_config="", files=None): 80 def __init__(self, extra_config="", files=None):
78 self.port = free_port() 81 self.port = free_port()
82 # Only used by a config that asks for live sync, but allocated either
83 # way so {livesync_port} always resolves.
84 self.livesync_port = free_port()
79 self.dir = tempfile.mkdtemp(prefix="ot-integration-") 85 self.dir = tempfile.mkdtemp(prefix="ot-integration-")
80 self.log = None 86 self.log = None
81 self.process = None 87 self.process = None
@@ -87,7 +93,7 @@ class Tracker:
87 with open(self.config, "w") as handle: 93 with open(self.config, "w") as handle:
88 handle.write("listen.tcp 127.0.0.1:%d\n" % self.port) 94 handle.write("listen.tcp 127.0.0.1:%d\n" % self.port)
89 handle.write("listen.udp 127.0.0.1:%d\n" % self.port) 95 handle.write("listen.udp 127.0.0.1:%d\n" % self.port)
90 handle.write(extra_config.replace("{dir}", self.dir)) 96 handle.write(extra_config.replace("{dir}", self.dir).replace("{livesync_port}", str(self.livesync_port)))
91 self.log = open(os.path.join(self.dir, "tracker.log"), "w+b") 97 self.log = open(os.path.join(self.dir, "tracker.log"), "w+b")
92 # Leak detection is left at the sanitizer's own default, which is on 98 # Leak detection is left at the sanitizer's own default, which is on
93 # where LeakSanitizer exists and off where it does not. Asking for it 99 # where LeakSanitizer exists and off where it does not. Asking for it
@@ -186,6 +192,32 @@ class Tracker:
186 def torrent_count(self): 192 def torrent_count(self):
187 return int(self.stats("torr").split()[0]) 193 return int(self.stats("torr").split()[0])
188 194
195 def livesync_send(self, payload):
196 """Put a packet on the live sync group.
197
198 It has to go to the group rather than straight at the port: the
199 tracker binds two sockets there, the reader on the wildcard address
200 and the writer on the configured one, and the kernel gives a unicast
201 datagram to the more specific of the two, which nobody reads."""
202 sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
203 try:
204 sock.setsockopt(socket.IPPROTO_IP, socket.IP_MULTICAST_IF, socket.inet_aton("127.0.0.1"))
205 sock.setsockopt(socket.IPPROTO_IP, socket.IP_MULTICAST_LOOP, 1)
206 sock.setsockopt(socket.IPPROTO_IP, socket.IP_MULTICAST_TTL, 1)
207 sock.sendto(payload, (LIVESYNC_GROUP, self.livesync_port))
208 finally:
209 sock.close()
210
211 def wait_until(self, predicate, timeout=5):
212 """A live sync packet is handled by another thread, so an effect of
213 one arrives some time after the send."""
214 deadline = time.time() + timeout
215 while time.time() < deadline:
216 if predicate():
217 return True
218 time.sleep(0.02)
219 return predicate()
220
189 # ------------------------------------------------------------ over udp 221 # ------------------------------------------------------------ over udp
190 222
191 def udp_connect(self): 223 def udp_connect(self):
diff --git a/tests/integration/test_livesync.py b/tests/integration/test_livesync.py
new file mode 100644
index 0000000..8dd754b
--- /dev/null
+++ b/tests/integration/test_livesync.py
@@ -0,0 +1,121 @@
1"""Live sync, driven over the multicast group a cluster would use.
2
3Live sync sits behind WANT_SYNC_LIVE, so these only run against a binary
4built with it: make check-livesync. Against any other build they skip, which
5is what the feature list in -v is for.
6"""
7
8import socket
9import struct
10import subprocess
11import unittest
12
13from harness import TRACKER, TrackerTestCase
14
15# enum { OT_SYNC_PEER4, OT_SYNC_PEER6 } in src/ot_livesync.c
16SYNC_PEER4 = 0
17SYNC_PEER6 = 1
18
19# src/trackerlogic.h
20FLAG_SEEDING = 0x80
21FLAG_COMPLETED = 0x40
22FLAG_STOPPED = 0x20
23
24
25def has_livesync():
26 done = subprocess.run([TRACKER, "-v"], capture_output=True, timeout=30)
27 return b"WANT_SYNC_LIVE" in done.stderr
28
29
30def peer4(address, port, flags):
31 """Four bytes of address, two of port, one of flags, one the tracker
32 overwrites with its own idea of the time."""
33 return socket.inet_aton(address) + struct.pack("!H", port) + bytes([flags, 0])
34
35
36def peer6(address, port, flags):
37 return socket.inet_pton(socket.AF_INET6, address) + struct.pack("!H", port) + bytes([flags, 0])
38
39
40def packet(kind, entries, tracker_id=0xDEADBEEF):
41 body = b"".join(info_hash + peer for info_hash, peer in entries)
42 return struct.pack("!I", tracker_id) + struct.pack("!I", kind) + body
43
44
45@unittest.skipUnless(has_livesync(), "this build has no WANT_SYNC_LIVE")
46class LiveSyncIn(TrackerTestCase):
47 config = "livesync.cluster.listen 127.0.0.1:{livesync_port}\nlivesync.cluster.node_ip 127.0.0.1\n"
48
49 def counts(self, info_hash):
50 """Seeders and leechers a scrape reports for one torrent."""
51 files = self.tracker.scrape(info_hash)[b"files"]
52 if info_hash not in files:
53 return None
54 entry = files[info_hash]
55 return entry[b"complete"], entry[b"incomplete"]
56
57 def test_a_synced_seeder_arrives(self):
58 info_hash = self.hash_for("sync-seed")
59 self.tracker.livesync_send(packet(SYNC_PEER4, [(info_hash, peer4("10.1.2.3", 6881, FLAG_SEEDING))]))
60 self.assertTrue(self.tracker.wait_until(lambda: self.counts(info_hash) == (1, 0)),
61 "scrape says %r" % (self.counts(info_hash),))
62
63 def test_a_synced_leecher_arrives(self):
64 info_hash = self.hash_for("sync-leech")
65 self.tracker.livesync_send(packet(SYNC_PEER4, [(info_hash, peer4("10.1.2.4", 6881, 0))]))
66 self.assertTrue(self.tracker.wait_until(lambda: self.counts(info_hash) == (0, 1)),
67 "scrape says %r" % (self.counts(info_hash),))
68
69 def test_a_v6_peer_arrives(self):
70 info_hash = self.hash_for("sync-v6")
71 self.tracker.livesync_send(packet(SYNC_PEER6, [(info_hash, peer6("2001:db8::1", 6881, FLAG_SEEDING))]))
72 self.assertTrue(self.tracker.wait_until(lambda: self.counts(info_hash) == (1, 0)),
73 "scrape says %r" % (self.counts(info_hash),))
74
75 def test_one_packet_carries_several_torrents(self):
76 hashes = [self.hash_for("sync-many-%d" % i) for i in range(4)]
77 self.tracker.livesync_send(packet(SYNC_PEER4, [(h, peer4("10.1.2.%d" % (5 + i), 6881, FLAG_SEEDING))
78 for i, h in enumerate(hashes)]))
79 self.assertTrue(self.tracker.wait_until(lambda: all(self.counts(h) == (1, 0) for h in hashes)))
80
81 def test_a_stopped_peer_is_taken_away_again(self):
82 info_hash = self.hash_for("sync-stop")
83 peer = peer4("10.1.2.9", 6881, FLAG_SEEDING)
84 self.tracker.livesync_send(packet(SYNC_PEER4, [(info_hash, peer)]))
85 self.assertTrue(self.tracker.wait_until(lambda: self.counts(info_hash) == (1, 0)))
86
87 self.tracker.livesync_send(packet(SYNC_PEER4, [(info_hash, peer4("10.1.2.9", 6881, FLAG_STOPPED))]))
88 self.assertTrue(self.tracker.wait_until(lambda: self.counts(info_hash) in (None, (0, 0))),
89 "scrape still says %r" % (self.counts(info_hash),))
90
91 def test_a_truncated_entry_is_dropped(self):
92 """The loop takes whole hash and peer pairs and leaves a short tail
93 where it is, so the complete entry before it still lands."""
94 good, short = self.hash_for("sync-good"), self.hash_for("sync-short")
95 payload = packet(SYNC_PEER4, [(good, peer4("10.1.2.10", 6881, FLAG_SEEDING))]) + short + b"\x0a\x01"
96 self.tracker.livesync_send(payload)
97 self.assertTrue(self.tracker.wait_until(lambda: self.counts(good) == (1, 0)))
98 self.assertIsNone(self.counts(short))
99
100 def test_an_unknown_packet_type_is_ignored(self):
101 info_hash = self.hash_for("sync-unknown")
102 self.tracker.livesync_send(packet(99, [(info_hash, peer4("10.1.2.11", 6881, FLAG_SEEDING))]))
103 self.assertFalse(self.tracker.wait_until(lambda: self.counts(info_hash) is not None, timeout=1))
104
105 def test_a_packet_with_nothing_but_a_header_is_ignored(self):
106 before = self.tracker.torrent_count()
107 self.tracker.livesync_send(struct.pack("!I", 0xDEADBEEF) + struct.pack("!I", SYNC_PEER4))
108 self.assertFalse(self.tracker.wait_until(lambda: self.tracker.torrent_count() != before, timeout=1))
109
110
111@unittest.skipUnless(has_livesync(), "this build has no WANT_SYNC_LIVE")
112class LiveSyncAccess(TrackerTestCase):
113 """Nothing blesses 127.0.0.1 here, so the tracker should take nothing
114 from it. Source address checking is all live sync has."""
115
116 config = "livesync.cluster.listen 127.0.0.1:{livesync_port}\nlivesync.cluster.node_ip 10.0.0.1\n"
117
118 def test_an_unblessed_sender_is_ignored(self):
119 info_hash = self.hash_for("sync-unblessed")
120 self.tracker.livesync_send(packet(SYNC_PEER4, [(info_hash, peer4("10.1.2.12", 6881, FLAG_SEEDING))]))
121 self.assertFalse(self.tracker.wait_until(lambda: self.tracker.torrent_count() != 0, timeout=1))