Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
feat p2p_daemon: add API to call peer handle (#181)
* Extend P2P api * Add tests for new api * Add p2pclient dependencies * Test P2P from different processes * Fix typo in tests * Add default initialization * Fix daemon ports assignment * Replace del with __del__ in tests * Read from input stream with receive_exactly Co-authored-by: Ilya Kobelev <ilya.kobellev@gmail.com>
- Loading branch information
1 parent
0535efe
commit 3595c94
Showing
2 changed files
with
292 additions
and
40 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -1,45 +1,197 @@ | ||
import asyncio | ||
import contextlib | ||
import copy | ||
from pathlib import Path | ||
import pickle | ||
import socket | ||
import subprocess | ||
import typing as tp | ||
import warnings | ||
|
||
from multiaddr import Multiaddr | ||
import p2pclient | ||
from libp2p.peer.id import ID | ||
|
||
|
||
class P2P(object): | ||
""" | ||
Forks a child process and executes p2pd command with given arguments. | ||
Sends SIGKILL to the child in destructor and on exit from contextmanager. | ||
Can be used for peer to peer communication and procedure calls. | ||
Sends SIGKILL to the child in destructor. | ||
""" | ||
|
||
LIBP2P_CMD = 'p2pd' | ||
P2PD_RELATIVE_PATH = 'hivemind_cli/p2pd' | ||
NUM_RETRIES = 3 | ||
RETRY_DELAY = 0.4 | ||
HEADER_LEN = 8 | ||
BYTEORDER = 'big' | ||
|
||
def __init__(self, *args, **kwargs): | ||
self._child = subprocess.Popen(args=self._make_process_args(args, kwargs)) | ||
try: | ||
stdout, stderr = self._child.communicate(timeout=0.2) | ||
except subprocess.TimeoutExpired: | ||
pass | ||
else: | ||
raise RuntimeError(f'p2p daemon exited with stderr: {stderr}') | ||
def __init__(self): | ||
self._child = None | ||
self._listen_task = None | ||
self._server_stopped = asyncio.Event() | ||
self._buffer = bytearray() | ||
|
||
def __enter__(self): | ||
return self._child | ||
@classmethod | ||
async def create(cls, *args, quic=1, tls=1, conn_manager=1, dht_client=1, | ||
nat_port_map=True, auto_nat=True, bootstrap=True, | ||
host_port: int = None, daemon_listen_port: int = None, **kwargs): | ||
self = cls() | ||
p2pd_path = Path(__file__).resolve().parents[1] / P2P.P2PD_RELATIVE_PATH | ||
proc_args = self._make_process_args( | ||
str(p2pd_path), *args, | ||
quic=quic, tls=tls, connManager=conn_manager, | ||
dhtClient=dht_client, natPortMap=nat_port_map, | ||
autonat=auto_nat, b=bootstrap, **kwargs) | ||
self._assign_daemon_ports(host_port, daemon_listen_port) | ||
for try_count in range(self.NUM_RETRIES): | ||
try: | ||
self._initialize(proc_args) | ||
await self._identify_client(P2P.RETRY_DELAY * (2 ** try_count)) | ||
except Exception as exc: | ||
warnings.warn("Failed to initialize p2p daemon: " + str(exc), RuntimeWarning) | ||
self._kill_child() | ||
if try_count == P2P.NUM_RETRIES - 1: | ||
raise | ||
self._assign_daemon_ports() | ||
continue | ||
break | ||
return self | ||
|
||
def __exit__(self, exc_type, exc_val, exc_tb): | ||
self._kill_child() | ||
def _initialize(self, proc_args: tp.List[str]) -> None: | ||
proc_args = copy.deepcopy(proc_args) | ||
proc_args.extend(self._make_process_args( | ||
hostAddrs=f'/ip4/0.0.0.0/tcp/{self._host_port},/ip4/0.0.0.0/udp/{self._host_port}/quic', | ||
listen=f'/ip4/127.0.0.1/tcp/{self._daemon_listen_port}' | ||
)) | ||
self._child = subprocess.Popen( | ||
args=proc_args, | ||
stdin=subprocess.PIPE, stdout=subprocess.PIPE, | ||
stderr=subprocess.PIPE, encoding="utf8" | ||
) | ||
self._client_listen_port = find_open_port() | ||
self._client = p2pclient.Client( | ||
Multiaddr(f'/ip4/127.0.0.1/tcp/{self._daemon_listen_port}'), | ||
Multiaddr(f'/ip4/127.0.0.1/tcp/{self._client_listen_port}')) | ||
|
||
async def _identify_client(self, delay): | ||
await asyncio.sleep(delay) | ||
encoded = await self._client.identify() | ||
self.id = encoded[0].to_base58() | ||
|
||
def _assign_daemon_ports(self, host_port=None, daemon_listen_port=None): | ||
self._host_port, self._daemon_listen_port = host_port, daemon_listen_port | ||
if host_port is None: | ||
self._host_port = find_open_port() | ||
if daemon_listen_port is None: | ||
self._daemon_listen_port = find_open_port() | ||
while self._daemon_listen_port == self._host_port: | ||
self._daemon_listen_port = find_open_port() | ||
|
||
@staticmethod | ||
async def send_data(data, stream): | ||
byte_str = pickle.dumps(data) | ||
request = len(byte_str).to_bytes(P2P.HEADER_LEN, P2P.BYTEORDER) + byte_str | ||
await stream.send_all(request) | ||
|
||
class IncompleteRead(Exception): | ||
pass | ||
|
||
async def _receive_exactly(self, stream, n_bytes, max_bytes=1 << 16): | ||
while len(self._buffer) < n_bytes: | ||
data = await stream.receive_some(max_bytes) | ||
if len(data) == 0: | ||
raise P2P.IncompleteRead() | ||
self._buffer.extend(data) | ||
|
||
result = self._buffer[:n_bytes] | ||
self._buffer = self._buffer[n_bytes:] | ||
return bytes(result) | ||
|
||
async def receive_data(self, stream, max_bytes=(1 < 16)): | ||
header = await self._receive_exactly(stream, P2P.HEADER_LEN) | ||
content_length = int.from_bytes(header, P2P.BYTEORDER) | ||
data = await self._receive_exactly(stream, content_length) | ||
return pickle.loads(data) | ||
|
||
def _handle_stream(self, handle): | ||
async def do_handle_stream(stream_info, stream): | ||
try: | ||
request = await self.receive_data(stream) | ||
except P2P.IncompleteRead: | ||
warnings.warn("Incomplete read while receiving request from peer", RuntimeWarning) | ||
return | ||
finally: | ||
stream.close() | ||
try: | ||
result = handle(request) | ||
await self.send_data(result, stream) | ||
except Exception as exc: | ||
await self.send_data(exc, stream) | ||
finally: | ||
await stream.close() | ||
|
||
return do_handle_stream | ||
|
||
def start_listening(self): | ||
async def listen(): | ||
async with self._client.listen(): | ||
await self._server_stopped.wait() | ||
|
||
self._listen_task = asyncio.create_task(listen()) | ||
|
||
async def stop_listening(self): | ||
if self._listen_task is not None: | ||
self._server_stopped.set() | ||
self._listen_task.cancel() | ||
try: | ||
await self._listen_task | ||
except asyncio.CancelledError: | ||
self._listen_task = None | ||
self._server_stopped.clear() | ||
|
||
async def add_stream_handler(self, name, handle): | ||
if self._listen_task is None: | ||
self.start_listening() | ||
|
||
await self._client.stream_handler(name, self._handle_stream(handle)) | ||
|
||
async def call_peer_handler(self, peer_id, handler_name, input_data): | ||
libp2p_peer_id = ID.from_base58(peer_id) | ||
stream_info, stream = await self._client.stream_open(libp2p_peer_id, (handler_name,)) | ||
try: | ||
await self.send_data(input_data, stream) | ||
return await self.receive_data(stream) | ||
finally: | ||
await stream.close() | ||
|
||
def __del__(self): | ||
self._kill_child() | ||
|
||
def _kill_child(self): | ||
if self._child.poll() is None: | ||
if self._child is not None and self._child.poll() is None: | ||
self._child.kill() | ||
self._child.wait() | ||
|
||
def _make_process_args(self, args: tp.Tuple[tp.Any], | ||
kwargs: tp.Dict[str, tp.Any]) -> tp.List[str]: | ||
proc_args = [self.LIBP2P_CMD] | ||
def _make_process_args(self, *args, **kwargs) -> tp.List[str]: | ||
proc_args = [] | ||
proc_args.extend( | ||
str(entry) for entry in args | ||
) | ||
proc_args.extend( | ||
f'-{key}={str(value)}' for key, value in kwargs.items() | ||
f'-{key}={value}' if value is not None else f'-{key}' | ||
for key, value in kwargs.items() | ||
) | ||
return proc_args | ||
|
||
|
||
def find_open_port(params=(socket.AF_INET, socket.SOCK_STREAM), | ||
opt=(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)): | ||
""" Finds a tcp port that can be occupied with a socket with *params and use *opt options """ | ||
try: | ||
with contextlib.closing(socket.socket(*params)) as sock: | ||
sock.bind(('', 0)) | ||
sock.setsockopt(*opt) | ||
return sock.getsockname()[1] | ||
except Exception: | ||
raise |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters