diff --git a/tests/test_frame_android_library.py b/tests/test_frame_android_library.py index 6d235b5..1695d4c 100644 --- a/tests/test_frame_android_library.py +++ b/tests/test_frame_android_library.py @@ -196,6 +196,42 @@ class ArtworkTests(unittest.TestCase): self.assertLess(self.trickle(b'HTTP/1.1 200 OK\r\nContent-Length: 1000\r\n\r\n', 0.15), 0.4) self.assertLess(self.trickle(b'HTTP/1.1 200 OK\r\n', 0.15), 0.4) # headers never finish + def test_deadline_covers_a_stalled_tls_handshake(self): + import socket + from apk_sources import _images + client, server = socket.socketpair() + public = [(2, 1, 6, '', ('93.184.216.34', 443))] + try: + with patch.object(_images.socket, 'getaddrinfo', return_value=public), \ + patch.object(_images.socket, 'create_connection', return_value=client): + start = time.monotonic() + with self.assertRaisesRegex(_images.SourceError, 'too long'): + _images.get('https://example.org/a.png', deadline=start + 0.25) # server never answers + self.assertLess(time.monotonic() - start, 0.45) + finally: + server.close() + + def test_timed_out_lookups_are_capped(self): + import threading + from apk_sources import _images + gate = threading.Event() + try: + with patch.object(_images.socket, 'getaddrinfo', side_effect=lambda *a, **k: gate.wait(5) and []): + errors = [] + for _ in range(6): + try: + _images.get('https://example.org/a.png', deadline=time.monotonic() + 0.05) + except _images.SourceError as e: + errors.append(str(e)) + self.assertEqual(sum('too long' in e for e in errors), 4) + self.assertEqual(sum('Too many' in e for e in errors), 2) + finally: + gate.set() + deadline = time.monotonic() + 5 + while time.monotonic() < deadline and not _images._resolvers.acquire(blocking=False): + time.sleep(0.01) + _images._resolvers.release() # the stuck lookups finished and gave their slots back + def test_deadline_covers_name_resolution(self): import threading from apk_sources import _images diff --git a/ui/apk_sources/_images.py b/ui/apk_sources/_images.py index 6205c4c..2a2541e 100644 --- a/ui/apk_sources/_images.py +++ b/ui/apk_sources/_images.py @@ -69,8 +69,14 @@ def fetch(url, redirects=3, deadline=None, limit=MAX_IMAGE): return data, image_type(data) +# Lookups that outlast their deadline keep running; cap them so they can't pile up. +_resolvers = threading.BoundedSemaphore(4) + + def _resolve(host, port, timeout): # getaddrinfo has no timeout of its own; a thread keeps a slow resolver inside the budget. + if not _resolvers.acquire(blocking=False): + raise SourceError('Too many slow artwork name lookups are still running; try again shortly') found = {} def run(): @@ -78,6 +84,8 @@ def _resolve(host, port, timeout): found['addresses'] = socket.getaddrinfo(host, port, type=socket.SOCK_STREAM) except OSError as e: found['error'] = e + finally: + _resolvers.release() worker = threading.Thread(target=run, daemon=True) worker.start() worker.join(timeout) @@ -110,8 +118,8 @@ def get(url, headers=None, redirects=3, deadline=None, limit=MAX_IMAGE): live = [socket.create_connection((addresses[0][4][0], port), timeout=min(10, left()))] def expire(): - try: - live[0].shutdown(socket.SHUT_RDWR) + try: # the plain socket method: it also ends a TLS handshake in progress + socket.socket.shutdown(live[0], socket.SHUT_RDWR) except OSError: pass watchdog = threading.Timer(left(), expire) @@ -120,7 +128,11 @@ def get(url, headers=None, redirects=3, deadline=None, limit=MAX_IMAGE): conn = http.client.HTTPConnection(p.hostname, port, timeout=10) try: if p.scheme == 'https': - live[0] = ssl.create_default_context().wrap_socket(live[0], server_hostname=p.hostname) + # Handshake only once the watchdog can reach the TLS socket, within the remaining time. + live[0] = ssl.create_default_context().wrap_socket(live[0], server_hostname=p.hostname, + do_handshake_on_connect=False) + live[0].settimeout(min(10, left())) + live[0].do_handshake() conn.sock = live[0] path = p.path or '/' if p.query: