From 4a0bf8a702aabaea7aee646d6f955333845d8d05 Mon Sep 17 00:00:00 2001 From: Victor Shyba Date: Sat, 5 Mar 2022 02:12:11 -0300 Subject: [PATCH 01/50] add torrent udp tracker client, server and tests --- lbry/torrent/tracker.py | 141 +++++++++++++++++++++++++++++ tests/unit/torrent/__init__.py | 0 tests/unit/torrent/test_tracker.py | 27 ++++++ 3 files changed, 168 insertions(+) create mode 100644 lbry/torrent/tracker.py create mode 100644 tests/unit/torrent/__init__.py create mode 100644 tests/unit/torrent/test_tracker.py diff --git a/lbry/torrent/tracker.py b/lbry/torrent/tracker.py new file mode 100644 index 000000000..fb1590f6a --- /dev/null +++ b/lbry/torrent/tracker.py @@ -0,0 +1,141 @@ +import random +import struct +import asyncio +import logging +from collections import namedtuple +from functools import reduce + +log = logging.getLogger(__name__) +ConnectRequest = namedtuple("ConnectRequest", ["connection_id", "action", "transaction_id"]) +ConnectResponse = namedtuple("ConnectResponse", ["action", "transaction_id", "connection_id"]) +AnnounceRequest = namedtuple("AnnounceRequest", + ["connection_id", "action", "transaction_id", "info_hash", "peer_id", "downloaded", "left", + "uploaded", "event", "ip_addr", "key", "num_want", "port"]) +AnnounceResponse = namedtuple("AnnounceResponse", + ["action", "transaction_id", "interval", "leechers", "seeders", "peers"]) +CompactIPv4Peer = namedtuple("CompactPeer", ["address", "port"]) +ScrapeRequest = namedtuple("ScrapeRequest", ["connection_id", "action", "transaction_id", "infohashes"]) +ScrapeResponse = namedtuple("ScrapeResponse", ["action", "transaction_id", "items"]) +ScrapeResponseItem = namedtuple("ScrapeResponseItem", ["seeders", "completed", "leechers"]) +ErrorResponse = namedtuple("ErrorResponse", ["action", "transaction_id", "message"]) +STRUCTS = { + ConnectRequest: struct.Struct(">QII"), + ConnectResponse: struct.Struct(">IIQ"), + AnnounceRequest: struct.Struct(">QII20s20sQQQIIIiH"), + AnnounceResponse: struct.Struct(">IIIII"), + CompactIPv4Peer: struct.Struct(">IH"), + ScrapeRequest: struct.Struct(">QII"), + ScrapeResponse: struct.Struct(">II"), + ScrapeResponseItem: struct.Struct(">III"), + ErrorResponse: struct.Struct(">II") +} + + +def decode(cls, data, offset=0): + decoder = STRUCTS[cls] + if cls == AnnounceResponse: + return AnnounceResponse(*decoder.unpack_from(data, offset), + peers=[decode(CompactIPv4Peer, data, index) for index in range(20, len(data), 6)]) + elif cls == ScrapeResponse: + return ScrapeResponse(*decoder.unpack_from(data, offset), + items=[decode(ScrapeResponseItem, data, index) for index in range(8, len(data), 12)]) + elif cls == ErrorResponse: + return ErrorResponse(*decoder.unpack_from(data, offset), data[decoder.size:]) + return cls(*decoder.unpack_from(data, offset)) + + +def encode(obj): + if isinstance(obj, ScrapeRequest): + return STRUCTS[ScrapeRequest].pack(*obj[:-1]) + b''.join(obj.infohashes) + elif isinstance(obj, ErrorResponse): + return STRUCTS[ErrorResponse].pack(*obj[:-1]) + obj.message + elif isinstance(obj, AnnounceResponse): + return STRUCTS[AnnounceResponse].pack(*obj[:-1]) + b''.join([encode(peer) for peer in obj.peers]) + return STRUCTS[type(obj)].pack(*obj) + + +class UDPTrackerClientProtocol(asyncio.DatagramProtocol): + def __init__(self): + self.transport = None + self.data_queue = {} + + def connection_made(self, transport: asyncio.DatagramTransport) -> None: + self.transport = transport + + async def request(self, obj, tracker_ip, tracker_port): + self.data_queue[obj.transaction_id] = asyncio.get_running_loop().create_future() + self.transport.sendto(encode(obj), (tracker_ip, tracker_port)) + try: + return await asyncio.wait_for(self.data_queue[obj.transaction_id], 3.0) + finally: + self.data_queue.pop(obj.transaction_id, None) + + async def connect(self, tracker_ip, tracker_port): + transaction_id = random.getrandbits(32) + return decode(ConnectResponse, + await self.request(ConnectRequest(0x41727101980, 0, transaction_id), tracker_ip, tracker_port)) + + async def announce(self, info_hash, peer_id, port, tracker_ip, tracker_port, connection_id=None): + if not connection_id: + reply = await self.connect(tracker_ip, tracker_port) + connection_id = reply.connection_id + # this should make the key deterministic but unique per info hash + peer id + key = int.from_bytes(info_hash[:4], "big") ^ int.from_bytes(peer_id[:4], "big") ^ port + transaction_id = random.getrandbits(32) + req = AnnounceRequest(connection_id, 1, transaction_id, info_hash, peer_id, 0, 0, 0, 1, 0, key, -1, port) + reply = await self.request(req, tracker_ip, tracker_port) + return decode(AnnounceResponse, reply), connection_id + + async def scrape(self, infohashes, tracker_ip, tracker_port, connection_id=None): + if not connection_id: + reply = await self.connect(tracker_ip, tracker_port) + connection_id = reply.connection_id + transaction_id = random.getrandbits(32) + reply = await self.request( + ScrapeRequest(connection_id, 2, transaction_id, infohashes), tracker_ip, tracker_port) + return decode(ScrapeResponse, reply), connection_id + + def datagram_received(self, data: bytes, addr: (str, int)) -> None: + if len(data) < 8: + return + transaction_id = int.from_bytes(data[4:8], byteorder="big", signed=False) + if transaction_id in self.data_queue: + if not self.data_queue[transaction_id].done(): + if data[3] == 3: + return self.data_queue[transaction_id].set_exception(Exception(decode(ErrorResponse, data).message)) + return self.data_queue[transaction_id].set_result(data) + print("error", data.hex()) + + def connection_lost(self, exc: Exception = None) -> None: + self.transport = None + + +class UDPTrackerServerProtocol(asyncio.DatagramProtocol): + def __init__(self): + self.transport = None + self.known_conns = set() + self.peers = {} + + def connection_made(self, transport: asyncio.DatagramTransport) -> None: + self.transport = transport + + def datagram_received(self, data: bytes, address: (str, int)) -> None: + if len(data) < 16: + return + action = int.from_bytes(data[8:12], "big", signed=False) + if action == 0: + req = decode(ConnectRequest, data) + connection_id = random.getrandbits(32) + self.known_conns.add(connection_id) + return self.transport.sendto(encode(ConnectResponse(0, req.transaction_id, connection_id)), address) + elif action == 1: + req = decode(AnnounceRequest, data) + if req.connection_id not in self.known_conns: + resp = encode(ErrorResponse(3, req.transaction_id, b'Connection ID missmatch.\x00')) + else: + self.peers.setdefault(req.info_hash, []) + compact_ip = reduce(lambda buff, x: buff + bytearray([int(x)]), address[0].split('.'), bytearray()) + self.peers[req.info_hash].append(compact_ip + req.port.to_bytes(2, "big", signed=False)) + peers = [decode(CompactIPv4Peer, peer) for peer in self.peers[req.info_hash]] + resp = encode(AnnounceResponse(1, req.transaction_id, 1700, 0, len(peers), peers)) + return self.transport.sendto(resp, address) diff --git a/tests/unit/torrent/__init__.py b/tests/unit/torrent/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/unit/torrent/test_tracker.py b/tests/unit/torrent/test_tracker.py new file mode 100644 index 000000000..3d56d0269 --- /dev/null +++ b/tests/unit/torrent/test_tracker.py @@ -0,0 +1,27 @@ +import random +from lbry.testcase import AsyncioTestCase +from lbry.torrent.tracker import UDPTrackerClientProtocol, UDPTrackerServerProtocol, CompactIPv4Peer + + +class UDPTrackerClientTestCase(AsyncioTestCase): + async def asyncSetUp(self): + transport, _ = await self.loop.create_datagram_endpoint(UDPTrackerServerProtocol, local_addr=("127.0.0.1", 59900)) + self.addCleanup(transport.close) + self.client = UDPTrackerClientProtocol() + transport, _ = await self.loop.create_datagram_endpoint(lambda: self.client, local_addr=("127.0.0.1", 0)) + self.addCleanup(transport.close) + + async def test_announce(self): + info_hash = random.getrandbits(160).to_bytes(20, "big", signed=False) + peer_id = random.getrandbits(160).to_bytes(20, "big", signed=False) + announcement, _ = await self.client.announce(info_hash, peer_id, 4444, "127.0.0.1", 59900) + self.assertEqual(announcement.seeders, 1) + self.assertEqual(announcement.peers, + [CompactIPv4Peer(int.from_bytes(bytes([127, 0, 0, 1]), "big", signed=False), 4444)]) + + async def test_error(self): + info_hash = random.getrandbits(160).to_bytes(20, "big", signed=False) + peer_id = random.getrandbits(160).to_bytes(20, "big", signed=False) + with self.assertRaises(Exception) as err: + announcement, _ = await self.client.announce(info_hash, peer_id, 4444, "127.0.0.1", 59900, connection_id=10) + self.assertEqual(err.exception.args[0], b'Connection ID missmatch.\x00') From 006391dd269c4c35f7aac6934a81427151ef9172 Mon Sep 17 00:00:00 2001 From: Victor Shyba Date: Sat, 5 Mar 2022 02:21:15 -0300 Subject: [PATCH 02/50] move udp server to test file, add link to BEP15 --- lbry/torrent/tracker.py | 33 +------------------------- tests/unit/torrent/test_tracker.py | 37 +++++++++++++++++++++++++++++- 2 files changed, 37 insertions(+), 33 deletions(-) diff --git a/lbry/torrent/tracker.py b/lbry/torrent/tracker.py index fb1590f6a..14fd3c136 100644 --- a/lbry/torrent/tracker.py +++ b/lbry/torrent/tracker.py @@ -3,9 +3,9 @@ import struct import asyncio import logging from collections import namedtuple -from functools import reduce log = logging.getLogger(__name__) +# see: http://bittorrent.org/beps/bep_0015.html and http://xbtt.sourceforge.net/udp_tracker_protocol.html ConnectRequest = namedtuple("ConnectRequest", ["connection_id", "action", "transaction_id"]) ConnectResponse = namedtuple("ConnectResponse", ["action", "transaction_id", "connection_id"]) AnnounceRequest = namedtuple("AnnounceRequest", @@ -108,34 +108,3 @@ class UDPTrackerClientProtocol(asyncio.DatagramProtocol): def connection_lost(self, exc: Exception = None) -> None: self.transport = None - - -class UDPTrackerServerProtocol(asyncio.DatagramProtocol): - def __init__(self): - self.transport = None - self.known_conns = set() - self.peers = {} - - def connection_made(self, transport: asyncio.DatagramTransport) -> None: - self.transport = transport - - def datagram_received(self, data: bytes, address: (str, int)) -> None: - if len(data) < 16: - return - action = int.from_bytes(data[8:12], "big", signed=False) - if action == 0: - req = decode(ConnectRequest, data) - connection_id = random.getrandbits(32) - self.known_conns.add(connection_id) - return self.transport.sendto(encode(ConnectResponse(0, req.transaction_id, connection_id)), address) - elif action == 1: - req = decode(AnnounceRequest, data) - if req.connection_id not in self.known_conns: - resp = encode(ErrorResponse(3, req.transaction_id, b'Connection ID missmatch.\x00')) - else: - self.peers.setdefault(req.info_hash, []) - compact_ip = reduce(lambda buff, x: buff + bytearray([int(x)]), address[0].split('.'), bytearray()) - self.peers[req.info_hash].append(compact_ip + req.port.to_bytes(2, "big", signed=False)) - peers = [decode(CompactIPv4Peer, peer) for peer in self.peers[req.info_hash]] - resp = encode(AnnounceResponse(1, req.transaction_id, 1700, 0, len(peers), peers)) - return self.transport.sendto(resp, address) diff --git a/tests/unit/torrent/test_tracker.py b/tests/unit/torrent/test_tracker.py index 3d56d0269..9f1ebf106 100644 --- a/tests/unit/torrent/test_tracker.py +++ b/tests/unit/torrent/test_tracker.py @@ -1,6 +1,41 @@ +import asyncio import random +from functools import reduce + from lbry.testcase import AsyncioTestCase -from lbry.torrent.tracker import UDPTrackerClientProtocol, UDPTrackerServerProtocol, CompactIPv4Peer +from lbry.torrent.tracker import UDPTrackerClientProtocol, encode, decode, CompactIPv4Peer, ConnectRequest, \ + ConnectResponse, AnnounceRequest, ErrorResponse, AnnounceResponse + + +class UDPTrackerServerProtocol(asyncio.DatagramProtocol): # for testing. Not suitable for production + def __init__(self): + self.transport = None + self.known_conns = set() + self.peers = {} + + def connection_made(self, transport: asyncio.DatagramTransport) -> None: + self.transport = transport + + def datagram_received(self, data: bytes, address: (str, int)) -> None: + if len(data) < 16: + return + action = int.from_bytes(data[8:12], "big", signed=False) + if action == 0: + req = decode(ConnectRequest, data) + connection_id = random.getrandbits(32) + self.known_conns.add(connection_id) + return self.transport.sendto(encode(ConnectResponse(0, req.transaction_id, connection_id)), address) + elif action == 1: + req = decode(AnnounceRequest, data) + if req.connection_id not in self.known_conns: + resp = encode(ErrorResponse(3, req.transaction_id, b'Connection ID missmatch.\x00')) + else: + self.peers.setdefault(req.info_hash, []) + compact_ip = reduce(lambda buff, x: buff + bytearray([int(x)]), address[0].split('.'), bytearray()) + self.peers[req.info_hash].append(compact_ip + req.port.to_bytes(2, "big", signed=False)) + peers = [decode(CompactIPv4Peer, peer) for peer in self.peers[req.info_hash]] + resp = encode(AnnounceResponse(1, req.transaction_id, 1700, 0, len(peers), peers)) + return self.transport.sendto(resp, address) class UDPTrackerClientTestCase(AsyncioTestCase): From 4ea858fdd3e122ea1250b7167859132696e5b79d Mon Sep 17 00:00:00 2001 From: Victor Shyba Date: Sat, 5 Mar 2022 02:21:53 -0300 Subject: [PATCH 03/50] add new conf: tracker_servers --- lbry/conf.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/lbry/conf.py b/lbry/conf.py index 15fe5f8b6..814e9d744 100644 --- a/lbry/conf.py +++ b/lbry/conf.py @@ -681,6 +681,10 @@ class Config(CLIConfig): ('cdn.reflector.lbry.com', 5567) ]) + tracker_servers = Servers("BitTorrent-compatible (BEP15) UDP trackers for helping P2P discovery", [ + ('tracker.lbry.com', 1337) + ]) + lbryum_servers = Servers("SPV wallet servers", [ ('spv11.lbry.com', 50001), ('spv12.lbry.com', 50001), From 2df8a1d99d5cea2999dc9898a5dfa7693782199b Mon Sep 17 00:00:00 2001 From: Victor Shyba Date: Sat, 5 Mar 2022 02:42:58 -0300 Subject: [PATCH 04/50] make a helper function to announce --- lbry/torrent/tracker.py | 19 +++++++++++++++++++ tests/unit/torrent/test_tracker.py | 8 +++++++- 2 files changed, 26 insertions(+), 1 deletion(-) diff --git a/lbry/torrent/tracker.py b/lbry/torrent/tracker.py index 14fd3c136..3d548e45b 100644 --- a/lbry/torrent/tracker.py +++ b/lbry/torrent/tracker.py @@ -4,6 +4,8 @@ import asyncio import logging from collections import namedtuple +from lbry.utils import resolve_host + log = logging.getLogger(__name__) # see: http://bittorrent.org/beps/bep_0015.html and http://xbtt.sourceforge.net/udp_tracker_protocol.html ConnectRequest = namedtuple("ConnectRequest", ["connection_id", "action", "transaction_id"]) @@ -108,3 +110,20 @@ class UDPTrackerClientProtocol(asyncio.DatagramProtocol): def connection_lost(self, exc: Exception = None) -> None: self.transport = None + + +async def get_peer_list(info_hash, node_id, port, tracker_ip, tracker_port): + node_id = node_id or random.getrandbits(160).to_bytes(20, "big", signed=False) + tracker_ip = await resolve_host(tracker_ip, tracker_port, 'udp') + proto = UDPTrackerClientProtocol() + transport, _ = await asyncio.get_running_loop().create_datagram_endpoint(lambda: proto, local_addr=("0.0.0.0", 0)) + try: + reply, _ = await proto.announce(info_hash, node_id, port, tracker_ip, tracker_port) + return reply.peers + except asyncio.CancelledError: + raise + except Exception as exc: + log.warning("Error fetching from tracker: %s", exc.args) + return [] + finally: + transport.close() \ No newline at end of file diff --git a/tests/unit/torrent/test_tracker.py b/tests/unit/torrent/test_tracker.py index 9f1ebf106..66f147f43 100644 --- a/tests/unit/torrent/test_tracker.py +++ b/tests/unit/torrent/test_tracker.py @@ -4,7 +4,7 @@ from functools import reduce from lbry.testcase import AsyncioTestCase from lbry.torrent.tracker import UDPTrackerClientProtocol, encode, decode, CompactIPv4Peer, ConnectRequest, \ - ConnectResponse, AnnounceRequest, ErrorResponse, AnnounceResponse + ConnectResponse, AnnounceRequest, ErrorResponse, AnnounceResponse, get_peer_list class UDPTrackerServerProtocol(asyncio.DatagramProtocol): # for testing. Not suitable for production @@ -54,6 +54,12 @@ class UDPTrackerClientTestCase(AsyncioTestCase): self.assertEqual(announcement.peers, [CompactIPv4Peer(int.from_bytes(bytes([127, 0, 0, 1]), "big", signed=False), 4444)]) + async def test_announce_using_helper_function(self): + info_hash = random.getrandbits(160).to_bytes(20, "big", signed=False) + peers = await get_peer_list(info_hash, None, 4444, "127.0.0.1", 59900) + self.assertEqual(len(peers), 1) + self.assertEqual(peers, [CompactIPv4Peer(int.from_bytes(bytes([127, 0, 0, 1]), "big", signed=False), 4444)]) + async def test_error(self): info_hash = random.getrandbits(160).to_bytes(20, "big", signed=False) peer_id = random.getrandbits(160).to_bytes(20, "big", signed=False) From dc6f8c4fc4872cdee2b24ac14034feadc643146b Mon Sep 17 00:00:00 2001 From: Victor Shyba Date: Sat, 5 Mar 2022 02:55:19 -0300 Subject: [PATCH 05/50] add arg to announce stopped, removing the announcement --- lbry/torrent/tracker.py | 9 +++++---- tests/unit/torrent/test_tracker.py | 9 +++++++-- 2 files changed, 12 insertions(+), 6 deletions(-) diff --git a/lbry/torrent/tracker.py b/lbry/torrent/tracker.py index 3d548e45b..cc263b380 100644 --- a/lbry/torrent/tracker.py +++ b/lbry/torrent/tracker.py @@ -77,14 +77,15 @@ class UDPTrackerClientProtocol(asyncio.DatagramProtocol): return decode(ConnectResponse, await self.request(ConnectRequest(0x41727101980, 0, transaction_id), tracker_ip, tracker_port)) - async def announce(self, info_hash, peer_id, port, tracker_ip, tracker_port, connection_id=None): + async def announce(self, info_hash, peer_id, port, tracker_ip, tracker_port, connection_id=None, stopped=False): if not connection_id: reply = await self.connect(tracker_ip, tracker_port) connection_id = reply.connection_id # this should make the key deterministic but unique per info hash + peer id key = int.from_bytes(info_hash[:4], "big") ^ int.from_bytes(peer_id[:4], "big") ^ port transaction_id = random.getrandbits(32) - req = AnnounceRequest(connection_id, 1, transaction_id, info_hash, peer_id, 0, 0, 0, 1, 0, key, -1, port) + req = AnnounceRequest( + connection_id, 1, transaction_id, info_hash, peer_id, 0, 0, 0, 3 if stopped else 1, 0, key, -1, port) reply = await self.request(req, tracker_ip, tracker_port) return decode(AnnounceResponse, reply), connection_id @@ -112,13 +113,13 @@ class UDPTrackerClientProtocol(asyncio.DatagramProtocol): self.transport = None -async def get_peer_list(info_hash, node_id, port, tracker_ip, tracker_port): +async def get_peer_list(info_hash, node_id, port, tracker_ip, tracker_port, stopped=False): node_id = node_id or random.getrandbits(160).to_bytes(20, "big", signed=False) tracker_ip = await resolve_host(tracker_ip, tracker_port, 'udp') proto = UDPTrackerClientProtocol() transport, _ = await asyncio.get_running_loop().create_datagram_endpoint(lambda: proto, local_addr=("0.0.0.0", 0)) try: - reply, _ = await proto.announce(info_hash, node_id, port, tracker_ip, tracker_port) + reply, _ = await proto.announce(info_hash, node_id, port, tracker_ip, tracker_port, stopped=stopped) return reply.peers except asyncio.CancelledError: raise diff --git a/tests/unit/torrent/test_tracker.py b/tests/unit/torrent/test_tracker.py index 66f147f43..3c4c0fa9d 100644 --- a/tests/unit/torrent/test_tracker.py +++ b/tests/unit/torrent/test_tracker.py @@ -32,7 +32,11 @@ class UDPTrackerServerProtocol(asyncio.DatagramProtocol): # for testing. Not su else: self.peers.setdefault(req.info_hash, []) compact_ip = reduce(lambda buff, x: buff + bytearray([int(x)]), address[0].split('.'), bytearray()) - self.peers[req.info_hash].append(compact_ip + req.port.to_bytes(2, "big", signed=False)) + compact_address = compact_ip + req.port.to_bytes(2, "big", signed=False) + if req.event != 3: + self.peers[req.info_hash].append(compact_address) + elif compact_address in self.peers[req.info_hash]: + self.peers[req.info_hash].remove(compact_address) peers = [decode(CompactIPv4Peer, peer) for peer in self.peers[req.info_hash]] resp = encode(AnnounceResponse(1, req.transaction_id, 1700, 0, len(peers), peers)) return self.transport.sendto(resp, address) @@ -57,8 +61,9 @@ class UDPTrackerClientTestCase(AsyncioTestCase): async def test_announce_using_helper_function(self): info_hash = random.getrandbits(160).to_bytes(20, "big", signed=False) peers = await get_peer_list(info_hash, None, 4444, "127.0.0.1", 59900) - self.assertEqual(len(peers), 1) self.assertEqual(peers, [CompactIPv4Peer(int.from_bytes(bytes([127, 0, 0, 1]), "big", signed=False), 4444)]) + peers = await get_peer_list(info_hash, None, 4444, "127.0.0.1", 59900, stopped=True) + self.assertEqual(peers, []) async def test_error(self): info_hash = random.getrandbits(160).to_bytes(20, "big", signed=False) From 3989eef84b69525d965cd721d79783936a1ae91d Mon Sep 17 00:00:00 2001 From: Victor Shyba Date: Sat, 5 Mar 2022 03:08:43 -0300 Subject: [PATCH 06/50] return whole announcement so the caller knows the interval --- lbry/torrent/tracker.py | 10 +++------- tests/unit/torrent/test_tracker.py | 6 +++--- 2 files changed, 6 insertions(+), 10 deletions(-) diff --git a/lbry/torrent/tracker.py b/lbry/torrent/tracker.py index cc263b380..c46f63bbc 100644 --- a/lbry/torrent/tracker.py +++ b/lbry/torrent/tracker.py @@ -120,11 +120,7 @@ async def get_peer_list(info_hash, node_id, port, tracker_ip, tracker_port, stop transport, _ = await asyncio.get_running_loop().create_datagram_endpoint(lambda: proto, local_addr=("0.0.0.0", 0)) try: reply, _ = await proto.announce(info_hash, node_id, port, tracker_ip, tracker_port, stopped=stopped) - return reply.peers - except asyncio.CancelledError: - raise - except Exception as exc: - log.warning("Error fetching from tracker: %s", exc.args) - return [] + return reply finally: - transport.close() \ No newline at end of file + if not transport.is_closing(): + transport.close() diff --git a/tests/unit/torrent/test_tracker.py b/tests/unit/torrent/test_tracker.py index 3c4c0fa9d..a03493118 100644 --- a/tests/unit/torrent/test_tracker.py +++ b/tests/unit/torrent/test_tracker.py @@ -60,10 +60,10 @@ class UDPTrackerClientTestCase(AsyncioTestCase): async def test_announce_using_helper_function(self): info_hash = random.getrandbits(160).to_bytes(20, "big", signed=False) - peers = await get_peer_list(info_hash, None, 4444, "127.0.0.1", 59900) + announcemenet = await get_peer_list(info_hash, None, 4444, "127.0.0.1", 59900) + peers = announcemenet.peers self.assertEqual(peers, [CompactIPv4Peer(int.from_bytes(bytes([127, 0, 0, 1]), "big", signed=False), 4444)]) - peers = await get_peer_list(info_hash, None, 4444, "127.0.0.1", 59900, stopped=True) - self.assertEqual(peers, []) + self.assertEqual((await get_peer_list(info_hash, None, 4444, "127.0.0.1", 59900, stopped=True)).peers, []) async def test_error(self): info_hash = random.getrandbits(160).to_bytes(20, "big", signed=False) From 30e8728f7fd88ffb3ffc5805cd107ec7a8c57bb8 Mon Sep 17 00:00:00 2001 From: Victor Shyba Date: Sat, 5 Mar 2022 04:15:04 -0300 Subject: [PATCH 07/50] use tracker on download --- lbry/stream/downloader.py | 32 ++++++++++++++++++++++++++++++++ 1 file changed, 32 insertions(+) diff --git a/lbry/stream/downloader.py b/lbry/stream/downloader.py index 0ef627248..874715c6f 100644 --- a/lbry/stream/downloader.py +++ b/lbry/stream/downloader.py @@ -1,4 +1,6 @@ import asyncio +import ipaddress +import time import typing import logging import binascii @@ -8,6 +10,8 @@ from lbry.error import DownloadSDTimeoutError from lbry.utils import lru_cache_concurrent from lbry.stream.descriptor import StreamDescriptor from lbry.blob_exchange.downloader import BlobDownloader +from lbry.torrent.tracker import get_peer_list + if typing.TYPE_CHECKING: from lbry.conf import Config from lbry.dht.node import Node @@ -36,6 +40,7 @@ class StreamDownloader: self.added_fixed_peers = False self.time_to_descriptor: typing.Optional[float] = None self.time_to_first_bytes: typing.Optional[float] = None + self.next_tracker_announce_time = None async def cached_read_blob(blob_info: 'BlobInfo') -> bytes: return await self.read_blob(blob_info, 2) @@ -62,6 +67,32 @@ class StreamDownloader: fixed_peers = await get_kademlia_peers_from_hosts(self.config.fixed_peers) self.fixed_peers_handle = self.loop.call_later(self.fixed_peers_delay, _add_fixed_peers, fixed_peers) + async def refresh_from_trackers(self, save_peers=True): + if not self.config.tracker_servers: + return + node_id = self.node.protocol.node_id if self.node else None + port = self.config.tcp_port + for server in self.config.tracker_servers: + try: + announcement = await get_peer_list( + bytes.fromhex(self.sd_hash)[:20], node_id, port, server[0], server[1]) + self.next_tracker_announce_time = max(self.next_tracker_announce_time or 0, + time.time() + announcement.interval) + except asyncio.CancelledError: + raise + except asyncio.TimeoutError: + log.warning("Tracker timed out: %s", server) + return + except Exception: + log.exception("Unexpected error querying tracker %s", server) + return + if not save_peers: + return + peers = [(str(ipaddress.ip_address(peer.address)), peer.port) for peer in announcement.peers] + peers = await get_kademlia_peers_from_hosts(peers) + log.info("Found %d peers from tracker %s for %s", len(peers), server, self.sd_hash[:8]) + self.peer_queue.put_nowait(peers) + async def load_descriptor(self, connection_id: int = 0): # download or get the sd blob sd_blob = self.blob_manager.get_blob(self.sd_hash) @@ -91,6 +122,7 @@ class StreamDownloader: self.accumulate_task.cancel() _, self.accumulate_task = self.node.accumulate_peers(self.search_queue, self.peer_queue) await self.add_fixed_peers() + asyncio.ensure_future(self.refresh_from_trackers()) # start searching for peers for the sd hash self.search_queue.put_nowait(self.sd_hash) log.info("searching for peers for stream %s", self.sd_hash) From 7b425eb2ac74d50d4369526e3658501ae0257a07 Mon Sep 17 00:00:00 2001 From: Victor Shyba Date: Sat, 5 Mar 2022 04:36:26 -0300 Subject: [PATCH 08/50] add tracker announcer component --- lbry/extras/daemon/components.py | 39 ++++++++++++++++++++++++++++++++ lbry/file/source.py | 1 + lbry/stream/downloader.py | 1 + 3 files changed, 41 insertions(+) diff --git a/lbry/extras/daemon/components.py b/lbry/extras/daemon/components.py index 03bef1534..5049b6bad 100644 --- a/lbry/extras/daemon/components.py +++ b/lbry/extras/daemon/components.py @@ -3,6 +3,7 @@ import os import asyncio import logging import binascii +import time import typing import base58 @@ -48,6 +49,7 @@ BACKGROUND_DOWNLOADER_COMPONENT = "background_downloader" PEER_PROTOCOL_SERVER_COMPONENT = "peer_protocol_server" UPNP_COMPONENT = "upnp" EXCHANGE_RATE_MANAGER_COMPONENT = "exchange_rate_manager" +TRACKER_ANNOUNCER_COMPONENT = "tracker_announcer_component" LIBTORRENT_COMPONENT = "libtorrent_component" @@ -708,3 +710,40 @@ class ExchangeRateManagerComponent(Component): async def stop(self): self.exchange_rate_manager.stop() + + +class TrackerAnnouncerComponent(Component): + component_name = TRACKER_ANNOUNCER_COMPONENT + depends_on = [FILE_MANAGER_COMPONENT] + + def __init__(self, component_manager): + super().__init__(component_manager) + self.file_manager = None + self.announce_task = None + + @property + def component(self) -> ExchangeRateManager: + return self.exchange_rate_manager + + async def announce_forever(self): + while True: + to_sleep = 60 * 10 + for file in self.file_manager.get_filtered(): + if not file.downloader: + continue + next_announce = file.downloader.next_tracker_announce_time + if next_announce is None or next_announce <= time.time(): + await file.downloader.refresh_from_trackers(False) + else: + to_sleep = min(to_sleep, next_announce - time.time()) + await asyncio.sleep(to_sleep + 1) + + async def start(self): + self.file_manager = self.component_manager.get_component(FILE_MANAGER_COMPONENT) + self.announce_task = asyncio.create_task(self.announce_forever()) + + async def stop(self): + self.file_manager = None + if self.announce_task and not self.announce_task.done(): + self.announce_task.cancel() + self.announce_task = None diff --git a/lbry/file/source.py b/lbry/file/source.py index b661eb594..0cded2f6c 100644 --- a/lbry/file/source.py +++ b/lbry/file/source.py @@ -45,6 +45,7 @@ class ManagedDownloadSource: self.purchase_receipt = None self._added_on = added_on self.analytics_manager = analytics_manager + self.downloader = None self.saving = asyncio.Event(loop=self.loop) self.finished_writing = asyncio.Event(loop=self.loop) diff --git a/lbry/stream/downloader.py b/lbry/stream/downloader.py index 874715c6f..608b33849 100644 --- a/lbry/stream/downloader.py +++ b/lbry/stream/downloader.py @@ -76,6 +76,7 @@ class StreamDownloader: try: announcement = await get_peer_list( bytes.fromhex(self.sd_hash)[:20], node_id, port, server[0], server[1]) + log.info("Announced %s to %s", self.sd_hash[:8], server) self.next_tracker_announce_time = max(self.next_tracker_announce_time or 0, time.time() + announcement.interval) except asyncio.CancelledError: From 758f9deafef48891a666239ec46ea20d82d00de4 Mon Sep 17 00:00:00 2001 From: Victor Shyba Date: Sat, 5 Mar 2022 04:55:57 -0300 Subject: [PATCH 09/50] fix unit tests --- tests/unit/blob_exchange/test_transfer_blob.py | 3 ++- tests/unit/components/test_component_manager.py | 4 ++++ tests/unit/lbrynet_daemon/test_allowed_origin.py | 4 ++-- tests/unit/stream/test_managed_stream.py | 7 ------- 4 files changed, 8 insertions(+), 10 deletions(-) diff --git a/tests/unit/blob_exchange/test_transfer_blob.py b/tests/unit/blob_exchange/test_transfer_blob.py index a50a89e54..5943c3874 100644 --- a/tests/unit/blob_exchange/test_transfer_blob.py +++ b/tests/unit/blob_exchange/test_transfer_blob.py @@ -54,7 +54,8 @@ class BlobExchangeTestBase(AsyncioTestCase): download_dir=self.client_dir, wallet=self.client_wallet_dir, save_files=True, - fixed_peers=[] + fixed_peers=[], + tracker_servers=[] ) self.client_config.transaction_cache_size = 10000 self.client_storage = SQLiteStorage(self.client_config, os.path.join(self.client_dir, "lbrynet.sqlite")) diff --git a/tests/unit/components/test_component_manager.py b/tests/unit/components/test_component_manager.py index a237c1ac8..269591d8e 100644 --- a/tests/unit/components/test_component_manager.py +++ b/tests/unit/components/test_component_manager.py @@ -34,6 +34,7 @@ class TestComponentManager(AsyncioTestCase): ], [ components.BackgroundDownloaderComponent, + components.TrackerAnnouncerComponent ] ] self.component_manager = ComponentManager(Config()) @@ -150,6 +151,9 @@ class FakeDelayedFileManager(FakeComponent): async def start(self): await asyncio.sleep(1) + def get_filtered(self): + return [] + class TestComponentManagerProperStart(AdvanceTimeTestCase): diff --git a/tests/unit/lbrynet_daemon/test_allowed_origin.py b/tests/unit/lbrynet_daemon/test_allowed_origin.py index b70fe6d3f..d7e31ea4d 100644 --- a/tests/unit/lbrynet_daemon/test_allowed_origin.py +++ b/tests/unit/lbrynet_daemon/test_allowed_origin.py @@ -11,7 +11,7 @@ from lbry.extras.daemon.components import ( DATABASE_COMPONENT, DISK_SPACE_COMPONENT, BLOB_COMPONENT, WALLET_COMPONENT, DHT_COMPONENT, HASH_ANNOUNCER_COMPONENT, FILE_MANAGER_COMPONENT, PEER_PROTOCOL_SERVER_COMPONENT, UPNP_COMPONENT, EXCHANGE_RATE_MANAGER_COMPONENT, WALLET_SERVER_PAYMENTS_COMPONENT, - LIBTORRENT_COMPONENT, BACKGROUND_DOWNLOADER_COMPONENT + LIBTORRENT_COMPONENT, BACKGROUND_DOWNLOADER_COMPONENT, TRACKER_ANNOUNCER_COMPONENT ) from lbry.extras.daemon.daemon import Daemon @@ -72,7 +72,7 @@ class TestAccessHeaders(AsyncioTestCase): DATABASE_COMPONENT, DISK_SPACE_COMPONENT, BLOB_COMPONENT, WALLET_COMPONENT, DHT_COMPONENT, HASH_ANNOUNCER_COMPONENT, FILE_MANAGER_COMPONENT, PEER_PROTOCOL_SERVER_COMPONENT, UPNP_COMPONENT, EXCHANGE_RATE_MANAGER_COMPONENT, WALLET_SERVER_PAYMENTS_COMPONENT, - LIBTORRENT_COMPONENT, BACKGROUND_DOWNLOADER_COMPONENT + LIBTORRENT_COMPONENT, BACKGROUND_DOWNLOADER_COMPONENT, TRACKER_ANNOUNCER_COMPONENT ) Daemon.component_attributes = {} self.daemon = Daemon(conf) diff --git a/tests/unit/stream/test_managed_stream.py b/tests/unit/stream/test_managed_stream.py index 3542c60e4..953eb4c83 100644 --- a/tests/unit/stream/test_managed_stream.py +++ b/tests/unit/stream/test_managed_stream.py @@ -13,13 +13,6 @@ from lbry.stream.descriptor import StreamDescriptor from tests.unit.blob_exchange.test_transfer_blob import BlobExchangeTestBase -def get_mock_node(loop): - mock_node = mock.Mock(spec=Node) - mock_node.joined = asyncio.Event(loop=loop) - mock_node.joined.set() - return mock_node - - class TestManagedStream(BlobExchangeTestBase): async def create_stream(self, blob_count: int = 10, file_name='test_file'): self.stream_bytes = b'' From 2344aca1466622ce6d87a979e3086ce20a7710c4 Mon Sep 17 00:00:00 2001 From: Victor Shyba Date: Sat, 5 Mar 2022 05:00:57 -0300 Subject: [PATCH 10/50] fix component property --- lbry/extras/daemon/components.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/lbry/extras/daemon/components.py b/lbry/extras/daemon/components.py index 5049b6bad..8535008d4 100644 --- a/lbry/extras/daemon/components.py +++ b/lbry/extras/daemon/components.py @@ -722,8 +722,8 @@ class TrackerAnnouncerComponent(Component): self.announce_task = None @property - def component(self) -> ExchangeRateManager: - return self.exchange_rate_manager + def component(self): + return self async def announce_forever(self): while True: From 24eb189b7f633ab131f130b5d56bf5550589ee1e Mon Sep 17 00:00:00 2001 From: Victor Shyba Date: Sat, 5 Mar 2022 05:03:14 -0300 Subject: [PATCH 11/50] skip component on test cli --- tests/integration/other/test_cli.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/integration/other/test_cli.py b/tests/integration/other/test_cli.py index ec28bbee3..924504a7e 100644 --- a/tests/integration/other/test_cli.py +++ b/tests/integration/other/test_cli.py @@ -10,7 +10,7 @@ from lbry.extras.daemon.components import ( DATABASE_COMPONENT, DISK_SPACE_COMPONENT, BLOB_COMPONENT, WALLET_COMPONENT, DHT_COMPONENT, HASH_ANNOUNCER_COMPONENT, FILE_MANAGER_COMPONENT, PEER_PROTOCOL_SERVER_COMPONENT, UPNP_COMPONENT, EXCHANGE_RATE_MANAGER_COMPONENT, WALLET_SERVER_PAYMENTS_COMPONENT, - LIBTORRENT_COMPONENT, BACKGROUND_DOWNLOADER_COMPONENT + LIBTORRENT_COMPONENT, BACKGROUND_DOWNLOADER_COMPONENT, TRACKER_ANNOUNCER_COMPONENT ) from lbry.extras.daemon.daemon import Daemon @@ -26,7 +26,7 @@ class CLIIntegrationTest(AsyncioTestCase): DATABASE_COMPONENT, DISK_SPACE_COMPONENT, BLOB_COMPONENT, WALLET_COMPONENT, DHT_COMPONENT, HASH_ANNOUNCER_COMPONENT, FILE_MANAGER_COMPONENT, PEER_PROTOCOL_SERVER_COMPONENT, UPNP_COMPONENT, EXCHANGE_RATE_MANAGER_COMPONENT, WALLET_SERVER_PAYMENTS_COMPONENT, - LIBTORRENT_COMPONENT, BACKGROUND_DOWNLOADER_COMPONENT + LIBTORRENT_COMPONENT, BACKGROUND_DOWNLOADER_COMPONENT, TRACKER_ANNOUNCER_COMPONENT ) Daemon.component_attributes = {} self.daemon = Daemon(conf) From 7acaecaed224300bf155fb9857a139c81bf2045d Mon Sep 17 00:00:00 2001 From: Victor Shyba Date: Mon, 7 Mar 2022 22:30:21 -0300 Subject: [PATCH 12/50] managed_stream: remove unused imports --- lbry/stream/managed_stream.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/lbry/stream/managed_stream.py b/lbry/stream/managed_stream.py index 2a85da66e..5ceaff2ad 100644 --- a/lbry/stream/managed_stream.py +++ b/lbry/stream/managed_stream.py @@ -16,10 +16,8 @@ from lbry.file.source import ManagedDownloadSource if typing.TYPE_CHECKING: from lbry.conf import Config - from lbry.schema.claim import Claim from lbry.blob.blob_manager import BlobManager from lbry.blob.blob_info import BlobInfo - from lbry.dht.node import Node from lbry.extras.daemon.analytics import AnalyticsManager from lbry.wallet.transaction import Transaction From 9e9a64d989eebcb9394ae1d491c10982f0523af1 Mon Sep 17 00:00:00 2001 From: Victor Shyba Date: Mon, 7 Mar 2022 23:35:12 -0300 Subject: [PATCH 13/50] evented system for tracker announcements --- lbry/extras/daemon/components.py | 12 +++-- lbry/stream/downloader.py | 38 ++++----------- lbry/torrent/tracker.py | 74 ++++++++++++++++++++++-------- tests/unit/torrent/test_tracker.py | 28 +++++------ 4 files changed, 89 insertions(+), 63 deletions(-) diff --git a/lbry/extras/daemon/components.py b/lbry/extras/daemon/components.py index 8535008d4..f290bedf2 100644 --- a/lbry/extras/daemon/components.py +++ b/lbry/extras/daemon/components.py @@ -28,6 +28,8 @@ from lbry.extras.daemon.storage import SQLiteStorage from lbry.torrent.torrent_manager import TorrentManager from lbry.wallet import WalletManager from lbry.wallet.usage_payment import WalletServerPayer +from lbry.torrent.tracker import TrackerClient + try: from lbry.torrent.session import TorrentSession except ImportError: @@ -720,6 +722,7 @@ class TrackerAnnouncerComponent(Component): super().__init__(component_manager) self.file_manager = None self.announce_task = None + self.tracker_client: typing.Optional[TrackerClient] = None @property def component(self): @@ -733,12 +736,15 @@ class TrackerAnnouncerComponent(Component): continue next_announce = file.downloader.next_tracker_announce_time if next_announce is None or next_announce <= time.time(): - await file.downloader.refresh_from_trackers(False) - else: - to_sleep = min(to_sleep, next_announce - time.time()) + self.tracker_client.on_hash(bytes.fromhex(file.sd_hash)) await asyncio.sleep(to_sleep + 1) async def start(self): + node = self.component_manager.get_component(DHT_COMPONENT) \ + if self.component_manager.has_component(DHT_COMPONENT) else None + node_id = node.protocol.node_id if node else None + self.tracker_client = TrackerClient(node_id, self.conf.tcp_port, self.conf.tracker_servers) + await self.tracker_client.start() self.file_manager = self.component_manager.get_component(FILE_MANAGER_COMPONENT) self.announce_task = asyncio.create_task(self.announce_forever()) diff --git a/lbry/stream/downloader.py b/lbry/stream/downloader.py index 608b33849..76f0bcdd2 100644 --- a/lbry/stream/downloader.py +++ b/lbry/stream/downloader.py @@ -10,9 +10,10 @@ from lbry.error import DownloadSDTimeoutError from lbry.utils import lru_cache_concurrent from lbry.stream.descriptor import StreamDescriptor from lbry.blob_exchange.downloader import BlobDownloader -from lbry.torrent.tracker import get_peer_list +from lbry.torrent.tracker import subscribe_hash if typing.TYPE_CHECKING: + from lbry.torrent.tracker import AnnounceResponse from lbry.conf import Config from lbry.dht.node import Node from lbry.blob.blob_manager import BlobManager @@ -67,32 +68,13 @@ class StreamDownloader: fixed_peers = await get_kademlia_peers_from_hosts(self.config.fixed_peers) self.fixed_peers_handle = self.loop.call_later(self.fixed_peers_delay, _add_fixed_peers, fixed_peers) - async def refresh_from_trackers(self, save_peers=True): - if not self.config.tracker_servers: - return - node_id = self.node.protocol.node_id if self.node else None - port = self.config.tcp_port - for server in self.config.tracker_servers: - try: - announcement = await get_peer_list( - bytes.fromhex(self.sd_hash)[:20], node_id, port, server[0], server[1]) - log.info("Announced %s to %s", self.sd_hash[:8], server) - self.next_tracker_announce_time = max(self.next_tracker_announce_time or 0, - time.time() + announcement.interval) - except asyncio.CancelledError: - raise - except asyncio.TimeoutError: - log.warning("Tracker timed out: %s", server) - return - except Exception: - log.exception("Unexpected error querying tracker %s", server) - return - if not save_peers: - return - peers = [(str(ipaddress.ip_address(peer.address)), peer.port) for peer in announcement.peers] - peers = await get_kademlia_peers_from_hosts(peers) - log.info("Found %d peers from tracker %s for %s", len(peers), server, self.sd_hash[:8]) - self.peer_queue.put_nowait(peers) + async def _process_announcement(self, announcement: 'AnnounceResponse'): + peers = [(str(ipaddress.ip_address(peer.address)), peer.port) for peer in announcement.peers] + peers = await get_kademlia_peers_from_hosts(peers) + log.info("Found %d peers from tracker for %s", len(peers), self.sd_hash[:8]) + self.next_tracker_announce_time = min(time() + announcement.interval, + self.next_tracker_announce_time or 1 << 64) + self.peer_queue.put_nowait(peers) async def load_descriptor(self, connection_id: int = 0): # download or get the sd blob @@ -123,7 +105,7 @@ class StreamDownloader: self.accumulate_task.cancel() _, self.accumulate_task = self.node.accumulate_peers(self.search_queue, self.peer_queue) await self.add_fixed_peers() - asyncio.ensure_future(self.refresh_from_trackers()) + subscribe_hash(self.sd_hash, self._process_announcement) # start searching for peers for the sd hash self.search_queue.put_nowait(self.sd_hash) log.info("searching for peers for stream %s", self.sd_hash) diff --git a/lbry/torrent/tracker.py b/lbry/torrent/tracker.py index c46f63bbc..33cf330a4 100644 --- a/lbry/torrent/tracker.py +++ b/lbry/torrent/tracker.py @@ -4,9 +4,11 @@ import asyncio import logging from collections import namedtuple -from lbry.utils import resolve_host +from lbry.utils import resolve_host, async_timed_cache +from lbry.wallet.stream import StreamController log = logging.getLogger(__name__) +CONNECTION_EXPIRES_AFTER_SECONDS = 360 # see: http://bittorrent.org/beps/bep_0015.html and http://xbtt.sourceforge.net/udp_tracker_protocol.html ConnectRequest = namedtuple("ConnectRequest", ["connection_id", "action", "transaction_id"]) ConnectResponse = namedtuple("ConnectResponse", ["action", "transaction_id", "connection_id"]) @@ -77,17 +79,19 @@ class UDPTrackerClientProtocol(asyncio.DatagramProtocol): return decode(ConnectResponse, await self.request(ConnectRequest(0x41727101980, 0, transaction_id), tracker_ip, tracker_port)) - async def announce(self, info_hash, peer_id, port, tracker_ip, tracker_port, connection_id=None, stopped=False): - if not connection_id: - reply = await self.connect(tracker_ip, tracker_port) - connection_id = reply.connection_id + @async_timed_cache(CONNECTION_EXPIRES_AFTER_SECONDS) + async def ensure_connection_id(self, peer_id, tracker_ip, tracker_port): + # peer_id is just to ensure cache coherency + return (await self.connect(tracker_ip, tracker_port)).connection_id + + async def announce(self, info_hash, peer_id, port, tracker_ip, tracker_port, stopped=False): + connection_id = await self.ensure_connection_id(peer_id, tracker_ip, tracker_port) # this should make the key deterministic but unique per info hash + peer id key = int.from_bytes(info_hash[:4], "big") ^ int.from_bytes(peer_id[:4], "big") ^ port transaction_id = random.getrandbits(32) req = AnnounceRequest( connection_id, 1, transaction_id, info_hash, peer_id, 0, 0, 0, 3 if stopped else 1, 0, key, -1, port) - reply = await self.request(req, tracker_ip, tracker_port) - return decode(AnnounceResponse, reply), connection_id + return decode(AnnounceResponse, await self.request(req, tracker_ip, tracker_port)) async def scrape(self, infohashes, tracker_ip, tracker_port, connection_id=None): if not connection_id: @@ -107,20 +111,52 @@ class UDPTrackerClientProtocol(asyncio.DatagramProtocol): if data[3] == 3: return self.data_queue[transaction_id].set_exception(Exception(decode(ErrorResponse, data).message)) return self.data_queue[transaction_id].set_result(data) - print("error", data.hex()) + log.debug("unexpected packet (can be a response for a previously timed out request): %s", data.hex()) def connection_lost(self, exc: Exception = None) -> None: self.transport = None -async def get_peer_list(info_hash, node_id, port, tracker_ip, tracker_port, stopped=False): - node_id = node_id or random.getrandbits(160).to_bytes(20, "big", signed=False) - tracker_ip = await resolve_host(tracker_ip, tracker_port, 'udp') - proto = UDPTrackerClientProtocol() - transport, _ = await asyncio.get_running_loop().create_datagram_endpoint(lambda: proto, local_addr=("0.0.0.0", 0)) - try: - reply, _ = await proto.announce(info_hash, node_id, port, tracker_ip, tracker_port, stopped=stopped) - return reply - finally: - if not transport.is_closing(): - transport.close() +class TrackerClient: + EVENT_CONTROLLER = StreamController() + def __init__(self, node_id, announce_port, servers): + self.client = UDPTrackerClientProtocol() + self.transport = None + self.node_id = node_id or random.getrandbits(160).to_bytes(20, "big", signed=False) + self.announce_port = announce_port + self.servers = servers + + async def start(self): + self.transport, _ = await asyncio.get_running_loop().create_datagram_endpoint( + lambda: self.client, local_addr=("0.0.0.0", 0)) + self.EVENT_CONTROLLER.stream.listen(lambda request: self.on_hash(request[1]) if request[0] == 'search' else None) + + def stop(self): + if self.transport is not None: + self.transport.close() + self.client = None + self.transport = None + self.EVENT_CONTROLLER.close() + + def on_hash(self, info_hash): + asyncio.ensure_future(self.get_peer_list(info_hash)) + + async def get_peer_list(self, info_hash, stopped=False): + found = [] + for done in asyncio.as_completed([self._probe_server(info_hash, *server, stopped) for server in self.servers]): + found.append(await done) + return found + + async def _probe_server(self, info_hash, tracker_host, tracker_port, stopped=False): + tracker_ip = await resolve_host(tracker_host, tracker_port, 'udp') + result = await self.client.announce( + info_hash, self.node_id, self.announce_port, tracker_ip, tracker_port, stopped) + log.info("Announced to tracker. Found %d peers for %s on %s", + len(result.peers), info_hash.hex()[:8], tracker_host) + self.EVENT_CONTROLLER.add((info_hash, result)) + return result + + +def subscribe_hash(hash, on_data): + TrackerClient.EVENT_CONTROLLER.add(('search', bytes.fromhex(hash))) + TrackerClient.EVENT_CONTROLLER.stream.listen(lambda request: on_data(request[1]) if request[0] == hash else None) diff --git a/tests/unit/torrent/test_tracker.py b/tests/unit/torrent/test_tracker.py index a03493118..7d981cf8a 100644 --- a/tests/unit/torrent/test_tracker.py +++ b/tests/unit/torrent/test_tracker.py @@ -3,8 +3,8 @@ import random from functools import reduce from lbry.testcase import AsyncioTestCase -from lbry.torrent.tracker import UDPTrackerClientProtocol, encode, decode, CompactIPv4Peer, ConnectRequest, \ - ConnectResponse, AnnounceRequest, ErrorResponse, AnnounceResponse, get_peer_list +from lbry.torrent.tracker import encode, decode, CompactIPv4Peer, ConnectRequest, \ + ConnectResponse, AnnounceRequest, ErrorResponse, AnnounceResponse, TrackerClient, subscribe_hash class UDPTrackerServerProtocol(asyncio.DatagramProtocol): # for testing. Not suitable for production @@ -44,30 +44,32 @@ class UDPTrackerServerProtocol(asyncio.DatagramProtocol): # for testing. Not su class UDPTrackerClientTestCase(AsyncioTestCase): async def asyncSetUp(self): - transport, _ = await self.loop.create_datagram_endpoint(UDPTrackerServerProtocol, local_addr=("127.0.0.1", 59900)) - self.addCleanup(transport.close) - self.client = UDPTrackerClientProtocol() - transport, _ = await self.loop.create_datagram_endpoint(lambda: self.client, local_addr=("127.0.0.1", 0)) + self.server = UDPTrackerServerProtocol() + transport, _ = await self.loop.create_datagram_endpoint(lambda: self.server, local_addr=("127.0.0.1", 59900)) self.addCleanup(transport.close) + self.client = TrackerClient(b"\x00" * 48, 4444, [("127.0.0.1", 59900)]) + await self.client.start() + self.addCleanup(self.client.stop) async def test_announce(self): info_hash = random.getrandbits(160).to_bytes(20, "big", signed=False) - peer_id = random.getrandbits(160).to_bytes(20, "big", signed=False) - announcement, _ = await self.client.announce(info_hash, peer_id, 4444, "127.0.0.1", 59900) + announcement = (await self.client.get_peer_list(info_hash))[0] self.assertEqual(announcement.seeders, 1) self.assertEqual(announcement.peers, [CompactIPv4Peer(int.from_bytes(bytes([127, 0, 0, 1]), "big", signed=False), 4444)]) async def test_announce_using_helper_function(self): info_hash = random.getrandbits(160).to_bytes(20, "big", signed=False) - announcemenet = await get_peer_list(info_hash, None, 4444, "127.0.0.1", 59900) - peers = announcemenet.peers + queue = asyncio.Queue() + subscribe_hash(info_hash, queue.put_nowait) + announcement = await queue.get() + peers = announcement.peers self.assertEqual(peers, [CompactIPv4Peer(int.from_bytes(bytes([127, 0, 0, 1]), "big", signed=False), 4444)]) - self.assertEqual((await get_peer_list(info_hash, None, 4444, "127.0.0.1", 59900, stopped=True)).peers, []) async def test_error(self): info_hash = random.getrandbits(160).to_bytes(20, "big", signed=False) - peer_id = random.getrandbits(160).to_bytes(20, "big", signed=False) + await self.client.get_peer_list(info_hash) + self.server.known_conns.clear() with self.assertRaises(Exception) as err: - announcement, _ = await self.client.announce(info_hash, peer_id, 4444, "127.0.0.1", 59900, connection_id=10) + await self.client.get_peer_list(info_hash) self.assertEqual(err.exception.args[0], b'Connection ID missmatch.\x00') From 888e9918a6ec4f8e8f289c9e788b7b2b21f89dff Mon Sep 17 00:00:00 2001 From: Victor Shyba Date: Mon, 7 Mar 2022 23:44:08 -0300 Subject: [PATCH 14/50] improve timeout handling --- lbry/extras/daemon/components.py | 2 +- lbry/torrent/tracker.py | 17 +++++++++++------ 2 files changed, 12 insertions(+), 7 deletions(-) diff --git a/lbry/extras/daemon/components.py b/lbry/extras/daemon/components.py index f290bedf2..9312233e0 100644 --- a/lbry/extras/daemon/components.py +++ b/lbry/extras/daemon/components.py @@ -730,7 +730,7 @@ class TrackerAnnouncerComponent(Component): async def announce_forever(self): while True: - to_sleep = 60 * 10 + to_sleep = 60 * 1 for file in self.file_manager.get_filtered(): if not file.downloader: continue diff --git a/lbry/torrent/tracker.py b/lbry/torrent/tracker.py index 33cf330a4..00db99183 100644 --- a/lbry/torrent/tracker.py +++ b/lbry/torrent/tracker.py @@ -59,9 +59,10 @@ def encode(obj): class UDPTrackerClientProtocol(asyncio.DatagramProtocol): - def __init__(self): + def __init__(self, timeout = 30.0): self.transport = None self.data_queue = {} + self.timeout = timeout def connection_made(self, transport: asyncio.DatagramTransport) -> None: self.transport = transport @@ -70,7 +71,7 @@ class UDPTrackerClientProtocol(asyncio.DatagramProtocol): self.data_queue[obj.transaction_id] = asyncio.get_running_loop().create_future() self.transport.sendto(encode(obj), (tracker_ip, tracker_port)) try: - return await asyncio.wait_for(self.data_queue[obj.transaction_id], 3.0) + return await asyncio.wait_for(self.data_queue[obj.transaction_id], self.timeout) finally: self.data_queue.pop(obj.transaction_id, None) @@ -144,13 +145,17 @@ class TrackerClient: async def get_peer_list(self, info_hash, stopped=False): found = [] for done in asyncio.as_completed([self._probe_server(info_hash, *server, stopped) for server in self.servers]): - found.append(await done) + found.extend(await done) return found async def _probe_server(self, info_hash, tracker_host, tracker_port, stopped=False): - tracker_ip = await resolve_host(tracker_host, tracker_port, 'udp') - result = await self.client.announce( - info_hash, self.node_id, self.announce_port, tracker_ip, tracker_port, stopped) + try: + tracker_ip = await resolve_host(tracker_host, tracker_port, 'udp') + result = await self.client.announce( + info_hash, self.node_id, self.announce_port, tracker_ip, tracker_port, stopped) + except asyncio.TimeoutError: + log.info("Tracker timed out: %s:%d", tracker_host, tracker_port) + return [] log.info("Announced to tracker. Found %d peers for %s on %s", len(result.peers), info_hash.hex()[:8], tracker_host) self.EVENT_CONTROLLER.add((info_hash, result)) From 43e50f7f046e4c861b1c5dbff5c511892a443073 Mon Sep 17 00:00:00 2001 From: Victor Shyba Date: Mon, 7 Mar 2022 23:57:53 -0300 Subject: [PATCH 15/50] fix subscribe_hash --- lbry/torrent/tracker.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/lbry/torrent/tracker.py b/lbry/torrent/tracker.py index 00db99183..f0b4e5dbf 100644 --- a/lbry/torrent/tracker.py +++ b/lbry/torrent/tracker.py @@ -164,4 +164,5 @@ class TrackerClient: def subscribe_hash(hash, on_data): TrackerClient.EVENT_CONTROLLER.add(('search', bytes.fromhex(hash))) - TrackerClient.EVENT_CONTROLLER.stream.listen(lambda request: on_data(request[1]) if request[0] == hash else None) + TrackerClient.EVENT_CONTROLLER.stream.listen( + lambda request: on_data(request[1]) if request[0].hex() == hash else None) From 2d9c5742c7c6accfc072935df6b3f06895839d13 Mon Sep 17 00:00:00 2001 From: Victor Shyba Date: Tue, 8 Mar 2022 00:58:18 -0300 Subject: [PATCH 16/50] cache results, save interval on tracker --- lbry/extras/daemon/components.py | 9 +++------ lbry/stream/downloader.py | 6 ++---- lbry/torrent/tracker.py | 26 +++++++++++++++++++------- tests/unit/torrent/test_tracker.py | 1 + 4 files changed, 25 insertions(+), 17 deletions(-) diff --git a/lbry/extras/daemon/components.py b/lbry/extras/daemon/components.py index 9312233e0..6b7fbb264 100644 --- a/lbry/extras/daemon/components.py +++ b/lbry/extras/daemon/components.py @@ -3,7 +3,6 @@ import os import asyncio import logging import binascii -import time import typing import base58 @@ -730,14 +729,12 @@ class TrackerAnnouncerComponent(Component): async def announce_forever(self): while True: - to_sleep = 60 * 1 + to_sleep = 6 for file in self.file_manager.get_filtered(): if not file.downloader: continue - next_announce = file.downloader.next_tracker_announce_time - if next_announce is None or next_announce <= time.time(): - self.tracker_client.on_hash(bytes.fromhex(file.sd_hash)) - await asyncio.sleep(to_sleep + 1) + self.tracker_client.on_hash(bytes.fromhex(file.sd_hash)) + await asyncio.sleep(to_sleep) async def start(self): node = self.component_manager.get_component(DHT_COMPONENT) \ diff --git a/lbry/stream/downloader.py b/lbry/stream/downloader.py index 76f0bcdd2..b4cd9bab3 100644 --- a/lbry/stream/downloader.py +++ b/lbry/stream/downloader.py @@ -41,7 +41,6 @@ class StreamDownloader: self.added_fixed_peers = False self.time_to_descriptor: typing.Optional[float] = None self.time_to_first_bytes: typing.Optional[float] = None - self.next_tracker_announce_time = None async def cached_read_blob(blob_info: 'BlobInfo') -> bytes: return await self.read_blob(blob_info, 2) @@ -72,8 +71,6 @@ class StreamDownloader: peers = [(str(ipaddress.ip_address(peer.address)), peer.port) for peer in announcement.peers] peers = await get_kademlia_peers_from_hosts(peers) log.info("Found %d peers from tracker for %s", len(peers), self.sd_hash[:8]) - self.next_tracker_announce_time = min(time() + announcement.interval, - self.next_tracker_announce_time or 1 << 64) self.peer_queue.put_nowait(peers) async def load_descriptor(self, connection_id: int = 0): @@ -105,7 +102,8 @@ class StreamDownloader: self.accumulate_task.cancel() _, self.accumulate_task = self.node.accumulate_peers(self.search_queue, self.peer_queue) await self.add_fixed_peers() - subscribe_hash(self.sd_hash, self._process_announcement) + subscribe_hash( + bytes.fromhex(self.sd_hash), lambda result: asyncio.ensure_future(self._process_announcement(result))) # start searching for peers for the sd hash self.search_queue.put_nowait(self.sd_hash) log.info("searching for peers for stream %s", self.sd_hash) diff --git a/lbry/torrent/tracker.py b/lbry/torrent/tracker.py index f0b4e5dbf..28fef4cf8 100644 --- a/lbry/torrent/tracker.py +++ b/lbry/torrent/tracker.py @@ -2,6 +2,7 @@ import random import struct import asyncio import logging +import time from collections import namedtuple from lbry.utils import resolve_host, async_timed_cache @@ -120,12 +121,14 @@ class UDPTrackerClientProtocol(asyncio.DatagramProtocol): class TrackerClient: EVENT_CONTROLLER = StreamController() + def __init__(self, node_id, announce_port, servers): self.client = UDPTrackerClientProtocol() self.transport = None self.node_id = node_id or random.getrandbits(160).to_bytes(20, "big", signed=False) self.announce_port = announce_port self.servers = servers + self.results = {} # we can't probe the server before the interval, so we keep the result here until it expires async def start(self): self.transport, _ = await asyncio.get_running_loop().create_datagram_endpoint( @@ -145,24 +148,33 @@ class TrackerClient: async def get_peer_list(self, info_hash, stopped=False): found = [] for done in asyncio.as_completed([self._probe_server(info_hash, *server, stopped) for server in self.servers]): - found.extend(await done) + result = await done + if result is not None: + self.EVENT_CONTROLLER.add((info_hash, result)) + found.append(result) return found async def _probe_server(self, info_hash, tracker_host, tracker_port, stopped=False): + result = None + if info_hash in self.results: + next_announcement, result = self.results[info_hash] + if time.time() < next_announcement: + return result try: tracker_ip = await resolve_host(tracker_host, tracker_port, 'udp') result = await self.client.announce( info_hash, self.node_id, self.announce_port, tracker_ip, tracker_port, stopped) except asyncio.TimeoutError: log.info("Tracker timed out: %s:%d", tracker_host, tracker_port) - return [] + return None + finally: + self.results[info_hash] = (time.time() + (result.interval if result else 60.0), result) log.info("Announced to tracker. Found %d peers for %s on %s", len(result.peers), info_hash.hex()[:8], tracker_host) - self.EVENT_CONTROLLER.add((info_hash, result)) return result -def subscribe_hash(hash, on_data): - TrackerClient.EVENT_CONTROLLER.add(('search', bytes.fromhex(hash))) - TrackerClient.EVENT_CONTROLLER.stream.listen( - lambda request: on_data(request[1]) if request[0].hex() == hash else None) +def subscribe_hash(hash: bytes, on_data): + TrackerClient.EVENT_CONTROLLER.add(('search', hash)) + TrackerClient.EVENT_CONTROLLER.stream.where(lambda request: request[0] == hash).add_done_callback( + lambda request: on_data(request.result()[1])) diff --git a/tests/unit/torrent/test_tracker.py b/tests/unit/torrent/test_tracker.py index 7d981cf8a..67448853e 100644 --- a/tests/unit/torrent/test_tracker.py +++ b/tests/unit/torrent/test_tracker.py @@ -70,6 +70,7 @@ class UDPTrackerClientTestCase(AsyncioTestCase): info_hash = random.getrandbits(160).to_bytes(20, "big", signed=False) await self.client.get_peer_list(info_hash) self.server.known_conns.clear() + self.client.results.clear() with self.assertRaises(Exception) as err: await self.client.get_peer_list(info_hash) self.assertEqual(err.exception.args[0], b'Connection ID missmatch.\x00') From 30acde0afc0644cce34259310209474ddd92de1b Mon Sep 17 00:00:00 2001 From: Victor Shyba Date: Tue, 8 Mar 2022 01:02:43 -0300 Subject: [PATCH 17/50] at most 10 announces concurrently --- lbry/torrent/tracker.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/lbry/torrent/tracker.py b/lbry/torrent/tracker.py index 28fef4cf8..63709d233 100644 --- a/lbry/torrent/tracker.py +++ b/lbry/torrent/tracker.py @@ -129,6 +129,7 @@ class TrackerClient: self.announce_port = announce_port self.servers = servers self.results = {} # we can't probe the server before the interval, so we keep the result here until it expires + self.semaphore = asyncio.Semaphore(10) async def start(self): self.transport, _ = await asyncio.get_running_loop().create_datagram_endpoint( @@ -162,8 +163,9 @@ class TrackerClient: return result try: tracker_ip = await resolve_host(tracker_host, tracker_port, 'udp') - result = await self.client.announce( - info_hash, self.node_id, self.announce_port, tracker_ip, tracker_port, stopped) + async with self.semaphore: + result = await self.client.announce( + info_hash, self.node_id, self.announce_port, tracker_ip, tracker_port, stopped) except asyncio.TimeoutError: log.info("Tracker timed out: %s:%d", tracker_host, tracker_port) return None From 3855db6c663744b66a9bc3750f429e08e2704719 Mon Sep 17 00:00:00 2001 From: Victor Shyba Date: Tue, 8 Mar 2022 01:03:26 -0300 Subject: [PATCH 18/50] pause announcer for 1 minute each round --- lbry/extras/daemon/components.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lbry/extras/daemon/components.py b/lbry/extras/daemon/components.py index 6b7fbb264..fee15ea09 100644 --- a/lbry/extras/daemon/components.py +++ b/lbry/extras/daemon/components.py @@ -729,7 +729,7 @@ class TrackerAnnouncerComponent(Component): async def announce_forever(self): while True: - to_sleep = 6 + to_sleep = 60.0 for file in self.file_manager.get_filtered(): if not file.downloader: continue From 28fdd629452d400808244bb2a7ae825c6b602ddd Mon Sep 17 00:00:00 2001 From: Victor Shyba Date: Tue, 8 Mar 2022 17:25:03 -0300 Subject: [PATCH 19/50] move concurreny control to lower layer --- lbry/torrent/tracker.py | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/lbry/torrent/tracker.py b/lbry/torrent/tracker.py index 63709d233..e02659893 100644 --- a/lbry/torrent/tracker.py +++ b/lbry/torrent/tracker.py @@ -64,15 +64,17 @@ class UDPTrackerClientProtocol(asyncio.DatagramProtocol): self.transport = None self.data_queue = {} self.timeout = timeout + self.semaphore = asyncio.Semaphore(10) def connection_made(self, transport: asyncio.DatagramTransport) -> None: self.transport = transport async def request(self, obj, tracker_ip, tracker_port): self.data_queue[obj.transaction_id] = asyncio.get_running_loop().create_future() - self.transport.sendto(encode(obj), (tracker_ip, tracker_port)) try: - return await asyncio.wait_for(self.data_queue[obj.transaction_id], self.timeout) + async with self.semaphore: + self.transport.sendto(encode(obj), (tracker_ip, tracker_port)) + return await asyncio.wait_for(self.data_queue[obj.transaction_id], self.timeout) finally: self.data_queue.pop(obj.transaction_id, None) @@ -129,7 +131,6 @@ class TrackerClient: self.announce_port = announce_port self.servers = servers self.results = {} # we can't probe the server before the interval, so we keep the result here until it expires - self.semaphore = asyncio.Semaphore(10) async def start(self): self.transport, _ = await asyncio.get_running_loop().create_datagram_endpoint( @@ -163,9 +164,8 @@ class TrackerClient: return result try: tracker_ip = await resolve_host(tracker_host, tracker_port, 'udp') - async with self.semaphore: - result = await self.client.announce( - info_hash, self.node_id, self.announce_port, tracker_ip, tracker_port, stopped) + result = await self.client.announce( + info_hash, self.node_id, self.announce_port, tracker_ip, tracker_port, stopped) except asyncio.TimeoutError: log.info("Tracker timed out: %s:%d", tracker_host, tracker_port) return None From 61c99abcf1bd61b9278fa7cb32e037653b857d20 Mon Sep 17 00:00:00 2001 From: Victor Shyba Date: Tue, 8 Mar 2022 17:32:35 -0300 Subject: [PATCH 20/50] avoid readding the same hash when tracker is busy with too many files --- lbry/torrent/tracker.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/lbry/torrent/tracker.py b/lbry/torrent/tracker.py index e02659893..5d3956419 100644 --- a/lbry/torrent/tracker.py +++ b/lbry/torrent/tracker.py @@ -131,6 +131,7 @@ class TrackerClient: self.announce_port = announce_port self.servers = servers self.results = {} # we can't probe the server before the interval, so we keep the result here until it expires + self.tasks = {} async def start(self): self.transport, _ = await asyncio.get_running_loop().create_datagram_endpoint( @@ -145,7 +146,10 @@ class TrackerClient: self.EVENT_CONTROLLER.close() def on_hash(self, info_hash): - asyncio.ensure_future(self.get_peer_list(info_hash)) + if info_hash not in self.tasks: + fut = asyncio.ensure_future(self.get_peer_list(info_hash)) + fut.add_done_callback(lambda *_: self.tasks.pop(info_hash, None)) + self.tasks[info_hash] = fut async def get_peer_list(self, info_hash, stopped=False): found = [] From 47e432b4bb6d0e621ef659328ca11e91abbefb17 Mon Sep 17 00:00:00 2001 From: Victor Shyba Date: Tue, 8 Mar 2022 17:39:27 -0300 Subject: [PATCH 21/50] make it less verbose, only log after all events are fired --- lbry/torrent/tracker.py | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/lbry/torrent/tracker.py b/lbry/torrent/tracker.py index 5d3956419..86da5b2ba 100644 --- a/lbry/torrent/tracker.py +++ b/lbry/torrent/tracker.py @@ -132,6 +132,7 @@ class TrackerClient: self.servers = servers self.results = {} # we can't probe the server before the interval, so we keep the result here until it expires self.tasks = {} + self.announced = 0 async def start(self): self.transport, _ = await asyncio.get_running_loop().create_datagram_endpoint( @@ -145,10 +146,17 @@ class TrackerClient: self.transport = None self.EVENT_CONTROLLER.close() + def hash_done(self, info_hash): + self.announced += 1 + self.tasks.pop(info_hash, None) + if len(self.tasks) == 0: + log.info("Tracker finished announcing %d files.", self.announced) + self.announced = 0 + def on_hash(self, info_hash): if info_hash not in self.tasks: fut = asyncio.ensure_future(self.get_peer_list(info_hash)) - fut.add_done_callback(lambda *_: self.tasks.pop(info_hash, None)) + fut.add_done_callback(lambda *_: self.hash_done(info_hash)) self.tasks[info_hash] = fut async def get_peer_list(self, info_hash, stopped=False): @@ -175,8 +183,7 @@ class TrackerClient: return None finally: self.results[info_hash] = (time.time() + (result.interval if result else 60.0), result) - log.info("Announced to tracker. Found %d peers for %s on %s", - len(result.peers), info_hash.hex()[:8], tracker_host) + log.debug("Announced: %s found %d peers for %s on %s", tracker_host, len(result.peers), info_hash.hex()[:8]) return result From 42fd1c962ed4c3031320abd1b9b050babfee2b0f Mon Sep 17 00:00:00 2001 From: Victor Shyba Date: Tue, 8 Mar 2022 17:40:53 -0300 Subject: [PATCH 22/50] stop tracker tasks on shutdown --- lbry/extras/daemon/components.py | 1 + lbry/torrent/tracker.py | 4 +++- 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/lbry/extras/daemon/components.py b/lbry/extras/daemon/components.py index fee15ea09..5bd2460cf 100644 --- a/lbry/extras/daemon/components.py +++ b/lbry/extras/daemon/components.py @@ -750,3 +750,4 @@ class TrackerAnnouncerComponent(Component): if self.announce_task and not self.announce_task.done(): self.announce_task.cancel() self.announce_task = None + self.tracker_client.stop() diff --git a/lbry/torrent/tracker.py b/lbry/torrent/tracker.py index 86da5b2ba..57824a4c6 100644 --- a/lbry/torrent/tracker.py +++ b/lbry/torrent/tracker.py @@ -5,7 +5,7 @@ import logging import time from collections import namedtuple -from lbry.utils import resolve_host, async_timed_cache +from lbry.utils import resolve_host, async_timed_cache, cancel_tasks from lbry.wallet.stream import StreamController log = logging.getLogger(__name__) @@ -145,6 +145,8 @@ class TrackerClient: self.client = None self.transport = None self.EVENT_CONTROLLER.close() + cancel_tasks([task for _, task in self.tasks]) + self.tasks.clear() def hash_done(self, info_hash): self.announced += 1 From 05124d41aeeeae2c0f4200d0a9d4b317038426f2 Mon Sep 17 00:00:00 2001 From: Victor Shyba Date: Tue, 8 Mar 2022 18:03:29 -0300 Subject: [PATCH 23/50] only log when really announcing, stop counting cached ones --- lbry/torrent/tracker.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/lbry/torrent/tracker.py b/lbry/torrent/tracker.py index 57824a4c6..d3a83650e 100644 --- a/lbry/torrent/tracker.py +++ b/lbry/torrent/tracker.py @@ -149,9 +149,8 @@ class TrackerClient: self.tasks.clear() def hash_done(self, info_hash): - self.announced += 1 self.tasks.pop(info_hash, None) - if len(self.tasks) == 0: + if len(self.tasks) == 0 and self.announced > 0: log.info("Tracker finished announcing %d files.", self.announced) self.announced = 0 @@ -178,6 +177,7 @@ class TrackerClient: return result try: tracker_ip = await resolve_host(tracker_host, tracker_port, 'udp') + self.announced += 1 result = await self.client.announce( info_hash, self.node_id, self.announce_port, tracker_ip, tracker_port, stopped) except asyncio.TimeoutError: From 2f1617eee4cadb52ca8b271da9d5ee99bdf4e050 Mon Sep 17 00:00:00 2001 From: Victor Shyba Date: Wed, 9 Mar 2022 12:57:35 -0300 Subject: [PATCH 24/50] less verbose on timeouts, dont count timeouts, fix stop --- lbry/torrent/tracker.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/lbry/torrent/tracker.py b/lbry/torrent/tracker.py index d3a83650e..989cb9605 100644 --- a/lbry/torrent/tracker.py +++ b/lbry/torrent/tracker.py @@ -145,7 +145,7 @@ class TrackerClient: self.client = None self.transport = None self.EVENT_CONTROLLER.close() - cancel_tasks([task for _, task in self.tasks]) + cancel_tasks([task for _, task in self.tasks.values()]) self.tasks.clear() def hash_done(self, info_hash): @@ -177,11 +177,11 @@ class TrackerClient: return result try: tracker_ip = await resolve_host(tracker_host, tracker_port, 'udp') - self.announced += 1 result = await self.client.announce( info_hash, self.node_id, self.announce_port, tracker_ip, tracker_port, stopped) - except asyncio.TimeoutError: - log.info("Tracker timed out: %s:%d", tracker_host, tracker_port) + self.announced += 1 + except asyncio.TimeoutError: # todo: this is UDP, timeout is common, we need a better metric for failures + log.debug("Tracker timed out: %s:%d", tracker_host, tracker_port) return None finally: self.results[info_hash] = (time.time() + (result.interval if result else 60.0), result) From a3da041412b0131c2c0647249ecf748c36cec82c Mon Sep 17 00:00:00 2001 From: Victor Shyba Date: Wed, 9 Mar 2022 14:24:16 -0300 Subject: [PATCH 25/50] fix exceptions on shutdown, stop using cancel_tasks --- lbry/torrent/tracker.py | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/lbry/torrent/tracker.py b/lbry/torrent/tracker.py index 989cb9605..aaf7564da 100644 --- a/lbry/torrent/tracker.py +++ b/lbry/torrent/tracker.py @@ -5,7 +5,7 @@ import logging import time from collections import namedtuple -from lbry.utils import resolve_host, async_timed_cache, cancel_tasks +from lbry.utils import resolve_host, async_timed_cache from lbry.wallet.stream import StreamController log = logging.getLogger(__name__) @@ -145,8 +145,8 @@ class TrackerClient: self.client = None self.transport = None self.EVENT_CONTROLLER.close() - cancel_tasks([task for _, task in self.tasks.values()]) - self.tasks.clear() + while self.tasks: + self.tasks.popitem()[1].cancel() def hash_done(self, info_hash): self.tasks.pop(info_hash, None) @@ -156,9 +156,9 @@ class TrackerClient: def on_hash(self, info_hash): if info_hash not in self.tasks: - fut = asyncio.ensure_future(self.get_peer_list(info_hash)) - fut.add_done_callback(lambda *_: self.hash_done(info_hash)) - self.tasks[info_hash] = fut + task = asyncio.create_task(self.get_peer_list(info_hash)) + task.add_done_callback(lambda *_: self.hash_done(info_hash)) + self.tasks[info_hash] = task async def get_peer_list(self, info_hash, stopped=False): found = [] From eccf0e6234e567ce339194f7eb5ea75fa375bb16 Mon Sep 17 00:00:00 2001 From: Victor Shyba Date: Wed, 9 Mar 2022 14:55:23 -0300 Subject: [PATCH 26/50] fix reusing result interval from failed expired attempt --- lbry/torrent/tracker.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/lbry/torrent/tracker.py b/lbry/torrent/tracker.py index aaf7564da..d2208fc79 100644 --- a/lbry/torrent/tracker.py +++ b/lbry/torrent/tracker.py @@ -179,12 +179,12 @@ class TrackerClient: tracker_ip = await resolve_host(tracker_host, tracker_port, 'udp') result = await self.client.announce( info_hash, self.node_id, self.announce_port, tracker_ip, tracker_port, stopped) + self.results[info_hash] = (time.time() + result.interval, result) self.announced += 1 except asyncio.TimeoutError: # todo: this is UDP, timeout is common, we need a better metric for failures + self.results[info_hash] = (time.time() + 60.0, result) log.debug("Tracker timed out: %s:%d", tracker_host, tracker_port) return None - finally: - self.results[info_hash] = (time.time() + (result.interval if result else 60.0), result) log.debug("Announced: %s found %d peers for %s on %s", tracker_host, len(result.peers), info_hash.hex()[:8]) return result From 0e4f1eae5b94e2b6a99d485c3fe7ed10d4e976bf Mon Sep 17 00:00:00 2001 From: Victor Shyba Date: Wed, 9 Mar 2022 16:42:20 -0300 Subject: [PATCH 27/50] reduce timeout to 10, fix lints --- lbry/torrent/tracker.py | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/lbry/torrent/tracker.py b/lbry/torrent/tracker.py index d2208fc79..0095a4fc6 100644 --- a/lbry/torrent/tracker.py +++ b/lbry/torrent/tracker.py @@ -60,7 +60,7 @@ def encode(obj): class UDPTrackerClientProtocol(asyncio.DatagramProtocol): - def __init__(self, timeout = 30.0): + def __init__(self, timeout=10.0): self.transport = None self.data_queue = {} self.timeout = timeout @@ -137,7 +137,8 @@ class TrackerClient: async def start(self): self.transport, _ = await asyncio.get_running_loop().create_datagram_endpoint( lambda: self.client, local_addr=("0.0.0.0", 0)) - self.EVENT_CONTROLLER.stream.listen(lambda request: self.on_hash(request[1]) if request[0] == 'search' else None) + self.EVENT_CONTROLLER.stream.listen( + lambda request: self.on_hash(request[1]) if request[0] == 'search' else None) def stop(self): if self.transport is not None: @@ -185,11 +186,11 @@ class TrackerClient: self.results[info_hash] = (time.time() + 60.0, result) log.debug("Tracker timed out: %s:%d", tracker_host, tracker_port) return None - log.debug("Announced: %s found %d peers for %s on %s", tracker_host, len(result.peers), info_hash.hex()[:8]) + log.debug("Announced: %s found %d peers for %s", tracker_host, len(result.peers), info_hash.hex()[:8]) return result -def subscribe_hash(hash: bytes, on_data): - TrackerClient.EVENT_CONTROLLER.add(('search', hash)) - TrackerClient.EVENT_CONTROLLER.stream.where(lambda request: request[0] == hash).add_done_callback( +def subscribe_hash(info_hash: bytes, on_data): + TrackerClient.EVENT_CONTROLLER.add(('search', info_hash)) + TrackerClient.EVENT_CONTROLLER.stream.where(lambda request: request[0] == info_hash).add_done_callback( lambda request: on_data(request.result()[1])) From cc4a578578f7f88ea4be35c34035f036af29c6d6 Mon Sep 17 00:00:00 2001 From: Victor Shyba Date: Wed, 9 Mar 2022 16:53:45 -0300 Subject: [PATCH 28/50] tests: add support for multiple trackers --- tests/unit/torrent/test_tracker.py | 18 +++++++++++++----- 1 file changed, 13 insertions(+), 5 deletions(-) diff --git a/tests/unit/torrent/test_tracker.py b/tests/unit/torrent/test_tracker.py index 67448853e..a7c484d92 100644 --- a/tests/unit/torrent/test_tracker.py +++ b/tests/unit/torrent/test_tracker.py @@ -44,12 +44,20 @@ class UDPTrackerServerProtocol(asyncio.DatagramProtocol): # for testing. Not su class UDPTrackerClientTestCase(AsyncioTestCase): async def asyncSetUp(self): - self.server = UDPTrackerServerProtocol() - transport, _ = await self.loop.create_datagram_endpoint(lambda: self.server, local_addr=("127.0.0.1", 59900)) - self.addCleanup(transport.close) - self.client = TrackerClient(b"\x00" * 48, 4444, [("127.0.0.1", 59900)]) + self.servers = {} + self.client = TrackerClient(b"\x00" * 48, 4444, []) await self.client.start() self.addCleanup(self.client.stop) + await self.add_server() + + async def add_server(self, port=None, add_to_client=True): + port = port or len(self.servers) + 59990 + server = UDPTrackerServerProtocol() + transport, _ = await self.loop.create_datagram_endpoint(lambda: server, local_addr=("127.0.0.1", port)) + self.addCleanup(transport.close) + self.servers[port] = server + if add_to_client: + self.client.servers.append(("127.0.0.1", port)) async def test_announce(self): info_hash = random.getrandbits(160).to_bytes(20, "big", signed=False) @@ -69,7 +77,7 @@ class UDPTrackerClientTestCase(AsyncioTestCase): async def test_error(self): info_hash = random.getrandbits(160).to_bytes(20, "big", signed=False) await self.client.get_peer_list(info_hash) - self.server.known_conns.clear() + list(self.servers.values())[0].known_conns.clear() self.client.results.clear() with self.assertRaises(Exception) as err: await self.client.get_peer_list(info_hash) From e299a9c159daf4c91e6fc4a873c1c9326745d889 Mon Sep 17 00:00:00 2001 From: Victor Shyba Date: Wed, 9 Mar 2022 17:01:49 -0300 Subject: [PATCH 29/50] tests: multiple trackers, simple case --- tests/unit/torrent/test_tracker.py | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/tests/unit/torrent/test_tracker.py b/tests/unit/torrent/test_tracker.py index a7c484d92..1c323fc15 100644 --- a/tests/unit/torrent/test_tracker.py +++ b/tests/unit/torrent/test_tracker.py @@ -52,10 +52,11 @@ class UDPTrackerClientTestCase(AsyncioTestCase): async def add_server(self, port=None, add_to_client=True): port = port or len(self.servers) + 59990 + assert port not in self.servers server = UDPTrackerServerProtocol() + self.servers[port] = server transport, _ = await self.loop.create_datagram_endpoint(lambda: server, local_addr=("127.0.0.1", port)) self.addCleanup(transport.close) - self.servers[port] = server if add_to_client: self.client.servers.append(("127.0.0.1", port)) @@ -82,3 +83,11 @@ class UDPTrackerClientTestCase(AsyncioTestCase): with self.assertRaises(Exception) as err: await self.client.get_peer_list(info_hash) self.assertEqual(err.exception.args[0], b'Connection ID missmatch.\x00') + + async def test_multiple(self): + await asyncio.gather(*[self.add_server() for _ in range(10)]) + info_hash = random.getrandbits(160).to_bytes(20, "big", signed=False) + await self.client.get_peer_list(info_hash) + for server in self.servers.values(): + self.assertEqual(1, len(server.peers)) + self.assertEqual(1, len(server.peers[info_hash])) From 407c570f8b24a5a7890c425e6655c54d6d559d65 Mon Sep 17 00:00:00 2001 From: Victor Shyba Date: Wed, 9 Mar 2022 17:07:16 -0300 Subject: [PATCH 30/50] tests: lower timeout, add test with bad and good mixed --- lbry/torrent/tracker.py | 4 ++-- tests/unit/torrent/test_tracker.py | 11 ++++++++++- 2 files changed, 12 insertions(+), 3 deletions(-) diff --git a/lbry/torrent/tracker.py b/lbry/torrent/tracker.py index 0095a4fc6..f38cf17fc 100644 --- a/lbry/torrent/tracker.py +++ b/lbry/torrent/tracker.py @@ -124,8 +124,8 @@ class UDPTrackerClientProtocol(asyncio.DatagramProtocol): class TrackerClient: EVENT_CONTROLLER = StreamController() - def __init__(self, node_id, announce_port, servers): - self.client = UDPTrackerClientProtocol() + def __init__(self, node_id, announce_port, servers, timeout=10.0): + self.client = UDPTrackerClientProtocol(timeout=timeout) self.transport = None self.node_id = node_id or random.getrandbits(160).to_bytes(20, "big", signed=False) self.announce_port = announce_port diff --git a/tests/unit/torrent/test_tracker.py b/tests/unit/torrent/test_tracker.py index 1c323fc15..a6afed06a 100644 --- a/tests/unit/torrent/test_tracker.py +++ b/tests/unit/torrent/test_tracker.py @@ -45,7 +45,7 @@ class UDPTrackerServerProtocol(asyncio.DatagramProtocol): # for testing. Not su class UDPTrackerClientTestCase(AsyncioTestCase): async def asyncSetUp(self): self.servers = {} - self.client = TrackerClient(b"\x00" * 48, 4444, []) + self.client = TrackerClient(b"\x00" * 48, 4444, [], timeout=0.1) await self.client.start() self.addCleanup(self.client.stop) await self.add_server() @@ -91,3 +91,12 @@ class UDPTrackerClientTestCase(AsyncioTestCase): for server in self.servers.values(): self.assertEqual(1, len(server.peers)) self.assertEqual(1, len(server.peers[info_hash])) + + async def test_multiple_with_bad_one(self): + await asyncio.gather(*[self.add_server() for _ in range(10)]) + self.client.servers.append(("127.0.0.2", 7070)) + info_hash = random.getrandbits(160).to_bytes(20, "big", signed=False) + await self.client.get_peer_list(info_hash) + for server in self.servers.values(): + self.assertEqual(1, len(server.peers)) + self.assertEqual(1, len(server.peers[info_hash])) From 2918d8c7b4c57869a7faddad764572f2b441de55 Mon Sep 17 00:00:00 2001 From: Victor Shyba Date: Wed, 9 Mar 2022 17:46:47 -0300 Subject: [PATCH 31/50] tracker component is running only if the task is alive --- lbry/extras/daemon/components.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/lbry/extras/daemon/components.py b/lbry/extras/daemon/components.py index 5bd2460cf..754c4fe0f 100644 --- a/lbry/extras/daemon/components.py +++ b/lbry/extras/daemon/components.py @@ -727,6 +727,10 @@ class TrackerAnnouncerComponent(Component): def component(self): return self + @property + def running(self): + return self._running and self.announce_task and not self.announce_task.done() + async def announce_forever(self): while True: to_sleep = 60.0 From d4aca89a48e359f7b9b9eda8bd04b18c3673e01a Mon Sep 17 00:00:00 2001 From: Victor Shyba Date: Wed, 9 Mar 2022 17:47:23 -0300 Subject: [PATCH 32/50] handle multiple results from multiple trackers --- lbry/torrent/tracker.py | 14 +++++----- tests/unit/torrent/test_tracker.py | 41 ++++++++++++++++++++++-------- 2 files changed, 37 insertions(+), 18 deletions(-) diff --git a/lbry/torrent/tracker.py b/lbry/torrent/tracker.py index f38cf17fc..e23a5ba84 100644 --- a/lbry/torrent/tracker.py +++ b/lbry/torrent/tracker.py @@ -138,7 +138,7 @@ class TrackerClient: self.transport, _ = await asyncio.get_running_loop().create_datagram_endpoint( lambda: self.client, local_addr=("0.0.0.0", 0)) self.EVENT_CONTROLLER.stream.listen( - lambda request: self.on_hash(request[1]) if request[0] == 'search' else None) + lambda request: self.on_hash(request[1], request[2]) if request[0] == 'search' else None) def stop(self): if self.transport is not None: @@ -155,18 +155,18 @@ class TrackerClient: log.info("Tracker finished announcing %d files.", self.announced) self.announced = 0 - def on_hash(self, info_hash): + def on_hash(self, info_hash, on_announcement=None): if info_hash not in self.tasks: - task = asyncio.create_task(self.get_peer_list(info_hash)) + task = asyncio.create_task(self.get_peer_list(info_hash, on_announcement=on_announcement)) task.add_done_callback(lambda *_: self.hash_done(info_hash)) self.tasks[info_hash] = task - async def get_peer_list(self, info_hash, stopped=False): + async def get_peer_list(self, info_hash, stopped=False, on_announcement=None): found = [] for done in asyncio.as_completed([self._probe_server(info_hash, *server, stopped) for server in self.servers]): result = await done if result is not None: - self.EVENT_CONTROLLER.add((info_hash, result)) + await asyncio.gather(*filter(asyncio.iscoroutine, [on_announcement(result)] if on_announcement else [])) found.append(result) return found @@ -191,6 +191,4 @@ class TrackerClient: def subscribe_hash(info_hash: bytes, on_data): - TrackerClient.EVENT_CONTROLLER.add(('search', info_hash)) - TrackerClient.EVENT_CONTROLLER.stream.where(lambda request: request[0] == info_hash).add_done_callback( - lambda request: on_data(request.result()[1])) + TrackerClient.EVENT_CONTROLLER.add(('search', info_hash, on_data)) diff --git a/tests/unit/torrent/test_tracker.py b/tests/unit/torrent/test_tracker.py index a6afed06a..e4b3c2f1c 100644 --- a/tests/unit/torrent/test_tracker.py +++ b/tests/unit/torrent/test_tracker.py @@ -16,6 +16,10 @@ class UDPTrackerServerProtocol(asyncio.DatagramProtocol): # for testing. Not su def connection_made(self, transport: asyncio.DatagramTransport) -> None: self.transport = transport + def add_peer(self, info_hash, ip_address: str, port: int): + self.peers.setdefault(info_hash, []) + self.peers[info_hash].append(encode_peer(ip_address, port)) + def datagram_received(self, data: bytes, address: (str, int)) -> None: if len(data) < 16: return @@ -30,18 +34,21 @@ class UDPTrackerServerProtocol(asyncio.DatagramProtocol): # for testing. Not su if req.connection_id not in self.known_conns: resp = encode(ErrorResponse(3, req.transaction_id, b'Connection ID missmatch.\x00')) else: - self.peers.setdefault(req.info_hash, []) - compact_ip = reduce(lambda buff, x: buff + bytearray([int(x)]), address[0].split('.'), bytearray()) - compact_address = compact_ip + req.port.to_bytes(2, "big", signed=False) + compact_address = encode_peer(address[0], req.port) if req.event != 3: - self.peers[req.info_hash].append(compact_address) - elif compact_address in self.peers[req.info_hash]: + self.add_peer(req.info_hash, address[0], req.port) + elif compact_address in self.peers.get(req.info_hash, []): self.peers[req.info_hash].remove(compact_address) peers = [decode(CompactIPv4Peer, peer) for peer in self.peers[req.info_hash]] resp = encode(AnnounceResponse(1, req.transaction_id, 1700, 0, len(peers), peers)) return self.transport.sendto(resp, address) +def encode_peer(ip_address: str, port: int): + compact_ip = reduce(lambda buff, x: buff + bytearray([int(x)]), ip_address.split('.'), bytearray()) + return compact_ip + port.to_bytes(2, "big", signed=False) + + class UDPTrackerClientTestCase(AsyncioTestCase): async def asyncSetUp(self): self.servers = {} @@ -70,7 +77,7 @@ class UDPTrackerClientTestCase(AsyncioTestCase): async def test_announce_using_helper_function(self): info_hash = random.getrandbits(160).to_bytes(20, "big", signed=False) queue = asyncio.Queue() - subscribe_hash(info_hash, queue.put_nowait) + subscribe_hash(info_hash, queue.put) announcement = await queue.get() peers = announcement.peers self.assertEqual(peers, [CompactIPv4Peer(int.from_bytes(bytes([127, 0, 0, 1]), "big", signed=False), 4444)]) @@ -89,8 +96,7 @@ class UDPTrackerClientTestCase(AsyncioTestCase): info_hash = random.getrandbits(160).to_bytes(20, "big", signed=False) await self.client.get_peer_list(info_hash) for server in self.servers.values(): - self.assertEqual(1, len(server.peers)) - self.assertEqual(1, len(server.peers[info_hash])) + self.assertEqual(server.peers, {info_hash: [encode_peer("127.0.0.1", self.client.announce_port)]}) async def test_multiple_with_bad_one(self): await asyncio.gather(*[self.add_server() for _ in range(10)]) @@ -98,5 +104,20 @@ class UDPTrackerClientTestCase(AsyncioTestCase): info_hash = random.getrandbits(160).to_bytes(20, "big", signed=False) await self.client.get_peer_list(info_hash) for server in self.servers.values(): - self.assertEqual(1, len(server.peers)) - self.assertEqual(1, len(server.peers[info_hash])) + self.assertEqual(server.peers, {info_hash: [encode_peer("127.0.0.1", self.client.announce_port)]}) + + async def test_multiple_with_different_peers_across_helper_function(self): + # this is how the downloader uses it + await asyncio.gather(*[self.add_server() for _ in range(10)]) + info_hash = random.getrandbits(160).to_bytes(20, "big", signed=False) + fake_peers = [] + for server in self.servers.values(): + for _ in range(10): + peer = (f"127.0.0.{random.randint(1, 255)}", random.randint(2000, 65500)) + fake_peers.append(peer) + server.add_peer(info_hash, *peer) + response = [] + subscribe_hash(info_hash, response.append) + await asyncio.sleep(0) + await asyncio.gather(*self.client.tasks.values()) + self.assertEqual(11, len(response)) From 99fc7178c13fbf447537a7d9baa884f2d523daff Mon Sep 17 00:00:00 2001 From: Victor Shyba Date: Wed, 9 Mar 2022 19:59:30 -0300 Subject: [PATCH 33/50] better way to batch announce + handle different intervals for different trackers --- lbry/extras/daemon/components.py | 4 ++- lbry/torrent/tracker.py | 42 +++++++++++++++++++----------- tests/unit/torrent/test_tracker.py | 18 ++++++++++--- 3 files changed, 45 insertions(+), 19 deletions(-) diff --git a/lbry/extras/daemon/components.py b/lbry/extras/daemon/components.py index 754c4fe0f..329f4ba12 100644 --- a/lbry/extras/daemon/components.py +++ b/lbry/extras/daemon/components.py @@ -734,10 +734,12 @@ class TrackerAnnouncerComponent(Component): async def announce_forever(self): while True: to_sleep = 60.0 + to_announce = [] for file in self.file_manager.get_filtered(): if not file.downloader: continue - self.tracker_client.on_hash(bytes.fromhex(file.sd_hash)) + to_announce.append(bytes.fromhex(file.sd_hash)) + await self.tracker_client.announce_many(*to_announce) await asyncio.sleep(to_sleep) async def start(self): diff --git a/lbry/torrent/tracker.py b/lbry/torrent/tracker.py index e23a5ba84..07c4ab785 100644 --- a/lbry/torrent/tracker.py +++ b/lbry/torrent/tracker.py @@ -132,7 +132,6 @@ class TrackerClient: self.servers = servers self.results = {} # we can't probe the server before the interval, so we keep the result here until it expires self.tasks = {} - self.announced = 0 async def start(self): self.transport, _ = await asyncio.get_running_loop().create_datagram_endpoint( @@ -149,18 +148,31 @@ class TrackerClient: while self.tasks: self.tasks.popitem()[1].cancel() - def hash_done(self, info_hash): - self.tasks.pop(info_hash, None) - if len(self.tasks) == 0 and self.announced > 0: - log.info("Tracker finished announcing %d files.", self.announced) - self.announced = 0 - def on_hash(self, info_hash, on_announcement=None): if info_hash not in self.tasks: task = asyncio.create_task(self.get_peer_list(info_hash, on_announcement=on_announcement)) - task.add_done_callback(lambda *_: self.hash_done(info_hash)) + task.add_done_callback(lambda *_: self.tasks.pop(info_hash, None)) self.tasks[info_hash] = task + async def announce_many(self, *info_hashes, stopped=False): + await asyncio.gather( + *[self._announce_many(server, info_hashes, stopped=stopped) for server in self.servers], + return_exceptions=True) + + async def _announce_many(self, server, info_hashes, stopped=False): + tracker_ip = await resolve_host(*server, 'udp') + still_good_info_hashes = { + info_hash for (info_hash, (next_announcement, _)) in self.results.get(tracker_ip, {}).items() + if time.time() < next_announcement + } + results = await asyncio.gather( + *[self._probe_server(info_hash, tracker_ip, server[1], stopped=stopped) + for info_hash in info_hashes if info_hash not in still_good_info_hashes], + return_exceptions=True) + if results: + errors = sum([1 for result in results if result is None or isinstance(result, Exception)]) + log.info("Tracker: finished announcing %d files to %s:%d, %d errors", len(results), *server, errors) + async def get_peer_list(self, info_hash, stopped=False, on_announcement=None): found = [] for done in asyncio.as_completed([self._probe_server(info_hash, *server, stopped) for server in self.servers]): @@ -172,18 +184,18 @@ class TrackerClient: async def _probe_server(self, info_hash, tracker_host, tracker_port, stopped=False): result = None - if info_hash in self.results: - next_announcement, result = self.results[info_hash] + self.results.setdefault(tracker_host, {}) + tracker_host = await resolve_host(tracker_host, tracker_port, 'udp') + if info_hash in self.results[tracker_host]: + next_announcement, result = self.results[tracker_host][info_hash] if time.time() < next_announcement: return result try: - tracker_ip = await resolve_host(tracker_host, tracker_port, 'udp') result = await self.client.announce( - info_hash, self.node_id, self.announce_port, tracker_ip, tracker_port, stopped) - self.results[info_hash] = (time.time() + result.interval, result) - self.announced += 1 + info_hash, self.node_id, self.announce_port, tracker_host, tracker_port, stopped) + self.results[tracker_host][info_hash] = (time.time() + result.interval, result) except asyncio.TimeoutError: # todo: this is UDP, timeout is common, we need a better metric for failures - self.results[info_hash] = (time.time() + 60.0, result) + self.results[tracker_host][info_hash] = (time.time() + 60.0, result) log.debug("Tracker timed out: %s:%d", tracker_host, tracker_port) return None log.debug("Announced: %s found %d peers for %s", tracker_host, len(result.peers), info_hash.hex()[:8]) diff --git a/tests/unit/torrent/test_tracker.py b/tests/unit/torrent/test_tracker.py index e4b3c2f1c..8712a479e 100644 --- a/tests/unit/torrent/test_tracker.py +++ b/tests/unit/torrent/test_tracker.py @@ -74,6 +74,18 @@ class UDPTrackerClientTestCase(AsyncioTestCase): self.assertEqual(announcement.peers, [CompactIPv4Peer(int.from_bytes(bytes([127, 0, 0, 1]), "big", signed=False), 4444)]) + async def test_announce_many_info_hashes_to_many_servers_with_bad_one_and_dns_error(self): + await asyncio.gather(*[self.add_server() for _ in range(3)]) + self.client.servers.append(("no.it.does.not.exist", 7070)) + self.client.servers.append(("127.0.0.2", 7070)) + info_hashes = [random.getrandbits(160).to_bytes(20, "big", signed=False) for _ in range(5)] + await self.client.announce_many(*info_hashes) + for server in self.servers.values(): + self.assertDictEqual( + server.peers, { + info_hash: [encode_peer("127.0.0.1", self.client.announce_port)] for info_hash in info_hashes + }) + async def test_announce_using_helper_function(self): info_hash = random.getrandbits(160).to_bytes(20, "big", signed=False) queue = asyncio.Queue() @@ -91,14 +103,14 @@ class UDPTrackerClientTestCase(AsyncioTestCase): await self.client.get_peer_list(info_hash) self.assertEqual(err.exception.args[0], b'Connection ID missmatch.\x00') - async def test_multiple(self): + async def test_multiple_servers(self): await asyncio.gather(*[self.add_server() for _ in range(10)]) info_hash = random.getrandbits(160).to_bytes(20, "big", signed=False) await self.client.get_peer_list(info_hash) for server in self.servers.values(): self.assertEqual(server.peers, {info_hash: [encode_peer("127.0.0.1", self.client.announce_port)]}) - async def test_multiple_with_bad_one(self): + async def test_multiple_servers_with_bad_one(self): await asyncio.gather(*[self.add_server() for _ in range(10)]) self.client.servers.append(("127.0.0.2", 7070)) info_hash = random.getrandbits(160).to_bytes(20, "big", signed=False) @@ -106,7 +118,7 @@ class UDPTrackerClientTestCase(AsyncioTestCase): for server in self.servers.values(): self.assertEqual(server.peers, {info_hash: [encode_peer("127.0.0.1", self.client.announce_port)]}) - async def test_multiple_with_different_peers_across_helper_function(self): + async def test_multiple_servers_with_different_peers_across_helper_function(self): # this is how the downloader uses it await asyncio.gather(*[self.add_server() for _ in range(10)]) info_hash = random.getrandbits(160).to_bytes(20, "big", signed=False) From 16a2023bbd2d490f4aaa473bc9b24f01ab00f3db Mon Sep 17 00:00:00 2001 From: Victor Shyba Date: Wed, 9 Mar 2022 20:01:17 -0300 Subject: [PATCH 34/50] stop tasks before removing transport --- lbry/torrent/tracker.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/lbry/torrent/tracker.py b/lbry/torrent/tracker.py index 07c4ab785..5e78ed045 100644 --- a/lbry/torrent/tracker.py +++ b/lbry/torrent/tracker.py @@ -140,13 +140,13 @@ class TrackerClient: lambda request: self.on_hash(request[1], request[2]) if request[0] == 'search' else None) def stop(self): + while self.tasks: + self.tasks.popitem()[1].cancel() if self.transport is not None: self.transport.close() self.client = None self.transport = None self.EVENT_CONTROLLER.close() - while self.tasks: - self.tasks.popitem()[1].cancel() def on_hash(self, info_hash, on_announcement=None): if info_hash not in self.tasks: From 4e09b35012269690c6206a8fa5f0025267b1147d Mon Sep 17 00:00:00 2001 From: Victor Shyba Date: Wed, 9 Mar 2022 20:03:27 -0300 Subject: [PATCH 35/50] remove unused import and dead code --- lbry/stream/downloader.py | 1 - lbry/utils.py | 15 --------------- 2 files changed, 16 deletions(-) diff --git a/lbry/stream/downloader.py b/lbry/stream/downloader.py index b4cd9bab3..c7d588267 100644 --- a/lbry/stream/downloader.py +++ b/lbry/stream/downloader.py @@ -1,6 +1,5 @@ import asyncio import ipaddress -import time import typing import logging import binascii diff --git a/lbry/utils.py b/lbry/utils.py index dc3a6c06e..7a92ccc6a 100644 --- a/lbry/utils.py +++ b/lbry/utils.py @@ -131,21 +131,6 @@ def json_dumps_pretty(obj, **kwargs): return json.dumps(obj, sort_keys=True, indent=2, separators=(',', ': '), **kwargs) -def cancel_task(task: typing.Optional[asyncio.Task]): - if task and not task.done(): - task.cancel() - - -def cancel_tasks(tasks: typing.List[typing.Optional[asyncio.Task]]): - for task in tasks: - cancel_task(task) - - -def drain_tasks(tasks: typing.List[typing.Optional[asyncio.Task]]): - while tasks: - cancel_task(tasks.pop()) - - def async_timed_cache(duration: int): def wrapper(func): cache: typing.Dict[typing.Tuple, From 6e5c7a1927f7284cc4a6fe95f0620003269d8252 Mon Sep 17 00:00:00 2001 From: Victor Shyba Date: Wed, 9 Mar 2022 20:11:30 -0300 Subject: [PATCH 36/50] use cache_concurrent to avoid requesting the same connection_id multiple times --- lbry/torrent/tracker.py | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/lbry/torrent/tracker.py b/lbry/torrent/tracker.py index 5e78ed045..9a6e98b6f 100644 --- a/lbry/torrent/tracker.py +++ b/lbry/torrent/tracker.py @@ -5,7 +5,7 @@ import logging import time from collections import namedtuple -from lbry.utils import resolve_host, async_timed_cache +from lbry.utils import resolve_host, async_timed_cache, cache_concurrent from lbry.wallet.stream import StreamController log = logging.getLogger(__name__) @@ -83,6 +83,7 @@ class UDPTrackerClientProtocol(asyncio.DatagramProtocol): return decode(ConnectResponse, await self.request(ConnectRequest(0x41727101980, 0, transaction_id), tracker_ip, tracker_port)) + @cache_concurrent @async_timed_cache(CONNECTION_EXPIRES_AFTER_SECONDS) async def ensure_connection_id(self, peer_id, tracker_ip, tracker_port): # peer_id is just to ensure cache coherency @@ -98,9 +99,7 @@ class UDPTrackerClientProtocol(asyncio.DatagramProtocol): return decode(AnnounceResponse, await self.request(req, tracker_ip, tracker_port)) async def scrape(self, infohashes, tracker_ip, tracker_port, connection_id=None): - if not connection_id: - reply = await self.connect(tracker_ip, tracker_port) - connection_id = reply.connection_id + connection_id = await self.ensure_connection_id(None, tracker_ip, tracker_port) transaction_id = random.getrandbits(32) reply = await self.request( ScrapeRequest(connection_id, 2, transaction_id, infohashes), tracker_ip, tracker_port) From 3c46cc4fdd91f9f07a298318952dfbe557977d86 Mon Sep 17 00:00:00 2001 From: Victor Shyba Date: Wed, 9 Mar 2022 20:16:18 -0300 Subject: [PATCH 37/50] expire connection id quicker as some trackers have it set low --- lbry/torrent/tracker.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lbry/torrent/tracker.py b/lbry/torrent/tracker.py index 9a6e98b6f..3b1e7d0a1 100644 --- a/lbry/torrent/tracker.py +++ b/lbry/torrent/tracker.py @@ -9,7 +9,7 @@ from lbry.utils import resolve_host, async_timed_cache, cache_concurrent from lbry.wallet.stream import StreamController log = logging.getLogger(__name__) -CONNECTION_EXPIRES_AFTER_SECONDS = 360 +CONNECTION_EXPIRES_AFTER_SECONDS = 50 # see: http://bittorrent.org/beps/bep_0015.html and http://xbtt.sourceforge.net/udp_tracker_protocol.html ConnectRequest = namedtuple("ConnectRequest", ["connection_id", "action", "transaction_id"]) ConnectResponse = namedtuple("ConnectResponse", ["action", "transaction_id", "connection_id"]) From 7e6ea97499323777795a78ef6f17e48aa8160e52 Mon Sep 17 00:00:00 2001 From: Victor Shyba Date: Thu, 10 Mar 2022 21:10:44 -0300 Subject: [PATCH 38/50] make peer id according to BEP20 --- lbry/torrent/tracker.py | 17 ++++++++++++++--- 1 file changed, 14 insertions(+), 3 deletions(-) diff --git a/lbry/torrent/tracker.py b/lbry/torrent/tracker.py index 3b1e7d0a1..e29e901a3 100644 --- a/lbry/torrent/tracker.py +++ b/lbry/torrent/tracker.py @@ -1,12 +1,15 @@ import random +import string import struct import asyncio import logging import time from collections import namedtuple +from typing import Optional from lbry.utils import resolve_host, async_timed_cache, cache_concurrent from lbry.wallet.stream import StreamController +from lbry import version log = logging.getLogger(__name__) CONNECTION_EXPIRES_AFTER_SECONDS = 50 @@ -59,8 +62,16 @@ def encode(obj): return STRUCTS[type(obj)].pack(*obj) +def make_peer_id(random_part: Optional[str] = None) -> bytes: + # see https://wiki.theory.org/BitTorrentSpecification#peer_id and https://www.bittorrent.org/beps/bep_0020.html + # not to confuse with node id; peer id identifies uniquely the software, version and instance + PREFIX = 'LB' # todo: PR BEP20 to add ourselves + random_part = random_part or ''.join(random.choice(string.ascii_letters) for _ in range(20)) + return f"{PREFIX}-{'-'.join(map(str, version))}-{random_part}"[:20].encode() + + class UDPTrackerClientProtocol(asyncio.DatagramProtocol): - def __init__(self, timeout=10.0): + def __init__(self, timeout: float = 10.0): self.transport = None self.data_queue = {} self.timeout = timeout @@ -126,7 +137,7 @@ class TrackerClient: def __init__(self, node_id, announce_port, servers, timeout=10.0): self.client = UDPTrackerClientProtocol(timeout=timeout) self.transport = None - self.node_id = node_id or random.getrandbits(160).to_bytes(20, "big", signed=False) + self.peer_id = make_peer_id(node_id.hex() if node_id else None) self.announce_port = announce_port self.servers = servers self.results = {} # we can't probe the server before the interval, so we keep the result here until it expires @@ -191,7 +202,7 @@ class TrackerClient: return result try: result = await self.client.announce( - info_hash, self.node_id, self.announce_port, tracker_host, tracker_port, stopped) + info_hash, self.peer_id, self.announce_port, tracker_host, tracker_port, stopped) self.results[tracker_host][info_hash] = (time.time() + result.interval, result) except asyncio.TimeoutError: # todo: this is UDP, timeout is common, we need a better metric for failures self.results[tracker_host][info_hash] = (time.time() + 60.0, result) From a7cea4082e1c148d161a3093f632a7e9187cdcc9 Mon Sep 17 00:00:00 2001 From: Victor Shyba Date: Thu, 10 Mar 2022 21:15:39 -0300 Subject: [PATCH 39/50] tracker:log DNS errors as warning instead of trace --- lbry/torrent/tracker.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/lbry/torrent/tracker.py b/lbry/torrent/tracker.py index e29e901a3..8ae778771 100644 --- a/lbry/torrent/tracker.py +++ b/lbry/torrent/tracker.py @@ -1,4 +1,5 @@ import random +import socket import string import struct import asyncio @@ -195,7 +196,11 @@ class TrackerClient: async def _probe_server(self, info_hash, tracker_host, tracker_port, stopped=False): result = None self.results.setdefault(tracker_host, {}) - tracker_host = await resolve_host(tracker_host, tracker_port, 'udp') + try: + tracker_host = await resolve_host(tracker_host, tracker_port, 'udp') + except socket.error: + log.warning("DNS failure while resolving tracker host: %s, skipping.", tracker_host) + return if info_hash in self.results[tracker_host]: next_announcement, result = self.results[tracker_host][info_hash] if time.time() < next_announcement: From 1169a02c8b62cc0536e5e7c1d36c3e7c7fbc665a Mon Sep 17 00:00:00 2001 From: Victor Shyba Date: Sat, 12 Mar 2022 02:15:45 -0300 Subject: [PATCH 40/50] make client server updatable from conf --- lbry/extras/daemon/components.py | 2 +- lbry/torrent/tracker.py | 9 +++++---- tests/unit/torrent/test_tracker.py | 11 ++++++----- 3 files changed, 12 insertions(+), 10 deletions(-) diff --git a/lbry/extras/daemon/components.py b/lbry/extras/daemon/components.py index 329f4ba12..b3be409e0 100644 --- a/lbry/extras/daemon/components.py +++ b/lbry/extras/daemon/components.py @@ -746,7 +746,7 @@ class TrackerAnnouncerComponent(Component): node = self.component_manager.get_component(DHT_COMPONENT) \ if self.component_manager.has_component(DHT_COMPONENT) else None node_id = node.protocol.node_id if node else None - self.tracker_client = TrackerClient(node_id, self.conf.tcp_port, self.conf.tracker_servers) + self.tracker_client = TrackerClient(node_id, self.conf.tcp_port, lambda: self.conf.tracker_servers) await self.tracker_client.start() self.file_manager = self.component_manager.get_component(FILE_MANAGER_COMPONENT) self.announce_task = asyncio.create_task(self.announce_forever()) diff --git a/lbry/torrent/tracker.py b/lbry/torrent/tracker.py index 8ae778771..27010c7b8 100644 --- a/lbry/torrent/tracker.py +++ b/lbry/torrent/tracker.py @@ -135,12 +135,12 @@ class UDPTrackerClientProtocol(asyncio.DatagramProtocol): class TrackerClient: EVENT_CONTROLLER = StreamController() - def __init__(self, node_id, announce_port, servers, timeout=10.0): + def __init__(self, node_id, announce_port, get_servers, timeout=10.0): self.client = UDPTrackerClientProtocol(timeout=timeout) self.transport = None self.peer_id = make_peer_id(node_id.hex() if node_id else None) self.announce_port = announce_port - self.servers = servers + self._get_servers = get_servers self.results = {} # we can't probe the server before the interval, so we keep the result here until it expires self.tasks = {} @@ -167,7 +167,7 @@ class TrackerClient: async def announce_many(self, *info_hashes, stopped=False): await asyncio.gather( - *[self._announce_many(server, info_hashes, stopped=stopped) for server in self.servers], + *[self._announce_many(server, info_hashes, stopped=stopped) for server in self._get_servers()], return_exceptions=True) async def _announce_many(self, server, info_hashes, stopped=False): @@ -186,7 +186,8 @@ class TrackerClient: async def get_peer_list(self, info_hash, stopped=False, on_announcement=None): found = [] - for done in asyncio.as_completed([self._probe_server(info_hash, *server, stopped) for server in self.servers]): + servers = self._get_servers() + for done in asyncio.as_completed([self._probe_server(info_hash, *server, stopped) for server in servers]): result = await done if result is not None: await asyncio.gather(*filter(asyncio.iscoroutine, [on_announcement(result)] if on_announcement else [])) diff --git a/tests/unit/torrent/test_tracker.py b/tests/unit/torrent/test_tracker.py index 8712a479e..4bb733361 100644 --- a/tests/unit/torrent/test_tracker.py +++ b/tests/unit/torrent/test_tracker.py @@ -51,8 +51,9 @@ def encode_peer(ip_address: str, port: int): class UDPTrackerClientTestCase(AsyncioTestCase): async def asyncSetUp(self): + self.client_servers_list = [] self.servers = {} - self.client = TrackerClient(b"\x00" * 48, 4444, [], timeout=0.1) + self.client = TrackerClient(b"\x00" * 48, 4444, lambda: self.client_servers_list, timeout=0.1) await self.client.start() self.addCleanup(self.client.stop) await self.add_server() @@ -65,7 +66,7 @@ class UDPTrackerClientTestCase(AsyncioTestCase): transport, _ = await self.loop.create_datagram_endpoint(lambda: server, local_addr=("127.0.0.1", port)) self.addCleanup(transport.close) if add_to_client: - self.client.servers.append(("127.0.0.1", port)) + self.client_servers_list.append(("127.0.0.1", port)) async def test_announce(self): info_hash = random.getrandbits(160).to_bytes(20, "big", signed=False) @@ -76,8 +77,8 @@ class UDPTrackerClientTestCase(AsyncioTestCase): async def test_announce_many_info_hashes_to_many_servers_with_bad_one_and_dns_error(self): await asyncio.gather(*[self.add_server() for _ in range(3)]) - self.client.servers.append(("no.it.does.not.exist", 7070)) - self.client.servers.append(("127.0.0.2", 7070)) + self.client_servers_list.append(("no.it.does.not.exist", 7070)) + self.client_servers_list.append(("127.0.0.2", 7070)) info_hashes = [random.getrandbits(160).to_bytes(20, "big", signed=False) for _ in range(5)] await self.client.announce_many(*info_hashes) for server in self.servers.values(): @@ -112,7 +113,7 @@ class UDPTrackerClientTestCase(AsyncioTestCase): async def test_multiple_servers_with_bad_one(self): await asyncio.gather(*[self.add_server() for _ in range(10)]) - self.client.servers.append(("127.0.0.2", 7070)) + self.client_servers_list.append(("127.0.0.2", 7070)) info_hash = random.getrandbits(160).to_bytes(20, "big", signed=False) await self.client.get_peer_list(info_hash) for server in self.servers.values(): From 2e85e29ef1a5b64df3e05a7358b176a6580c7554 Mon Sep 17 00:00:00 2001 From: Victor Shyba Date: Sat, 12 Mar 2022 02:16:18 -0300 Subject: [PATCH 41/50] peer id PREFIX is a constant --- lbry/torrent/tracker.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lbry/torrent/tracker.py b/lbry/torrent/tracker.py index 27010c7b8..037c5bbb2 100644 --- a/lbry/torrent/tracker.py +++ b/lbry/torrent/tracker.py @@ -14,6 +14,7 @@ from lbry import version log = logging.getLogger(__name__) CONNECTION_EXPIRES_AFTER_SECONDS = 50 +PREFIX = 'LB' # todo: PR BEP20 to add ourselves # see: http://bittorrent.org/beps/bep_0015.html and http://xbtt.sourceforge.net/udp_tracker_protocol.html ConnectRequest = namedtuple("ConnectRequest", ["connection_id", "action", "transaction_id"]) ConnectResponse = namedtuple("ConnectResponse", ["action", "transaction_id", "connection_id"]) @@ -66,7 +67,6 @@ def encode(obj): def make_peer_id(random_part: Optional[str] = None) -> bytes: # see https://wiki.theory.org/BitTorrentSpecification#peer_id and https://www.bittorrent.org/beps/bep_0020.html # not to confuse with node id; peer id identifies uniquely the software, version and instance - PREFIX = 'LB' # todo: PR BEP20 to add ourselves random_part = random_part or ''.join(random.choice(string.ascii_letters) for _ in range(20)) return f"{PREFIX}-{'-'.join(map(str, version))}-{random_part}"[:20].encode() From c276053301c5caa5566a0095e0a3e3540e8103b8 Mon Sep 17 00:00:00 2001 From: Victor Shyba Date: Sat, 12 Mar 2022 02:17:37 -0300 Subject: [PATCH 42/50] move server implementation to tracker module --- lbry/torrent/tracker.py | 43 ++++++++++++++++++++++++++++ tests/unit/torrent/test_tracker.py | 46 +----------------------------- 2 files changed, 44 insertions(+), 45 deletions(-) diff --git a/lbry/torrent/tracker.py b/lbry/torrent/tracker.py index 037c5bbb2..d29242790 100644 --- a/lbry/torrent/tracker.py +++ b/lbry/torrent/tracker.py @@ -6,6 +6,7 @@ import asyncio import logging import time from collections import namedtuple +from functools import reduce from typing import Optional from lbry.utils import resolve_host, async_timed_cache, cache_concurrent @@ -220,3 +221,45 @@ class TrackerClient: def subscribe_hash(info_hash: bytes, on_data): TrackerClient.EVENT_CONTROLLER.add(('search', info_hash, on_data)) + + +class UDPTrackerServerProtocol(asyncio.DatagramProtocol): # for testing. Not suitable for production + def __init__(self): + self.transport = None + self.known_conns = set() + self.peers = {} + + def connection_made(self, transport: asyncio.DatagramTransport) -> None: + self.transport = transport + + def add_peer(self, info_hash, ip_address: str, port: int): + self.peers.setdefault(info_hash, []) + self.peers[info_hash].append(encode_peer(ip_address, port)) + + def datagram_received(self, data: bytes, address: (str, int)) -> None: + if len(data) < 16: + return + action = int.from_bytes(data[8:12], "big", signed=False) + if action == 0: + req = decode(ConnectRequest, data) + connection_id = random.getrandbits(32) + self.known_conns.add(connection_id) + return self.transport.sendto(encode(ConnectResponse(0, req.transaction_id, connection_id)), address) + elif action == 1: + req = decode(AnnounceRequest, data) + if req.connection_id not in self.known_conns: + resp = encode(ErrorResponse(3, req.transaction_id, b'Connection ID missmatch.\x00')) + else: + compact_address = encode_peer(address[0], req.port) + if req.event != 3: + self.add_peer(req.info_hash, address[0], req.port) + elif compact_address in self.peers.get(req.info_hash, []): + self.peers[req.info_hash].remove(compact_address) + peers = [decode(CompactIPv4Peer, peer) for peer in self.peers[req.info_hash]] + resp = encode(AnnounceResponse(1, req.transaction_id, 1700, 0, len(peers), peers)) + return self.transport.sendto(resp, address) + + +def encode_peer(ip_address: str, port: int): + compact_ip = reduce(lambda buff, x: buff + bytearray([int(x)]), ip_address.split('.'), bytearray()) + return compact_ip + port.to_bytes(2, "big", signed=False) diff --git a/tests/unit/torrent/test_tracker.py b/tests/unit/torrent/test_tracker.py index 4bb733361..b240ac60e 100644 --- a/tests/unit/torrent/test_tracker.py +++ b/tests/unit/torrent/test_tracker.py @@ -1,52 +1,8 @@ import asyncio import random -from functools import reduce from lbry.testcase import AsyncioTestCase -from lbry.torrent.tracker import encode, decode, CompactIPv4Peer, ConnectRequest, \ - ConnectResponse, AnnounceRequest, ErrorResponse, AnnounceResponse, TrackerClient, subscribe_hash - - -class UDPTrackerServerProtocol(asyncio.DatagramProtocol): # for testing. Not suitable for production - def __init__(self): - self.transport = None - self.known_conns = set() - self.peers = {} - - def connection_made(self, transport: asyncio.DatagramTransport) -> None: - self.transport = transport - - def add_peer(self, info_hash, ip_address: str, port: int): - self.peers.setdefault(info_hash, []) - self.peers[info_hash].append(encode_peer(ip_address, port)) - - def datagram_received(self, data: bytes, address: (str, int)) -> None: - if len(data) < 16: - return - action = int.from_bytes(data[8:12], "big", signed=False) - if action == 0: - req = decode(ConnectRequest, data) - connection_id = random.getrandbits(32) - self.known_conns.add(connection_id) - return self.transport.sendto(encode(ConnectResponse(0, req.transaction_id, connection_id)), address) - elif action == 1: - req = decode(AnnounceRequest, data) - if req.connection_id not in self.known_conns: - resp = encode(ErrorResponse(3, req.transaction_id, b'Connection ID missmatch.\x00')) - else: - compact_address = encode_peer(address[0], req.port) - if req.event != 3: - self.add_peer(req.info_hash, address[0], req.port) - elif compact_address in self.peers.get(req.info_hash, []): - self.peers[req.info_hash].remove(compact_address) - peers = [decode(CompactIPv4Peer, peer) for peer in self.peers[req.info_hash]] - resp = encode(AnnounceResponse(1, req.transaction_id, 1700, 0, len(peers), peers)) - return self.transport.sendto(resp, address) - - -def encode_peer(ip_address: str, port: int): - compact_ip = reduce(lambda buff, x: buff + bytearray([int(x)]), ip_address.split('.'), bytearray()) - return compact_ip + port.to_bytes(2, "big", signed=False) +from lbry.torrent.tracker import CompactIPv4Peer, TrackerClient, subscribe_hash, UDPTrackerServerProtocol, encode_peer class UDPTrackerClientTestCase(AsyncioTestCase): From 235cc5dc051f7ec28ca50522d6028b5852e6383e Mon Sep 17 00:00:00 2001 From: Victor Shyba Date: Sat, 12 Mar 2022 02:18:15 -0300 Subject: [PATCH 43/50] results are indexed by ip, setdefault after resolve --- lbry/torrent/tracker.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lbry/torrent/tracker.py b/lbry/torrent/tracker.py index d29242790..166875f31 100644 --- a/lbry/torrent/tracker.py +++ b/lbry/torrent/tracker.py @@ -197,12 +197,12 @@ class TrackerClient: async def _probe_server(self, info_hash, tracker_host, tracker_port, stopped=False): result = None - self.results.setdefault(tracker_host, {}) try: tracker_host = await resolve_host(tracker_host, tracker_port, 'udp') except socket.error: log.warning("DNS failure while resolving tracker host: %s, skipping.", tracker_host) return + self.results.setdefault(tracker_host, {}) if info_hash in self.results[tracker_host]: next_announcement, result = self.results[tracker_host][info_hash] if time.time() < next_announcement: From b3f894e4804d95d7a89dcd9a235e2493b1b9f018 Mon Sep 17 00:00:00 2001 From: Victor Shyba Date: Sat, 12 Mar 2022 02:32:53 -0300 Subject: [PATCH 44/50] add integration test for tracker discovery --- .../datanetwork/test_file_commands.py | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/tests/integration/datanetwork/test_file_commands.py b/tests/integration/datanetwork/test_file_commands.py index 08cf070c8..970d54898 100644 --- a/tests/integration/datanetwork/test_file_commands.py +++ b/tests/integration/datanetwork/test_file_commands.py @@ -10,6 +10,7 @@ from lbry.stream.descriptor import StreamDescriptor from lbry.testcase import CommandTestCase from lbry.extras.daemon.components import TorrentSession, BACKGROUND_DOWNLOADER_COMPONENT from lbry.wallet import Transaction +from lbry.torrent.tracker import UDPTrackerServerProtocol class FileCommands(CommandTestCase): @@ -102,6 +103,24 @@ class FileCommands(CommandTestCase): await self.daemon.jsonrpc_get('lbry://foo') self.assertItemCount(await self.daemon.jsonrpc_file_list(), 1) + async def test_tracker_discovery(self): + port = 50990 + server = UDPTrackerServerProtocol() + transport, _ = await self.loop.create_datagram_endpoint(lambda: server, local_addr=("127.0.0.1", port)) + self.addCleanup(transport.close) + self.daemon.conf.fixed_peers = [] + self.daemon.conf.tracker_servers = [("127.0.0.1", port)] + tx = await self.stream_create('foo', '0.01') + sd_hash = tx['outputs'][0]['value']['source']['sd_hash'] + self.assertNotIn(bytes.fromhex(sd_hash)[:20], server.peers) + server.add_peer(bytes.fromhex(sd_hash)[:20], "127.0.0.1", 5567) + self.assertEqual(1, len(server.peers[bytes.fromhex(sd_hash)[:20]])) + self.assertTrue(await self.daemon.jsonrpc_file_delete(delete_all=True)) + stream = await self.daemon.jsonrpc_get('foo', save_file=True) + await self.wait_files_to_complete() + self.assertEqual(0, stream.blobs_remaining) + self.assertEqual(2, len(server.peers[bytes.fromhex(sd_hash)[:20]])) + async def test_announces(self): # announces on publish self.assertEqual(await self.daemon.storage.get_blobs_to_announce(), []) From 7d560df9fdbfc3df6cb51edda366b7c75d4f657f Mon Sep 17 00:00:00 2001 From: Victor Shyba Date: Sat, 12 Mar 2022 02:51:13 -0300 Subject: [PATCH 45/50] use same arg name as overriden datagram_received (linting) --- lbry/torrent/tracker.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/lbry/torrent/tracker.py b/lbry/torrent/tracker.py index 166875f31..d534e2a14 100644 --- a/lbry/torrent/tracker.py +++ b/lbry/torrent/tracker.py @@ -236,7 +236,7 @@ class UDPTrackerServerProtocol(asyncio.DatagramProtocol): # for testing. Not su self.peers.setdefault(info_hash, []) self.peers[info_hash].append(encode_peer(ip_address, port)) - def datagram_received(self, data: bytes, address: (str, int)) -> None: + def datagram_received(self, data: bytes, addr: (str, int)) -> None: if len(data) < 16: return action = int.from_bytes(data[8:12], "big", signed=False) @@ -244,20 +244,20 @@ class UDPTrackerServerProtocol(asyncio.DatagramProtocol): # for testing. Not su req = decode(ConnectRequest, data) connection_id = random.getrandbits(32) self.known_conns.add(connection_id) - return self.transport.sendto(encode(ConnectResponse(0, req.transaction_id, connection_id)), address) + return self.transport.sendto(encode(ConnectResponse(0, req.transaction_id, connection_id)), addr) elif action == 1: req = decode(AnnounceRequest, data) if req.connection_id not in self.known_conns: resp = encode(ErrorResponse(3, req.transaction_id, b'Connection ID missmatch.\x00')) else: - compact_address = encode_peer(address[0], req.port) + compact_address = encode_peer(addr[0], req.port) if req.event != 3: - self.add_peer(req.info_hash, address[0], req.port) + self.add_peer(req.info_hash, addr[0], req.port) elif compact_address in self.peers.get(req.info_hash, []): self.peers[req.info_hash].remove(compact_address) peers = [decode(CompactIPv4Peer, peer) for peer in self.peers[req.info_hash]] resp = encode(AnnounceResponse(1, req.transaction_id, 1700, 0, len(peers), peers)) - return self.transport.sendto(resp, address) + return self.transport.sendto(resp, addr) def encode_peer(ip_address: str, port: int): From 3dc145fe68ea2459a2c074bb6499e33d6f16bc84 Mon Sep 17 00:00:00 2001 From: Victor Shyba Date: Sun, 3 Apr 2022 23:20:02 -0300 Subject: [PATCH 46/50] make peer list query trackers --- lbry/extras/daemon/components.py | 2 +- lbry/extras/daemon/daemon.py | 19 ++++++++++++------- lbry/torrent/tracker.py | 7 +++++++ 3 files changed, 20 insertions(+), 8 deletions(-) diff --git a/lbry/extras/daemon/components.py b/lbry/extras/daemon/components.py index b3be409e0..88abe11a4 100644 --- a/lbry/extras/daemon/components.py +++ b/lbry/extras/daemon/components.py @@ -725,7 +725,7 @@ class TrackerAnnouncerComponent(Component): @property def component(self): - return self + return self.tracker_client @property def running(self): diff --git a/lbry/extras/daemon/daemon.py b/lbry/extras/daemon/daemon.py index 6881889bc..9c9ea8840 100644 --- a/lbry/extras/daemon/daemon.py +++ b/lbry/extras/daemon/daemon.py @@ -44,7 +44,7 @@ from lbry.error import ( from lbry.extras import system_info from lbry.extras.daemon import analytics from lbry.extras.daemon.components import WALLET_COMPONENT, DATABASE_COMPONENT, DHT_COMPONENT, BLOB_COMPONENT -from lbry.extras.daemon.components import FILE_MANAGER_COMPONENT, DISK_SPACE_COMPONENT +from lbry.extras.daemon.components import FILE_MANAGER_COMPONENT, DISK_SPACE_COMPONENT, TRACKER_ANNOUNCER_COMPONENT from lbry.extras.daemon.components import EXCHANGE_RATE_MANAGER_COMPONENT, UPNP_COMPONENT from lbry.extras.daemon.componentmanager import RequiredCondition from lbry.extras.daemon.componentmanager import ComponentManager @@ -4971,21 +4971,26 @@ class Daemon(metaclass=JSONRPCServerType): if not is_valid_blobhash(blob_hash): # TODO: use error from lbry.error raise Exception("invalid blob hash") - peers = [] peer_q = asyncio.Queue(loop=self.component_manager.loop) + if self.component_manager.has_component(TRACKER_ANNOUNCER_COMPONENT): + tracker = self.component_manager.get_component(TRACKER_ANNOUNCER_COMPONENT) + tracker_peers = await tracker.get_kademlia_peer_list(bytes.fromhex(blob_hash)) + log.info("Found %d peers for %s from trackers.", len(tracker_peers), blob_hash[:8]) + peer_q.put_nowait(tracker_peers) + peers = [] await self.dht_node._peers_for_value_producer(blob_hash, peer_q) while not peer_q.empty(): peers.extend(peer_q.get_nowait()) - results = [ - { - "node_id": hexlify(peer.node_id).decode(), + results = { + (peer.address, peer.tcp_port): { + "node_id": hexlify(peer.node_id).decode() if peer.node_id else None, "address": peer.address, "udp_port": peer.udp_port, "tcp_port": peer.tcp_port, } for peer in peers - ] - return paginate_list(results, page, page_size) + } + return paginate_list(list(results.values()), page, page_size) @requires(DATABASE_COMPONENT) async def jsonrpc_blob_announce(self, blob_hash=None, stream_hash=None, sd_hash=None): diff --git a/lbry/torrent/tracker.py b/lbry/torrent/tracker.py index d534e2a14..228353ef1 100644 --- a/lbry/torrent/tracker.py +++ b/lbry/torrent/tracker.py @@ -5,10 +5,12 @@ import struct import asyncio import logging import time +import ipaddress from collections import namedtuple from functools import reduce from typing import Optional +from lbry.dht.node import get_kademlia_peers_from_hosts from lbry.utils import resolve_host, async_timed_cache, cache_concurrent from lbry.wallet.stream import StreamController from lbry import version @@ -195,6 +197,11 @@ class TrackerClient: found.append(result) return found + async def get_kademlia_peer_list(self, info_hash): + responses = await self.get_peer_list(info_hash) + peers = [(str(ipaddress.ip_address(peer.address)), peer.port) for ann in responses for peer in ann.peers] + return await get_kademlia_peers_from_hosts(peers) + async def _probe_server(self, info_hash, tracker_host, tracker_port, stopped=False): result = None try: From 7cba51ca7d22b230526a68fc7f6ed7081b222e05 Mon Sep 17 00:00:00 2001 From: Victor Shyba Date: Mon, 4 Apr 2022 00:09:20 -0300 Subject: [PATCH 47/50] update tests, query with port 0, filter bad ports earlier, make unit tests more reliable --- lbry/extras/daemon/daemon.py | 6 ++++-- lbry/stream/downloader.py | 5 +++-- lbry/torrent/tracker.py | 17 ++++++++++------- .../datanetwork/test_file_commands.py | 8 ++++++++ tests/unit/torrent/test_tracker.py | 2 +- 5 files changed, 26 insertions(+), 12 deletions(-) diff --git a/lbry/extras/daemon/daemon.py b/lbry/extras/daemon/daemon.py index 9c9ea8840..c9983f756 100644 --- a/lbry/extras/daemon/daemon.py +++ b/lbry/extras/daemon/daemon.py @@ -4949,7 +4949,6 @@ class Daemon(metaclass=JSONRPCServerType): DHT / Blob Exchange peer commands. """ - @requires(DHT_COMPONENT) async def jsonrpc_peer_list(self, blob_hash, page=None, page_size=None): """ Get peers for blob hash @@ -4977,8 +4976,11 @@ class Daemon(metaclass=JSONRPCServerType): tracker_peers = await tracker.get_kademlia_peer_list(bytes.fromhex(blob_hash)) log.info("Found %d peers for %s from trackers.", len(tracker_peers), blob_hash[:8]) peer_q.put_nowait(tracker_peers) + elif not self.component_manager.has_component(DHT_COMPONENT): + raise Exception("Peer list needs, at least, either a DHT component or a Tracker component for discovery.") peers = [] - await self.dht_node._peers_for_value_producer(blob_hash, peer_q) + if self.component_manager.has_component(DHT_COMPONENT): + await self.dht_node._peers_for_value_producer(blob_hash, peer_q) while not peer_q.empty(): peers.extend(peer_q.get_nowait()) results = { diff --git a/lbry/stream/downloader.py b/lbry/stream/downloader.py index c7d588267..0d9684443 100644 --- a/lbry/stream/downloader.py +++ b/lbry/stream/downloader.py @@ -67,8 +67,9 @@ class StreamDownloader: self.fixed_peers_handle = self.loop.call_later(self.fixed_peers_delay, _add_fixed_peers, fixed_peers) async def _process_announcement(self, announcement: 'AnnounceResponse'): - peers = [(str(ipaddress.ip_address(peer.address)), peer.port) for peer in announcement.peers] - peers = await get_kademlia_peers_from_hosts(peers) + peers = await get_kademlia_peers_from_hosts([ + (str(ipaddress.ip_address(peer.address)), peer.port) for peer in announcement.peers if peer.port > 1024 + ]) log.info("Found %d peers from tracker for %s", len(peers), self.sd_hash[:8]) self.peer_queue.put_nowait(peers) diff --git a/lbry/torrent/tracker.py b/lbry/torrent/tracker.py index 228353ef1..0eb64b793 100644 --- a/lbry/torrent/tracker.py +++ b/lbry/torrent/tracker.py @@ -187,10 +187,10 @@ class TrackerClient: errors = sum([1 for result in results if result is None or isinstance(result, Exception)]) log.info("Tracker: finished announcing %d files to %s:%d, %d errors", len(results), *server, errors) - async def get_peer_list(self, info_hash, stopped=False, on_announcement=None): + async def get_peer_list(self, info_hash, stopped=False, on_announcement=None, no_port=False): found = [] - servers = self._get_servers() - for done in asyncio.as_completed([self._probe_server(info_hash, *server, stopped) for server in servers]): + probes = [self._probe_server(info_hash, *server, stopped, no_port) for server in self._get_servers()] + for done in asyncio.as_completed(probes): result = await done if result is not None: await asyncio.gather(*filter(asyncio.iscoroutine, [on_announcement(result)] if on_announcement else [])) @@ -198,11 +198,14 @@ class TrackerClient: return found async def get_kademlia_peer_list(self, info_hash): - responses = await self.get_peer_list(info_hash) - peers = [(str(ipaddress.ip_address(peer.address)), peer.port) for ann in responses for peer in ann.peers] + responses = await self.get_peer_list(info_hash, no_port=True) + peers = [ + (str(ipaddress.ip_address(peer.address)), peer.port) + for ann in responses for peer in ann.peers if peer.port > 1024 # filter out privileged and 0 + ] return await get_kademlia_peers_from_hosts(peers) - async def _probe_server(self, info_hash, tracker_host, tracker_port, stopped=False): + async def _probe_server(self, info_hash, tracker_host, tracker_port, stopped=False, no_port=False): result = None try: tracker_host = await resolve_host(tracker_host, tracker_port, 'udp') @@ -216,7 +219,7 @@ class TrackerClient: return result try: result = await self.client.announce( - info_hash, self.peer_id, self.announce_port, tracker_host, tracker_port, stopped) + info_hash, self.peer_id, 0 if no_port else self.announce_port, tracker_host, tracker_port, stopped) self.results[tracker_host][info_hash] = (time.time() + result.interval, result) except asyncio.TimeoutError: # todo: this is UDP, timeout is common, we need a better metric for failures self.results[tracker_host][info_hash] = (time.time() + 60.0, result) diff --git a/tests/integration/datanetwork/test_file_commands.py b/tests/integration/datanetwork/test_file_commands.py index 970d54898..ffde6acc9 100644 --- a/tests/integration/datanetwork/test_file_commands.py +++ b/tests/integration/datanetwork/test_file_commands.py @@ -120,6 +120,14 @@ class FileCommands(CommandTestCase): await self.wait_files_to_complete() self.assertEqual(0, stream.blobs_remaining) self.assertEqual(2, len(server.peers[bytes.fromhex(sd_hash)[:20]])) + self.assertEqual([{'address': '127.0.0.1', + 'node_id': None, + 'tcp_port': 5567, + 'udp_port': None}, + {'address': '127.0.0.1', + 'node_id': None, + 'tcp_port': 4444, + 'udp_port': None}], (await self.daemon.jsonrpc_peer_list(sd_hash))['items']) async def test_announces(self): # announces on publish diff --git a/tests/unit/torrent/test_tracker.py b/tests/unit/torrent/test_tracker.py index b240ac60e..a411cddb0 100644 --- a/tests/unit/torrent/test_tracker.py +++ b/tests/unit/torrent/test_tracker.py @@ -9,7 +9,7 @@ class UDPTrackerClientTestCase(AsyncioTestCase): async def asyncSetUp(self): self.client_servers_list = [] self.servers = {} - self.client = TrackerClient(b"\x00" * 48, 4444, lambda: self.client_servers_list, timeout=0.1) + self.client = TrackerClient(b"\x00" * 48, 4444, lambda: self.client_servers_list, timeout=1) await self.client.start() self.addCleanup(self.client.stop) await self.add_server() From e54cc8850c4d418272e87f2ef6030036ca178e93 Mon Sep 17 00:00:00 2001 From: Victor Shyba Date: Mon, 4 Apr 2022 23:53:38 -0300 Subject: [PATCH 48/50] return KademliaPeers directly into the queue instead of exposing Announcement abstraction --- lbry/stream/downloader.py | 14 ++------------ lbry/torrent/tracker.py | 22 +++++++++++++++------- tests/unit/torrent/test_tracker.py | 16 ++++++++-------- 3 files changed, 25 insertions(+), 27 deletions(-) diff --git a/lbry/stream/downloader.py b/lbry/stream/downloader.py index 0d9684443..1f78979b7 100644 --- a/lbry/stream/downloader.py +++ b/lbry/stream/downloader.py @@ -1,5 +1,4 @@ import asyncio -import ipaddress import typing import logging import binascii @@ -9,10 +8,9 @@ from lbry.error import DownloadSDTimeoutError from lbry.utils import lru_cache_concurrent from lbry.stream.descriptor import StreamDescriptor from lbry.blob_exchange.downloader import BlobDownloader -from lbry.torrent.tracker import subscribe_hash +from lbry.torrent.tracker import enqueue_tracker_search if typing.TYPE_CHECKING: - from lbry.torrent.tracker import AnnounceResponse from lbry.conf import Config from lbry.dht.node import Node from lbry.blob.blob_manager import BlobManager @@ -66,13 +64,6 @@ class StreamDownloader: fixed_peers = await get_kademlia_peers_from_hosts(self.config.fixed_peers) self.fixed_peers_handle = self.loop.call_later(self.fixed_peers_delay, _add_fixed_peers, fixed_peers) - async def _process_announcement(self, announcement: 'AnnounceResponse'): - peers = await get_kademlia_peers_from_hosts([ - (str(ipaddress.ip_address(peer.address)), peer.port) for peer in announcement.peers if peer.port > 1024 - ]) - log.info("Found %d peers from tracker for %s", len(peers), self.sd_hash[:8]) - self.peer_queue.put_nowait(peers) - async def load_descriptor(self, connection_id: int = 0): # download or get the sd blob sd_blob = self.blob_manager.get_blob(self.sd_hash) @@ -102,8 +93,7 @@ class StreamDownloader: self.accumulate_task.cancel() _, self.accumulate_task = self.node.accumulate_peers(self.search_queue, self.peer_queue) await self.add_fixed_peers() - subscribe_hash( - bytes.fromhex(self.sd_hash), lambda result: asyncio.ensure_future(self._process_announcement(result))) + enqueue_tracker_search(bytes.fromhex(self.sd_hash), self.peer_queue) # start searching for peers for the sd hash self.search_queue.put_nowait(self.sd_hash) log.info("searching for peers for stream %s", self.sd_hash) diff --git a/lbry/torrent/tracker.py b/lbry/torrent/tracker.py index 0eb64b793..14b782f62 100644 --- a/lbry/torrent/tracker.py +++ b/lbry/torrent/tracker.py @@ -199,11 +199,7 @@ class TrackerClient: async def get_kademlia_peer_list(self, info_hash): responses = await self.get_peer_list(info_hash, no_port=True) - peers = [ - (str(ipaddress.ip_address(peer.address)), peer.port) - for ann in responses for peer in ann.peers if peer.port > 1024 # filter out privileged and 0 - ] - return await get_kademlia_peers_from_hosts(peers) + return await announcement_to_kademlia_peers(*responses) async def _probe_server(self, info_hash, tracker_host, tracker_port, stopped=False, no_port=False): result = None @@ -229,8 +225,20 @@ class TrackerClient: return result -def subscribe_hash(info_hash: bytes, on_data): - TrackerClient.EVENT_CONTROLLER.add(('search', info_hash, on_data)) +def enqueue_tracker_search(info_hash: bytes, peer_q: asyncio.Queue): + async def on_announcement(announcement: AnnounceResponse): + peers = await announcement_to_kademlia_peers(announcement) + log.info("Found %d peers from tracker for %s", len(peers), info_hash.hex()[:8]) + peer_q.put_nowait(peers) + TrackerClient.EVENT_CONTROLLER.add(('search', info_hash, on_announcement)) + + +def announcement_to_kademlia_peers(*announcements: AnnounceResponse): + peers = [ + (str(ipaddress.ip_address(peer.address)), peer.port) + for announcement in announcements for peer in announcement.peers if peer.port > 1024 # no privileged or 0 + ] + return get_kademlia_peers_from_hosts(peers) class UDPTrackerServerProtocol(asyncio.DatagramProtocol): # for testing. Not suitable for production diff --git a/tests/unit/torrent/test_tracker.py b/tests/unit/torrent/test_tracker.py index a411cddb0..32e4846a1 100644 --- a/tests/unit/torrent/test_tracker.py +++ b/tests/unit/torrent/test_tracker.py @@ -2,7 +2,8 @@ import asyncio import random from lbry.testcase import AsyncioTestCase -from lbry.torrent.tracker import CompactIPv4Peer, TrackerClient, subscribe_hash, UDPTrackerServerProtocol, encode_peer +from lbry.dht.peer import KademliaPeer +from lbry.torrent.tracker import CompactIPv4Peer, TrackerClient, enqueue_tracker_search, UDPTrackerServerProtocol, encode_peer class UDPTrackerClientTestCase(AsyncioTestCase): @@ -46,10 +47,9 @@ class UDPTrackerClientTestCase(AsyncioTestCase): async def test_announce_using_helper_function(self): info_hash = random.getrandbits(160).to_bytes(20, "big", signed=False) queue = asyncio.Queue() - subscribe_hash(info_hash, queue.put) - announcement = await queue.get() - peers = announcement.peers - self.assertEqual(peers, [CompactIPv4Peer(int.from_bytes(bytes([127, 0, 0, 1]), "big", signed=False), 4444)]) + enqueue_tracker_search(info_hash, queue) + peers = await queue.get() + self.assertEqual(peers, [KademliaPeer('127.0.0.1', None, None, 4444, allow_localhost=True)]) async def test_error(self): info_hash = random.getrandbits(160).to_bytes(20, "big", signed=False) @@ -85,8 +85,8 @@ class UDPTrackerClientTestCase(AsyncioTestCase): peer = (f"127.0.0.{random.randint(1, 255)}", random.randint(2000, 65500)) fake_peers.append(peer) server.add_peer(info_hash, *peer) - response = [] - subscribe_hash(info_hash, response.append) + peer_q = asyncio.Queue() + enqueue_tracker_search(info_hash, peer_q) await asyncio.sleep(0) await asyncio.gather(*self.client.tasks.values()) - self.assertEqual(11, len(response)) + self.assertEqual(11, peer_q.qsize()) From 629812337b10d4193299a3b9cf183edaaac891b7 Mon Sep 17 00:00:00 2001 From: Victor Shyba Date: Fri, 6 May 2022 04:01:01 -0300 Subject: [PATCH 49/50] changes from review --- lbry/extras/daemon/components.py | 10 +++++----- lbry/torrent/tracker.py | 26 +++++++++++++------------- 2 files changed, 18 insertions(+), 18 deletions(-) diff --git a/lbry/extras/daemon/components.py b/lbry/extras/daemon/components.py index 88abe11a4..e061c4363 100644 --- a/lbry/extras/daemon/components.py +++ b/lbry/extras/daemon/components.py @@ -733,14 +733,14 @@ class TrackerAnnouncerComponent(Component): async def announce_forever(self): while True: - to_sleep = 60.0 - to_announce = [] + sleep_seconds = 60.0 + announce_sd_hashes = [] for file in self.file_manager.get_filtered(): if not file.downloader: continue - to_announce.append(bytes.fromhex(file.sd_hash)) - await self.tracker_client.announce_many(*to_announce) - await asyncio.sleep(to_sleep) + announce_sd_hashes.append(bytes.fromhex(file.sd_hash)) + await self.tracker_client.announce_many(*announce_sd_hashes) + await asyncio.sleep(sleep_seconds) async def start(self): node = self.component_manager.get_component(DHT_COMPONENT) \ diff --git a/lbry/torrent/tracker.py b/lbry/torrent/tracker.py index 14b782f62..16446bfdd 100644 --- a/lbry/torrent/tracker.py +++ b/lbry/torrent/tracker.py @@ -31,7 +31,7 @@ ScrapeRequest = namedtuple("ScrapeRequest", ["connection_id", "action", "transac ScrapeResponse = namedtuple("ScrapeResponse", ["action", "transaction_id", "items"]) ScrapeResponseItem = namedtuple("ScrapeResponseItem", ["seeders", "completed", "leechers"]) ErrorResponse = namedtuple("ErrorResponse", ["action", "transaction_id", "message"]) -STRUCTS = { +structs = { ConnectRequest: struct.Struct(">QII"), ConnectResponse: struct.Struct(">IIQ"), AnnounceRequest: struct.Struct(">QII20s20sQQQIIIiH"), @@ -45,26 +45,26 @@ STRUCTS = { def decode(cls, data, offset=0): - decoder = STRUCTS[cls] - if cls == AnnounceResponse: + decoder = structs[cls] + if cls is AnnounceResponse: return AnnounceResponse(*decoder.unpack_from(data, offset), peers=[decode(CompactIPv4Peer, data, index) for index in range(20, len(data), 6)]) - elif cls == ScrapeResponse: + elif cls is ScrapeResponse: return ScrapeResponse(*decoder.unpack_from(data, offset), items=[decode(ScrapeResponseItem, data, index) for index in range(8, len(data), 12)]) - elif cls == ErrorResponse: + elif cls is ErrorResponse: return ErrorResponse(*decoder.unpack_from(data, offset), data[decoder.size:]) return cls(*decoder.unpack_from(data, offset)) def encode(obj): if isinstance(obj, ScrapeRequest): - return STRUCTS[ScrapeRequest].pack(*obj[:-1]) + b''.join(obj.infohashes) + return structs[ScrapeRequest].pack(*obj[:-1]) + b''.join(obj.infohashes) elif isinstance(obj, ErrorResponse): - return STRUCTS[ErrorResponse].pack(*obj[:-1]) + obj.message + return structs[ErrorResponse].pack(*obj[:-1]) + obj.message elif isinstance(obj, AnnounceResponse): - return STRUCTS[AnnounceResponse].pack(*obj[:-1]) + b''.join([encode(peer) for peer in obj.peers]) - return STRUCTS[type(obj)].pack(*obj) + return structs[AnnounceResponse].pack(*obj[:-1]) + b''.join([encode(peer) for peer in obj.peers]) + return structs[type(obj)].pack(*obj) def make_peer_id(random_part: Optional[str] = None) -> bytes: @@ -136,7 +136,7 @@ class UDPTrackerClientProtocol(asyncio.DatagramProtocol): class TrackerClient: - EVENT_CONTROLLER = StreamController() + event_controller = StreamController() def __init__(self, node_id, announce_port, get_servers, timeout=10.0): self.client = UDPTrackerClientProtocol(timeout=timeout) @@ -150,7 +150,7 @@ class TrackerClient: async def start(self): self.transport, _ = await asyncio.get_running_loop().create_datagram_endpoint( lambda: self.client, local_addr=("0.0.0.0", 0)) - self.EVENT_CONTROLLER.stream.listen( + self.event_controller.stream.listen( lambda request: self.on_hash(request[1], request[2]) if request[0] == 'search' else None) def stop(self): @@ -160,7 +160,7 @@ class TrackerClient: self.transport.close() self.client = None self.transport = None - self.EVENT_CONTROLLER.close() + self.event_controller.close() def on_hash(self, info_hash, on_announcement=None): if info_hash not in self.tasks: @@ -230,7 +230,7 @@ def enqueue_tracker_search(info_hash: bytes, peer_q: asyncio.Queue): peers = await announcement_to_kademlia_peers(announcement) log.info("Found %d peers from tracker for %s", len(peers), info_hash.hex()[:8]) peer_q.put_nowait(peers) - TrackerClient.EVENT_CONTROLLER.add(('search', info_hash, on_announcement)) + TrackerClient.event_controller.add(('search', info_hash, on_announcement)) def announcement_to_kademlia_peers(*announcements: AnnounceResponse): From 03b0d5e2501d345c7a7754bd6bcf48f90922672c Mon Sep 17 00:00:00 2001 From: Victor Shyba Date: Wed, 11 May 2022 14:35:15 -0300 Subject: [PATCH 50/50] tracker client: extract default timeout and concurreny. Bump concurrency to 100 --- lbry/torrent/tracker.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/lbry/torrent/tracker.py b/lbry/torrent/tracker.py index 16446bfdd..82daa87f5 100644 --- a/lbry/torrent/tracker.py +++ b/lbry/torrent/tracker.py @@ -18,6 +18,8 @@ from lbry import version log = logging.getLogger(__name__) CONNECTION_EXPIRES_AFTER_SECONDS = 50 PREFIX = 'LB' # todo: PR BEP20 to add ourselves +DEFAULT_TIMEOUT_SECONDS = 10.0 +DEFAULT_CONCURRENCY_LIMIT = 100 # see: http://bittorrent.org/beps/bep_0015.html and http://xbtt.sourceforge.net/udp_tracker_protocol.html ConnectRequest = namedtuple("ConnectRequest", ["connection_id", "action", "transaction_id"]) ConnectResponse = namedtuple("ConnectResponse", ["action", "transaction_id", "connection_id"]) @@ -75,11 +77,11 @@ def make_peer_id(random_part: Optional[str] = None) -> bytes: class UDPTrackerClientProtocol(asyncio.DatagramProtocol): - def __init__(self, timeout: float = 10.0): + def __init__(self, timeout: float = DEFAULT_TIMEOUT_SECONDS): self.transport = None self.data_queue = {} self.timeout = timeout - self.semaphore = asyncio.Semaphore(10) + self.semaphore = asyncio.Semaphore(DEFAULT_CONCURRENCY_LIMIT) def connection_made(self, transport: asyncio.DatagramTransport) -> None: self.transport = transport