diff options
Diffstat (limited to 'tests')
| -rw-r--r-- | tests/integration/harness.py | 6 | ||||
| -rw-r--r-- | tests/integration/test_concurrency.py | 209 | ||||
| -rw-r--r-- | tests/stubs.c | 3 |
3 files changed, 216 insertions, 2 deletions
diff --git a/tests/integration/harness.py b/tests/integration/harness.py index 03a0561..122c36d 100644 --- a/tests/integration/harness.py +++ b/tests/integration/harness.py | |||
| @@ -121,6 +121,7 @@ class Tracker: | |||
| 121 | all: LeakSanitizer reports from an exit handler, so a killed tracker | 121 | all: LeakSanitizer reports from an exit handler, so a killed tracker |
| 122 | reports nothing and the suite passes over leaks it never looked for. | 122 | reports nothing and the suite passes over leaks it never looked for. |
| 123 | """ | 123 | """ |
| 124 | refused_to_exit = False | ||
| 124 | if self.process is not None and self.process.poll() is None: | 125 | if self.process is not None and self.process.poll() is None: |
| 125 | self.process.send_signal(signal.SIGINT) | 126 | self.process.send_signal(signal.SIGINT) |
| 126 | try: | 127 | try: |
| @@ -128,11 +129,16 @@ class Tracker: | |||
| 128 | except subprocess.TimeoutExpired: | 129 | except subprocess.TimeoutExpired: |
| 129 | self.process.kill() | 130 | self.process.kill() |
| 130 | self.process.wait() | 131 | self.process.wait() |
| 132 | refused_to_exit = True | ||
| 131 | complaints = [ | 133 | complaints = [ |
| 132 | line | 134 | line |
| 133 | for line in self.output().splitlines() | 135 | for line in self.output().splitlines() |
| 134 | if "Sanitizer" in line or "runtime error" in line or line.startswith("ERROR") | 136 | if "Sanitizer" in line or "runtime error" in line or line.startswith("ERROR") |
| 135 | ] | 137 | ] |
| 138 | if refused_to_exit: | ||
| 139 | # Killing it throws away whatever the sanitizer had to say on the | ||
| 140 | # way out, so a test that ends this way has checked nothing. | ||
| 141 | complaints.append("the tracker did not exit within 5 s of SIGINT and had to be killed") | ||
| 136 | self._cleanup() | 142 | self._cleanup() |
| 137 | return complaints | 143 | return complaints |
| 138 | 144 | ||
diff --git a/tests/integration/test_concurrency.py b/tests/integration/test_concurrency.py new file mode 100644 index 0000000..1374852 --- /dev/null +++ b/tests/integration/test_concurrency.py | |||
| @@ -0,0 +1,209 @@ | |||
| 1 | """Several clients at once. | ||
| 2 | |||
| 3 | The rest of the suite talks to the tracker one request at a time, which | ||
| 4 | leaves the worker threads and the bucket locks almost untouched. These reach | ||
| 5 | them. | ||
| 6 | |||
| 7 | What they assert is weak on purpose. A lost update needs two threads inside | ||
| 8 | the same few instructions, which is rare enough that these tests pass | ||
| 9 | against code that has the race: measured, eight runs in a row. Their worth | ||
| 10 | is that they make the racing code run at all, so that "make check-tsan" | ||
| 11 | has something to report. Reaching the code and judging it are two jobs, and | ||
| 12 | these only do the first. | ||
| 13 | """ | ||
| 14 | |||
| 15 | import struct | ||
| 16 | import threading | ||
| 17 | import unittest | ||
| 18 | |||
| 19 | from harness import TrackerTestCase, bdecode | ||
| 20 | |||
| 21 | THREADS = 8 | ||
| 22 | PER_THREAD = 40 | ||
| 23 | |||
| 24 | |||
| 25 | def run_threads(target, count=THREADS): | ||
| 26 | errors = [] | ||
| 27 | |||
| 28 | def guarded(index): | ||
| 29 | try: | ||
| 30 | target(index) | ||
| 31 | except Exception as exception: # noqa: BLE001 - reported, not swallowed | ||
| 32 | errors.append("thread %d: %r" % (index, exception)) | ||
| 33 | |||
| 34 | threads = [threading.Thread(target=guarded, args=(i,)) for i in range(count)] | ||
| 35 | for thread in threads: | ||
| 36 | thread.start() | ||
| 37 | for thread in threads: | ||
| 38 | thread.join() | ||
| 39 | return errors | ||
| 40 | |||
| 41 | |||
| 42 | class ConcurrentAnnounce(TrackerTestCase): | ||
| 43 | def test_every_peer_of_one_swarm_arrives(self): | ||
| 44 | """One torrent, one bucket, every thread contending for its lock.""" | ||
| 45 | info_hash = self.hash_for("concurrent-one") | ||
| 46 | |||
| 47 | def announce(index): | ||
| 48 | for i in range(PER_THREAD): | ||
| 49 | self.tracker.announce(info_hash, port=10000 + index * PER_THREAD + i, left=0) | ||
| 50 | |||
| 51 | self.assertEqual(run_threads(announce), []) | ||
| 52 | answer = self.tracker.scrape(info_hash) | ||
| 53 | self.assertEqual(answer[b"files"][info_hash][b"complete"], THREADS * PER_THREAD) | ||
| 54 | |||
| 55 | def test_inserts_spread_over_buckets(self): | ||
| 56 | """Every thread inserts into a different bucket, so the bucket locks | ||
| 57 | serialise nothing between them, and the counter they all adjust is | ||
| 58 | the one global the locks do not cover. Under check-tsan this is what | ||
| 59 | names mutex_bucket_unlock if that counter stops being atomic.""" | ||
| 60 | |||
| 61 | def announce(index): | ||
| 62 | for i in range(PER_THREAD): | ||
| 63 | self.tracker.announce(struct.pack(">HH", index, i) + b"\0" * 16, port=11000 + i, left=0) | ||
| 64 | |||
| 65 | self.assertEqual(run_threads(announce), []) | ||
| 66 | self.assertEqual(self.tracker.torrent_count(), THREADS * PER_THREAD) | ||
| 67 | |||
| 68 | def test_a_swarm_that_fills_and_empties_at_once(self): | ||
| 69 | """Inserts and removals racing in the same buckets.""" | ||
| 70 | info_hash = self.hash_for("concurrent-churn") | ||
| 71 | for port in range(12000, 12000 + THREADS * PER_THREAD): | ||
| 72 | self.tracker.announce(info_hash, port=port, left=0) | ||
| 73 | |||
| 74 | def churn(index): | ||
| 75 | base = 12000 + index * PER_THREAD | ||
| 76 | for i in range(PER_THREAD): | ||
| 77 | self.tracker.announce(info_hash, port=base + i, left=0, event="stopped") | ||
| 78 | self.tracker.announce(info_hash, port=base + i, left=100) | ||
| 79 | |||
| 80 | self.assertEqual(run_threads(churn), []) | ||
| 81 | answer = self.tracker.scrape(info_hash) | ||
| 82 | self.assertEqual(answer[b"files"][info_hash][b"incomplete"], THREADS * PER_THREAD) | ||
| 83 | self.assertEqual(answer[b"files"][info_hash][b"complete"], 0) | ||
| 84 | |||
| 85 | |||
| 86 | class ConcurrentMixedBearers(TrackerTestCase): | ||
| 87 | def test_tcp_and_udp_reach_the_same_swarm(self): | ||
| 88 | info_hash = self.hash_for("concurrent-mixed") | ||
| 89 | |||
| 90 | def over_tcp(index): | ||
| 91 | for i in range(PER_THREAD): | ||
| 92 | self.tracker.announce(info_hash, port=13000 + index * PER_THREAD + i, left=0) | ||
| 93 | |||
| 94 | def over_udp(index): | ||
| 95 | sock, connection_id = self.tracker.udp_connect() | ||
| 96 | try: | ||
| 97 | for i in range(PER_THREAD): | ||
| 98 | self.tracker.udp_announce( | ||
| 99 | sock, connection_id, info_hash, left=0, port=14000 + index * PER_THREAD + i | ||
| 100 | ) | ||
| 101 | finally: | ||
| 102 | sock.close() | ||
| 103 | |||
| 104 | errors = [] | ||
| 105 | threads = [threading.Thread(target=lambda i=i: over_tcp(i)) for i in range(THREADS // 2)] | ||
| 106 | threads += [threading.Thread(target=lambda i=i: over_udp(i)) for i in range(THREADS // 2)] | ||
| 107 | for thread in threads: | ||
| 108 | thread.start() | ||
| 109 | for thread in threads: | ||
| 110 | thread.join() | ||
| 111 | |||
| 112 | self.assertEqual(errors, []) | ||
| 113 | answer = self.tracker.scrape(info_hash) | ||
| 114 | self.assertEqual(answer[b"files"][info_hash][b"complete"], THREADS * PER_THREAD) | ||
| 115 | |||
| 116 | |||
| 117 | class ConcurrentWorkerTasks(TrackerTestCase): | ||
| 118 | def test_full_scrapes_while_the_swarm_moves(self): | ||
| 119 | """The full scrape runs on a worker thread, walking the same buckets | ||
| 120 | the announces are changing.""" | ||
| 121 | for i in range(200): | ||
| 122 | self.tracker.announce(struct.pack(">I", i) + b"\0" * 16, port=15000 + i, left=0) | ||
| 123 | |||
| 124 | def announce(index): | ||
| 125 | for i in range(PER_THREAD): | ||
| 126 | self.tracker.announce(struct.pack(">I", i) + b"\0" * 16, port=16000 + index * PER_THREAD + i, left=0) | ||
| 127 | |||
| 128 | def scrape(index): | ||
| 129 | for _ in range(6): | ||
| 130 | answer = bdecode(self.tracker.get("/scrape")) | ||
| 131 | for counts in answer[b"files"].values(): | ||
| 132 | if counts[b"complete"] < 0 or counts[b"incomplete"] < 0: | ||
| 133 | raise AssertionError("negative count in a full scrape: %r" % counts) | ||
| 134 | |||
| 135 | threads = [threading.Thread(target=announce, args=(i,)) for i in range(THREADS // 2)] | ||
| 136 | threads += [threading.Thread(target=scrape, args=(i,)) for i in range(2)] | ||
| 137 | for thread in threads: | ||
| 138 | thread.start() | ||
| 139 | for thread in threads: | ||
| 140 | thread.join() | ||
| 141 | |||
| 142 | self.assertEqual(self.tracker.torrent_count(), 200) | ||
| 143 | |||
| 144 | def test_stats_while_the_swarm_moves(self): | ||
| 145 | def announce(index): | ||
| 146 | for i in range(PER_THREAD): | ||
| 147 | self.tracker.announce(struct.pack(">HH", index, i) + b"\0" * 16, port=17000 + i, left=i % 2) | ||
| 148 | |||
| 149 | def poll(index): | ||
| 150 | for _ in range(10): | ||
| 151 | for mode in ("everything", "top100", "peer", "torr", "s24s"): | ||
| 152 | self.tracker.stats(mode) | ||
| 153 | |||
| 154 | threads = [threading.Thread(target=announce, args=(i,)) for i in range(THREADS // 2)] | ||
| 155 | threads += [threading.Thread(target=poll, args=(i,)) for i in range(2)] | ||
| 156 | for thread in threads: | ||
| 157 | thread.start() | ||
| 158 | for thread in threads: | ||
| 159 | thread.join() | ||
| 160 | |||
| 161 | self.assertEqual(self.tracker.torrent_count(), (THREADS // 2) * PER_THREAD) | ||
| 162 | |||
| 163 | |||
| 164 | class CountersAcrossContexts(TrackerTestCase): | ||
| 165 | """With udp workers configured, every one of them counts into a block of | ||
| 166 | its own, and the figure reported is those blocks added up. A registry | ||
| 167 | that lost a block, or a worker that wrote into somebody else's, would | ||
| 168 | show here and nowhere else in the suite: without this directive udp is | ||
| 169 | served by the main loop, which is a single context.""" | ||
| 170 | |||
| 171 | config = "listen.udp.workers 4\n" | ||
| 172 | |||
| 173 | def test_the_udp_count_is_the_sum_of_every_worker(self): | ||
| 174 | before = int(self.tracker.stats("udp4").splitlines()[0]) | ||
| 175 | |||
| 176 | packets = 0 | ||
| 177 | for round_ in range(6): | ||
| 178 | sock, connection_id = self.tracker.udp_connect() # one packet | ||
| 179 | packets += 1 | ||
| 180 | try: | ||
| 181 | for i in range(10): | ||
| 182 | self.tracker.udp_announce(sock, connection_id, self.hash_for("ctx%d" % i), left=0) | ||
| 183 | packets += 1 | ||
| 184 | finally: | ||
| 185 | sock.close() | ||
| 186 | |||
| 187 | after = int(self.tracker.stats("udp4").splitlines()[0]) | ||
| 188 | self.assertEqual(after - before, packets, "every packet counted exactly once") | ||
| 189 | |||
| 190 | def test_announces_from_several_workers_reach_one_swarm(self): | ||
| 191 | info_hash = self.hash_for("ctx-swarm") | ||
| 192 | |||
| 193 | def announce(index): | ||
| 194 | sock, connection_id = self.tracker.udp_connect() | ||
| 195 | try: | ||
| 196 | for i in range(PER_THREAD): | ||
| 197 | self.tracker.udp_announce( | ||
| 198 | sock, connection_id, info_hash, left=0, port=18000 + index * PER_THREAD + i | ||
| 199 | ) | ||
| 200 | finally: | ||
| 201 | sock.close() | ||
| 202 | |||
| 203 | self.assertEqual(run_threads(announce, 4), []) | ||
| 204 | answer = self.tracker.scrape(info_hash) | ||
| 205 | self.assertEqual(answer[b"files"][info_hash][b"complete"], 4 * PER_THREAD) | ||
| 206 | |||
| 207 | |||
| 208 | if __name__ == "__main__": | ||
| 209 | unittest.main() | ||
diff --git a/tests/stubs.c b/tests/stubs.c index 09a7312..a8e6962 100644 --- a/tests/stubs.c +++ b/tests/stubs.c | |||
| @@ -16,8 +16,7 @@ void free_peerlist(ot_peerlist *peer_list) { (void)peer_list; } | |||
| 16 | /* ot_accesslist.c is built with WANT_ACCESSLIST_BLACK so that the hash parser | 16 | /* ot_accesslist.c is built with WANT_ACCESSLIST_BLACK so that the hash parser |
| 17 | is reachable. These two belong to accesslist_readfile and the reload worker, | 17 | is reachable. These two belong to accesslist_readfile and the reload worker, |
| 18 | neither of which any test drives. */ | 18 | neither of which any test drives. */ |
| 19 | time_t g_now_seconds; | 19 | _Atomic int g_opentracker_running; |
| 20 | volatile int g_opentracker_running; | ||
| 21 | 20 | ||
| 22 | /* ot_mutex.c wakes the main loop through the self pipe when a result is | 21 | /* ot_mutex.c wakes the main loop through the self pipe when a result is |
| 23 | ready. The queue tests never get that far. */ | 22 | ready. The queue tests never get that far. */ |
