diff --git a/pyproject.toml b/pyproject.toml index e3a38295..dd3951be 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -20,6 +20,7 @@ dependencies = [ "base58>=1.0.3", "coincurve==21.0.0", "exceptiongroup>=1.2.0; python_version < '3.11'", + "fastecdsa==2.3.2; sys_platform != 'win32'", "grpcio>=1.41.0", "lru-dict>=1.1.6", "multiaddr (>=0.0.9,<0.0.10)", @@ -32,7 +33,6 @@ dependencies = [ "rpcudp>=3.0.0", "trio-typing>=0.0.4", "trio>=0.26.0", - "fastecdsa==2.3.2; sys_platform != 'win32'", "zeroconf (>=0.147.0,<0.148.0)", ] classifiers = [ @@ -282,4 +282,5 @@ project_excludes = [ "**/*pb2.py", "**/*.pyi", ".venv/**", + "./tests/interop/nim_libp2p", ] diff --git a/tests/interop/nim_libp2p/.gitignore b/tests/interop/nim_libp2p/.gitignore new file mode 100644 index 00000000..7bcc01ea --- /dev/null +++ b/tests/interop/nim_libp2p/.gitignore @@ -0,0 +1,8 @@ +nimble.develop +nimble.paths + +*.nimble +nim-libp2p/ + +nim_echo_server +config.nims diff --git a/tests/interop/nim_libp2p/nim_echo_server.nim b/tests/interop/nim_libp2p/nim_echo_server.nim new file mode 100644 index 00000000..a4f581d9 --- /dev/null +++ b/tests/interop/nim_libp2p/nim_echo_server.nim @@ -0,0 +1,108 @@ +{.used.} + +import chronos +import stew/byteutils +import libp2p + +## +# Simple Echo Protocol Implementation for py-libp2p Interop Testing +## +const EchoCodec = "/echo/1.0.0" + +type EchoProto = ref object of LPProtocol + +proc new(T: typedesc[EchoProto]): T = + proc handle(conn: Connection, proto: string) {.async: (raises: [CancelledError]).} = + try: + echo "Echo server: Received connection from ", conn.peerId + + # Read and echo messages in a loop + while not conn.atEof: + try: + # Read length-prefixed message using nim-libp2p's readLp + let message = await conn.readLp(1024 * 1024) # Max 1MB + if message.len == 0: + echo "Echo server: Empty message, closing connection" + break + + let messageStr = string.fromBytes(message) + echo "Echo server: Received (", message.len, " bytes): ", messageStr + + # Echo back using writeLp + await conn.writeLp(message) + echo "Echo server: Echoed message back" + + except CatchableError as e: + echo "Echo server: Error processing message: ", e.msg + break + + except CancelledError as e: + echo "Echo server: Connection cancelled" + raise e + except CatchableError as e: + echo "Echo server: Exception in handler: ", e.msg + finally: + echo "Echo server: Connection closed" + await conn.close() + + return T.new(codecs = @[EchoCodec], handler = handle) + +## +# Create QUIC-enabled switch +## +proc createSwitch(ma: MultiAddress, rng: ref HmacDrbgContext): Switch = + var switch = SwitchBuilder + .new() + .withRng(rng) + .withAddress(ma) + .withQuicTransport() + .build() + result = switch + +## +# Main server +## +proc main() {.async.} = + let + rng = newRng() + localAddr = MultiAddress.init("/ip4/0.0.0.0/udp/0/quic-v1").tryGet() + echoProto = EchoProto.new() + + echo "=== Nim Echo Server for py-libp2p Interop ===" + + # Create switch + let switch = createSwitch(localAddr, rng) + switch.mount(echoProto) + + # Start server + await switch.start() + + # Print connection info + echo "Peer ID: ", $switch.peerInfo.peerId + echo "Listening on:" + for addr in switch.peerInfo.addrs: + echo " ", $addr, "/p2p/", $switch.peerInfo.peerId + echo "Protocol: ", EchoCodec + echo "Ready for py-libp2p connections!" + echo "" + + # Keep running + try: + await sleepAsync(100.hours) + except CancelledError: + echo "Shutting down..." + finally: + await switch.stop() + +# Graceful shutdown handler +proc signalHandler() {.noconv.} = + echo "\nShutdown signal received" + quit(0) + +when isMainModule: + setControlCHook(signalHandler) + try: + waitFor(main()) + except CatchableError as e: + echo "Error: ", e.msg + quit(1) diff --git a/tests/interop/nim_libp2p/scripts/setup_nim_echo.sh b/tests/interop/nim_libp2p/scripts/setup_nim_echo.sh new file mode 100755 index 00000000..bf8aa307 --- /dev/null +++ b/tests/interop/nim_libp2p/scripts/setup_nim_echo.sh @@ -0,0 +1,98 @@ +#!/usr/bin/env bash +# Simple setup script for nim echo server interop testing + +set -euo pipefail + +# Colors +GREEN='\033[0;32m' +RED='\033[0;31m' +YELLOW='\033[1;33m' +NC='\033[0m' + +log_info() { echo -e "${GREEN}[INFO]${NC} $1"; } +log_warn() { echo -e "${YELLOW}[WARN]${NC} $1"; } +log_error() { echo -e "${RED}[ERROR]${NC} $1"; } + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +PROJECT_ROOT="${SCRIPT_DIR}/.." +NIM_LIBP2P_DIR="${PROJECT_ROOT}/nim-libp2p" + +# Check prerequisites +check_nim() { + if ! command -v nim &> /dev/null; then + log_error "Nim not found. Install with: curl -sSf https://nim-lang.org/choosenim/init.sh | sh" + exit 1 + fi + if ! command -v nimble &> /dev/null; then + log_error "Nimble not found. Please install Nim properly." + exit 1 + fi +} + +# Setup nim-libp2p dependency +setup_nim_libp2p() { + log_info "Setting up nim-libp2p dependency..." + + if [ ! -d "${NIM_LIBP2P_DIR}" ]; then + log_info "Cloning nim-libp2p..." + git clone https://github.com/status-im/nim-libp2p.git "${NIM_LIBP2P_DIR}" + fi + + cd "${NIM_LIBP2P_DIR}" + log_info "Installing nim-libp2p dependencies..." + nimble install -y --depsOnly +} + +# Build nim echo server +build_echo_server() { + log_info "Building nim echo server..." + + cd "${PROJECT_ROOT}" + + # Create nimble file if it doesn't exist + cat > nim_echo_test.nimble << 'EOF' +# Package +version = "0.1.0" +author = "py-libp2p interop" +description = "nim echo server for interop testing" +license = "MIT" + +# Dependencies +requires "nim >= 1.6.0" +requires "libp2p" +requires "chronos" +requires "stew" + +# Binary +bin = @["nim_echo_server"] +EOF + + # Build the server + log_info "Compiling nim echo server..." + nim c -d:release -d:chronicles_log_level=INFO -d:libp2p_quic_support --opt:speed --gc:orc -o:nim_echo_server nim_echo_server.nim + + if [ -f "nim_echo_server" ]; then + log_info "✅ nim_echo_server built successfully" + else + log_error "❌ Failed to build nim_echo_server" + exit 1 + fi +} + +main() { + log_info "Setting up nim echo server for interop testing..." + + # Create logs directory + mkdir -p "${PROJECT_ROOT}/logs" + + # Clean up any existing processes + pkill -f "nim_echo_server" || true + + check_nim + setup_nim_libp2p + build_echo_server + + log_info "🎉 Setup complete! You can now run: python -m pytest test_echo_interop.py -v" +} + +main "$@" diff --git a/tests/interop/nim_libp2p/test_echo_interop.py b/tests/interop/nim_libp2p/test_echo_interop.py new file mode 100644 index 00000000..598a01d0 --- /dev/null +++ b/tests/interop/nim_libp2p/test_echo_interop.py @@ -0,0 +1,241 @@ +#!/usr/bin/env python3 +""" +Simple echo protocol interop test between py-libp2p and nim-libp2p. + +Tests that py-libp2p QUIC clients can communicate with nim-libp2p echo servers. +""" + +import logging +from pathlib import Path +import subprocess +from subprocess import Popen +import time + +import pytest +import multiaddr +import trio + +from libp2p import new_host +from libp2p.crypto.secp256k1 import create_new_key_pair +from libp2p.custom_types import TProtocol +from libp2p.peer.peerinfo import info_from_p2p_addr +from libp2p.transport.quic.config import QUICTransportConfig +from libp2p.utils.varint import encode_varint_prefixed, read_varint_prefixed_bytes + +# Configuration +PROTOCOL_ID = TProtocol("/echo/1.0.0") +TEST_TIMEOUT = 15.0 # Reduced timeout +SERVER_START_TIMEOUT = 10.0 + +# Setup logging +logging.basicConfig(level=logging.INFO) +logger = logging.getLogger(__name__) + + +class NimEchoServer: + """Simple nim echo server manager.""" + + def __init__(self, binary_path: Path): + self.binary_path = binary_path + self.process: None | Popen = None + self.peer_id = None + self.listen_addr = None + + async def start(self): + """Start nim echo server and get connection info.""" + logger.info(f"Starting nim echo server: {self.binary_path}") + + self.process: Popen[str] = subprocess.Popen( + [str(self.binary_path)], + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + text=True, + bufsize=1, + ) + + if self.process is None: + return None, None + + # Parse output for connection info + start_time = time.time() + while ( + self.process is not None and time.time() - start_time < SERVER_START_TIMEOUT + ): + if self.process.poll() is not None: + IOout = self.process.stdout + if IOout: + output = IOout.read() + raise RuntimeError(f"Server exited early: {output}") + + IOin = self.process.stdout + if IOin: + line = IOin.readline().strip() + if not line: + continue + + logger.info(f"Server: {line}") + + if line.startswith("Peer ID:"): + self.peer_id = line.split(":", 1)[1].strip() + + elif "/quic-v1/p2p/" in line and self.peer_id: + if line.strip().startswith("/"): + self.listen_addr = line.strip() + logger.info(f"Server ready: {self.listen_addr}") + return self.peer_id, self.listen_addr + + await self.stop() + raise TimeoutError(f"Server failed to start within {SERVER_START_TIMEOUT}s") + + async def stop(self): + """Stop the server.""" + if self.process: + logger.info("Stopping nim echo server...") + try: + self.process.terminate() + self.process.wait(timeout=5) + except subprocess.TimeoutExpired: + self.process.kill() + self.process.wait() + self.process = None + + +async def run_echo_test(server_addr: str, messages: list[str]): + """Test echo protocol against nim server with proper timeout handling.""" + # Create py-libp2p QUIC client with shorter timeouts + quic_config = QUICTransportConfig( + idle_timeout=10.0, + max_concurrent_streams=10, + connection_timeout=5.0, + enable_draft29=False, + ) + + host = new_host( + key_pair=create_new_key_pair(), + transport_opt={"quic_config": quic_config}, + ) + + listen_addr = multiaddr.Multiaddr("/ip4/0.0.0.0/udp/0/quic-v1") + responses = [] + + try: + async with host.run(listen_addrs=[listen_addr]): + logger.info(f"Connecting to nim server: {server_addr}") + + # Connect to nim server + maddr = multiaddr.Multiaddr(server_addr) + info = info_from_p2p_addr(maddr) + await host.connect(info) + + # Create stream + stream = await host.new_stream(info.peer_id, [PROTOCOL_ID]) + logger.info("Stream created") + + # Test each message + for i, message in enumerate(messages, 1): + logger.info(f"Testing message {i}: {message}") + + # Send with varint length prefix + data = message.encode("utf-8") + prefixed_data = encode_varint_prefixed(data) + await stream.write(prefixed_data) + + # Read response + response_data = await read_varint_prefixed_bytes(stream) + response = response_data.decode("utf-8") + + logger.info(f"Got echo: {response}") + responses.append(response) + + # Verify echo + assert message == response, ( + f"Echo failed: sent {message!r}, got {response!r}" + ) + + await stream.close() + logger.info("✅ All messages echoed correctly") + + finally: + await host.close() + + return responses + + +@pytest.fixture +def nim_echo_binary(): + """Path to nim echo server binary.""" + current_dir = Path(__file__).parent + binary_path = current_dir / "nim_echo_server" + + if not binary_path.exists(): + pytest.skip( + f"Nim echo server not found at {binary_path}. Run setup script first." + ) + + return binary_path + + +@pytest.fixture +async def nim_server(nim_echo_binary): + """Start and stop nim echo server for tests.""" + server = NimEchoServer(nim_echo_binary) + + try: + peer_id, listen_addr = await server.start() + yield server, peer_id, listen_addr + finally: + await server.stop() + + +@pytest.mark.trio +async def test_basic_echo_interop(nim_server): + """Test basic echo functionality between py-libp2p and nim-libp2p.""" + server, peer_id, listen_addr = nim_server + + test_messages = [ + "Hello from py-libp2p!", + "QUIC transport working", + "Echo test successful!", + "Unicode: Ñoël, 测试, Ψυχή", + ] + + logger.info(f"Testing against nim server: {peer_id}") + + # Run test with timeout + with trio.move_on_after(TEST_TIMEOUT - 2): # Leave 2s buffer for cleanup + responses = await run_echo_test(listen_addr, test_messages) + + # Verify all messages echoed correctly + assert len(responses) == len(test_messages) + for sent, received in zip(test_messages, responses): + assert sent == received + + logger.info("✅ Basic echo interop test passed!") + + +@pytest.mark.trio +async def test_large_message_echo(nim_server): + """Test echo with larger messages.""" + server, peer_id, listen_addr = nim_server + + large_messages = [ + "x" * 1024, # 1KB + "y" * 10000, + ] + + logger.info("Testing large message echo...") + + # Run test with timeout + with trio.move_on_after(TEST_TIMEOUT - 2): # Leave 2s buffer for cleanup + responses = await run_echo_test(listen_addr, large_messages) + + assert len(responses) == len(large_messages) + for sent, received in zip(large_messages, responses): + assert sent == received + + logger.info("✅ Large message echo test passed!") + + +if __name__ == "__main__": + # Run tests directly + pytest.main([__file__, "-v", "--tb=short"])