summaryrefslogtreecommitdiff
path: root/tests
diff options
context:
space:
mode:
Diffstat (limited to 'tests')
-rw-r--r--tests/integration/harness.py6
-rw-r--r--tests/integration/test_concurrency.py209
2 files changed, 215 insertions, 0 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
3The rest of the suite talks to the tracker one request at a time, which
4leaves the worker threads and the bucket locks almost untouched. These reach
5them.
6
7What they assert is weak on purpose. A lost update needs two threads inside
8the same few instructions, which is rare enough that these tests pass
9against code that has the race: measured, eight runs in a row. Their worth
10is that they make the racing code run at all, so that "make check-tsan"
11has something to report. Reaching the code and judging it are two jobs, and
12these only do the first.
13"""
14
15import struct
16import threading
17import unittest
18
19from harness import TrackerTestCase, bdecode
20
21THREADS = 8
22PER_THREAD = 40
23
24
25def 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
42class 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
86class 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
117class 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
164class 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
208if __name__ == "__main__":
209 unittest.main()