diff options
| author | Dirk Engling <erdgeist@erdgeist.org> | 2026-11-03 02:40:38 +0100 |
|---|---|---|
| committer | Dirk Engling <erdgeist@erdgeist.org> | 2026-11-03 02:40:38 +0100 |
| commit | 0fcf290ba2db320287163f142a83da0f9fe48b77 (patch) | |
| tree | f1737d669a0510eff32ea6e714f14804524fcb5c | |
| parent | 21f4d0875aad382d021ff37ece09999a61fb4129 (diff) | |
Add integration tests for live sync
| -rw-r--r-- | Mk/tests.mk | 20 | ||||
| -rw-r--r-- | tests/integration/harness.py | 34 | ||||
| -rw-r--r-- | tests/integration/test_livesync.py | 121 |
3 files changed, 172 insertions, 3 deletions
diff --git a/Mk/tests.mk b/Mk/tests.mk index 46f308b..a7d4fbc 100644 --- a/Mk/tests.mk +++ b/Mk/tests.mk | |||
| @@ -29,7 +29,13 @@ LDFLAGS_integration=$(LINK) -fsanitize=address,undefined | |||
| 29 | CFLAGS_tsan=$(OPTS_debug) $(CPPFLAGS) $(OT_CFLAGS) $(FEATURES_integration) -fsanitize=thread -fno-omit-frame-pointer | 29 | CFLAGS_tsan=$(OPTS_debug) $(CPPFLAGS) $(OT_CFLAGS) $(FEATURES_integration) -fsanitize=thread -fno-omit-frame-pointer |
| 30 | LDFLAGS_tsan=$(LINK) -fsanitize=thread | 30 | LDFLAGS_tsan=$(LINK) -fsanitize=thread |
| 31 | 31 | ||
| 32 | BINARIES+=$(TESTBIN) $(TESTBIN).poll $(TESTBIN).sanitize opentracker.integration opentracker.tsan | 32 | # Live sync is a compile time feature, so reaching it from a black box test |
| 33 | # needs a binary of its own. tests/integration/test_livesync.py skips itself | ||
| 34 | # against a build without it, which is what -v is for. | ||
| 35 | FEATURES_livesync=$(FEATURES_integration) -DWANT_SYNC_LIVE | ||
| 36 | CFLAGS_livesync=$(OPTS_debug) $(CPPFLAGS) $(OT_CFLAGS) $(FEATURES_livesync) -fsanitize=address,undefined -fno-omit-frame-pointer | ||
| 37 | |||
| 38 | BINARIES+=$(TESTBIN) $(TESTBIN).poll $(TESTBIN).sanitize opentracker.integration opentracker.tsan opentracker.livesync | ||
| 33 | 39 | ||
| 34 | $(TESTBIN): $(TEST_SOURCES) $(TEST_UNITS) $(TEST_HEADERS) $(BUILDDEPS) | 40 | $(TESTBIN): $(TEST_SOURCES) $(TEST_UNITS) $(TEST_HEADERS) $(BUILDDEPS) |
| 35 | $(CC) -o $@ $(CFLAGS_test) $(TEST_SOURCES) $(TEST_UNITS) | 41 | $(CC) -o $@ $(CFLAGS_test) $(TEST_SOURCES) $(TEST_UNITS) |
| @@ -46,6 +52,9 @@ opentracker.integration: $(SOURCES) $(HEADERS) $(BUILDDEPS) | |||
| 46 | opentracker.tsan: $(SOURCES) $(HEADERS) $(BUILDDEPS) | 52 | opentracker.tsan: $(SOURCES) $(HEADERS) $(BUILDDEPS) |
| 47 | $(CC) -o $@ $(CFLAGS_tsan) $(SOURCES) $(LDFLAGS_tsan) | 53 | $(CC) -o $@ $(CFLAGS_tsan) $(SOURCES) $(LDFLAGS_tsan) |
| 48 | 54 | ||
| 55 | opentracker.livesync: $(SOURCES) $(HEADERS) $(BUILDDEPS) | ||
| 56 | $(CC) -o $@ $(CFLAGS_livesync) $(SOURCES) $(LDFLAGS_integration) | ||
| 57 | |||
| 49 | check: $(TESTBIN) $(BINARY) | 58 | check: $(TESTBIN) $(BINARY) |
| 50 | ./$(TESTBIN) | 59 | ./$(TESTBIN) |
| 51 | 60 | ||
| @@ -61,12 +70,19 @@ check-integration: opentracker.integration | |||
| 61 | check-tsan: opentracker.tsan | 70 | check-tsan: opentracker.tsan |
| 62 | OPENTRACKER=./opentracker.tsan $(PYTHON) -m unittest discover -s tests/integration | 71 | OPENTRACKER=./opentracker.tsan $(PYTHON) -m unittest discover -s tests/integration |
| 63 | 72 | ||
| 73 | # Only the live sync tests: a build with WANT_SYNC_LIVE refuses to start | ||
| 74 | # without a live sync address, so it cannot stand in for the plain binary in | ||
| 75 | # the rest of the suite. | ||
| 76 | check-livesync: opentracker.livesync | ||
| 77 | OPENTRACKER=./opentracker.livesync $(PYTHON) -m unittest discover -s tests/integration -p 'test_livesync.py' | ||
| 78 | |||
| 64 | # Cheapest first, so a quick failure comes back quickly. | 79 | # Cheapest first, so a quick failure comes back quickly. |
| 65 | check-all: | 80 | check-all: |
| 66 | $(MAKE) check | 81 | $(MAKE) check |
| 67 | $(MAKE) check-poll | 82 | $(MAKE) check-poll |
| 68 | $(MAKE) check-sanitize | 83 | $(MAKE) check-sanitize |
| 69 | $(MAKE) check-integration | 84 | $(MAKE) check-integration |
| 85 | $(MAKE) check-livesync | ||
| 70 | $(MAKE) check-tsan | 86 | $(MAKE) check-tsan |
| 71 | 87 | ||
| 72 | .PHONY: check check-poll check-sanitize check-integration check-tsan check-all | 88 | .PHONY: check check-poll check-sanitize check-integration check-livesync check-tsan check-all |
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 | |||
| 20 | import urllib.parse | 20 | import urllib.parse |
| 21 | import urllib.request | 21 | import urllib.request |
| 22 | 22 | ||
| 23 | # groupip_1 in src/ot_livesync.c | ||
| 24 | LIVESYNC_GROUP = "224.0.23.5" | ||
| 25 | |||
| 23 | TRACKER = os.environ.get("OPENTRACKER", os.path.join(os.path.dirname(__file__), "..", "..", "opentracker.debug")) | 26 | TRACKER = 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 | |||
| 3 | Live sync sits behind WANT_SYNC_LIVE, so these only run against a binary | ||
| 4 | built with it: make check-livesync. Against any other build they skip, which | ||
| 5 | is what the feature list in -v is for. | ||
| 6 | """ | ||
| 7 | |||
| 8 | import socket | ||
| 9 | import struct | ||
| 10 | import subprocess | ||
| 11 | import unittest | ||
| 12 | |||
| 13 | from harness import TRACKER, TrackerTestCase | ||
| 14 | |||
| 15 | # enum { OT_SYNC_PEER4, OT_SYNC_PEER6 } in src/ot_livesync.c | ||
| 16 | SYNC_PEER4 = 0 | ||
| 17 | SYNC_PEER6 = 1 | ||
| 18 | |||
| 19 | # src/trackerlogic.h | ||
| 20 | FLAG_SEEDING = 0x80 | ||
| 21 | FLAG_COMPLETED = 0x40 | ||
| 22 | FLAG_STOPPED = 0x20 | ||
| 23 | |||
| 24 | |||
| 25 | def has_livesync(): | ||
| 26 | done = subprocess.run([TRACKER, "-v"], capture_output=True, timeout=30) | ||
| 27 | return b"WANT_SYNC_LIVE" in done.stderr | ||
| 28 | |||
| 29 | |||
| 30 | def 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 | |||
| 36 | def peer6(address, port, flags): | ||
| 37 | return socket.inet_pton(socket.AF_INET6, address) + struct.pack("!H", port) + bytes([flags, 0]) | ||
| 38 | |||
| 39 | |||
| 40 | def 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") | ||
| 46 | class 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") | ||
| 112 | class 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)) | ||
