summaryrefslogtreecommitdiff
path: root/tests/integration/harness.py
diff options
context:
space:
mode:
Diffstat (limited to 'tests/integration/harness.py')
-rw-r--r--tests/integration/harness.py174
1 files changed, 158 insertions, 16 deletions
diff --git a/tests/integration/harness.py b/tests/integration/harness.py
index c8da1e2..03a0561 100644
--- a/tests/integration/harness.py
+++ b/tests/integration/harness.py
@@ -7,6 +7,8 @@ reports its own memory errors, which the teardown turns into test failures.
7""" 7"""
8 8
9import os 9import os
10import shutil
11import signal
10import socket 12import socket
11import struct 13import struct
12import subprocess 14import subprocess
@@ -58,18 +60,40 @@ def free_port():
58class Tracker: 60class Tracker:
59 """One tracker process, listening on loopback.""" 61 """One tracker process, listening on loopback."""
60 62
61 def __init__(self, extra_config=""): 63 def __init__(self, extra_config="", files=None):
62 self.port = free_port() 64 self.port = free_port()
63 self.dir = tempfile.mkdtemp(prefix="ot-integration-") 65 self.dir = tempfile.mkdtemp(prefix="ot-integration-")
64 self.config = os.path.join(self.dir, "opentracker.conf") 66 self.log = None
65 with open(self.config, "w") as handle: 67 self.process = None
66 handle.write("listen.tcp 127.0.0.1:%d\n" % self.port) 68 try:
67 handle.write("listen.udp 127.0.0.1:%d\n" % self.port) 69 for name, content in (files or {}).items():
68 handle.write(extra_config) 70 with open(os.path.join(self.dir, name), "wb") as handle:
69 self.log = open(os.path.join(self.dir, "tracker.log"), "w+b") 71 handle.write(content)
70 environment = dict(os.environ, ASAN_OPTIONS="detect_leaks=0:abort_on_error=0") 72 self.config = os.path.join(self.dir, "opentracker.conf")
71 self.process = subprocess.Popen([TRACKER, "-f", self.config], stdout=self.log, stderr=self.log, env=environment) 73 with open(self.config, "w") as handle:
72 self._wait_for_port() 74 handle.write("listen.tcp 127.0.0.1:%d\n" % self.port)
75 handle.write("listen.udp 127.0.0.1:%d\n" % self.port)
76 handle.write(extra_config.replace("{dir}", self.dir))
77 self.log = open(os.path.join(self.dir, "tracker.log"), "w+b")
78 # Leak detection is left at the sanitizer's own default, which is on
79 # where LeakSanitizer exists and off where it does not. Asking for it
80 # by name makes the tracker refuse to start on macOS and FreeBSD.
81 environment = dict(os.environ, ASAN_OPTIONS="abort_on_error=0")
82 self.process = subprocess.Popen([TRACKER, "-f", self.config], stdout=self.log, stderr=self.log, env=environment)
83 self._wait_for_port()
84 except BaseException:
85 self._cleanup()
86 raise
87
88 def _cleanup(self):
89 """Close what we opened and take the scratch directory with it."""
90 if self.process is not None and self.process.poll() is None:
91 self.process.kill()
92 self.process.wait()
93 if self.log is not None:
94 self.log.close()
95 self.log = None
96 shutil.rmtree(self.dir, ignore_errors=True)
73 97
74 def _wait_for_port(self): 98 def _wait_for_port(self):
75 for _ in range(200): 99 for _ in range(200):
@@ -84,21 +108,32 @@ class Tracker:
84 raise AssertionError("tracker never listened on %d:\n%s" % (self.port, self.output())) 108 raise AssertionError("tracker never listened on %d:\n%s" % (self.port, self.output()))
85 109
86 def output(self): 110 def output(self):
111 if self.log is None:
112 return ""
87 self.log.flush() 113 self.log.flush()
88 self.log.seek(0) 114 self.log.seek(0)
89 return self.log.read().decode("utf-8", "replace") 115 return self.log.read().decode("utf-8", "replace")
90 116
91 def stop(self): 117 def stop(self):
92 """Returns anything the tracker complained about on the way out.""" 118 """Returns anything the tracker complained about on the way out.
93 if self.process.poll() is None: 119
94 self.process.kill() 120 The interrupt rather than a kill is what makes the leak check work at
95 self.process.wait() 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.
123 """
124 if self.process is not None and self.process.poll() is None:
125 self.process.send_signal(signal.SIGINT)
126 try:
127 self.process.wait(timeout=5)
128 except subprocess.TimeoutExpired:
129 self.process.kill()
130 self.process.wait()
96 complaints = [ 131 complaints = [
97 line 132 line
98 for line in self.output().splitlines() 133 for line in self.output().splitlines()
99 if "Sanitizer" in line or "runtime error" in line or line.startswith("ERROR") 134 if "Sanitizer" in line or "runtime error" in line or line.startswith("ERROR")
100 ] 135 ]
101 self.log.close() 136 self._cleanup()
102 return complaints 137 return complaints
103 138
104 # ----------------------------------------------------------- over http 139 # ----------------------------------------------------------- over http
@@ -122,6 +157,12 @@ class Tracker:
122 def stats(self, mode): 157 def stats(self, mode):
123 return self.get("/stats?mode=" + mode) 158 return self.get("/stats?mode=" + mode)
124 159
160 def get_with_headers(self, path, headers=None, timeout=10):
161 """The body and the response headers, for the compressed answers."""
162 request = urllib.request.Request("http://127.0.0.1:%d%s" % (self.port, path), headers=headers or {})
163 with urllib.request.urlopen(request, timeout=timeout) as response:
164 return response.read(), dict(response.headers)
165
125 def torrent_count(self): 166 def torrent_count(self):
126 return int(self.stats("torr").split()[0]) 167 return int(self.stats("torr").split()[0])
127 168
@@ -148,16 +189,117 @@ class Tracker:
148 assert action == 1, "udp announce answered action %d" % action 189 assert action == 1, "udp announce answered action %d" % action
149 return {"interval": interval, "leechers": leechers, "seeders": seeders, "peers": answer[20:]} 190 return {"interval": interval, "leechers": leechers, "seeders": seeders, "peers": answer[20:]}
150 191
192 def udp_scrape(self, sock, connection_id, hashes):
193 """One (seeders, completed, leechers) triple per hash, in order."""
194 sock.sendto(struct.pack(">QII", connection_id, 2, 3) + b"".join(hashes), ("127.0.0.1", self.port))
195 answer = sock.recv(2048)
196 action, _ = struct.unpack(">II", answer[:8])
197 assert action == 2, "udp scrape answered action %d" % action
198 return [struct.unpack(">III", answer[8 + 12 * i : 20 + 12 * i]) for i in range(len(hashes))]
199
200 def connect(self):
201 """A connection the caller drives and closes."""
202 return Connection(self.port)
203
204 def udp_raw(self, payload, timeout=1.0):
205 """Sends a packet exactly as given. Returns the answer, or None if the
206 tracker chose to say nothing, which is how it treats a broken one."""
207 sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
208 sock.settimeout(timeout)
209 try:
210 sock.sendto(payload, ("127.0.0.1", self.port))
211 try:
212 return sock.recv(2048)
213 except socket.timeout:
214 return None
215 finally:
216 sock.close()
217
218
219class Connection:
220 """One HTTP connection the caller keeps open, which urllib will not do.
221
222 Reads exactly one reply at a time, so a test can tell "the tracker
223 answered and kept the socket" from "the tracker answered and hung up"."""
224
225 def __init__(self, port, timeout=5):
226 self.sock = socket.create_connection(("127.0.0.1", port), timeout=timeout)
227 self.sock.settimeout(timeout)
228 self.buffer = b""
229
230 def send(self, path, connection="Keep-Alive", version="1.1"):
231 request = "GET %s HTTP/%s\r\nHost: tracker\r\n" % (path, version)
232 if connection is not None:
233 request += "Connection: %s\r\n" % connection
234 self.sock.sendall((request + "\r\n").encode())
235
236 def _fill(self):
237 chunk = self.sock.recv(65536)
238 if not chunk:
239 raise EOFError("the tracker closed the connection")
240 self.buffer += chunk
241
242 def reply(self):
243 """The one complete reply at the front of the stream, headers and body."""
244 while b"\r\n\r\n" not in self.buffer:
245 self._fill()
246 head, rest = self.buffer.split(b"\r\n\r\n", 1)
247 headers = {}
248 for line in head.split(b"\r\n")[1:]:
249 name, _, value = line.partition(b":")
250 headers[name.strip().lower()] = value.strip()
251
252 if headers.get(b"transfer-encoding") == b"chunked":
253 while not rest.endswith(b"0\r\n\r\n"):
254 self.buffer = head + b"\r\n\r\n" + rest
255 self._fill()
256 rest = self.buffer.split(b"\r\n\r\n", 1)[1]
257 body, self.buffer = rest, b""
258 else:
259 length = int(headers.get(b"content-length", b"0"))
260 while len(rest) < length:
261 self.buffer = head + b"\r\n\r\n" + rest
262 self._fill()
263 rest = self.buffer.split(b"\r\n\r\n", 1)[1]
264 body, self.buffer = rest[:length], rest[length:]
265 return head.split(b"\r\n")[0], headers, body
266
267 def is_open(self):
268 """True if the tracker is still holding the connection."""
269 try:
270 self.sock.settimeout(0.4)
271 return self.sock.recv(1) != b""
272 except socket.timeout:
273 return True
274 except OSError:
275 return False
276 finally:
277 try:
278 self.sock.settimeout(5)
279 except OSError:
280 pass
281
282 def close(self):
283 self.sock.close()
284
151 285
152class TrackerTestCase(unittest.TestCase): 286class TrackerTestCase(unittest.TestCase):
153 """Gives every test its own tracker and fails it on any sanitizer output.""" 287 """Gives every test its own tracker and fails it on any sanitizer output."""
154 288
155 config = "" 289 config = ""
290 files = None
156 291
157 def setUp(self): 292 def setUp(self):
158 self.tracker = Tracker(self.config) 293 self.tracker = Tracker(self.config, self.files)
159 294
160 def tearDown(self): 295 def tearDown(self):
296 # A connection the test closed is only reaped when the event loop gets
297 # round to it, and a cookie still held at exit reads as a leak. One
298 # more request puts us behind that work in the loop's queue.
299 try:
300 self.tracker.torrent_count()
301 except Exception:
302 pass
161 complaints = self.tracker.stop() 303 complaints = self.tracker.stop()
162 self.assertEqual(complaints, [], "the tracker reported:\n" + "\n".join(complaints)) 304 self.assertEqual(complaints, [], "the tracker reported:\n" + "\n".join(complaints))
163 305