Interoperability Testing

This example provides a standalone console script for testing libp2p ping functionality without Docker or Redis dependencies. It supports both listener and dialer roles and measures ping RTT and handshake times.

Usage

Run as listener (waits for connection):

$ python -m examples.interop.local_ping_test --listener --port 8000
Listener ready, listening on:
  /ip4/127.0.0.1/tcp/8000/p2p/Qm...
Waiting for dialer to connect...

Run as dialer (connects to listener):

$ python -m examples.interop.local_ping_test --dialer --destination /ip4/127.0.0.1/tcp/8000/p2p/Qm...
Connecting to listener at: /ip4/127.0.0.1/tcp/8000/p2p/Qm...
Connected successfully
Performing ping test
{"handshakePlusOneRTTMillis": 15.2, "pingRTTMilllis": 2.1}

Options

  • --listener: Run as listener (wait for connection)

  • --dialer: Run as dialer (connect to listener)

  • --destination ADDR: Destination multiaddr (required for dialer)

  • --port PORT: Port number (0 = auto-select)

  • --transport {tcp,ws,quic-v1}: Transport protocol (default: tcp)

  • --muxer {mplex,yamux}: Stream muxer (default: mplex)

  • --security {noise,plaintext}: Security protocol (default: noise)

  • --test-timeout SECONDS: Test timeout in seconds (default: 180)

  • --debug: Enable debug logging

The full source code for this example is below:

  1#!/usr/bin/env python3
  2"""
  3Local libp2p ping test implementation.
  4
  5This is a standalone console script version of the transport-interop ping test
  6that runs without Docker or Redis dependencies. It supports both listener and
  7dialer roles and measures ping RTT and handshake times.
  8
  9Usage:
 10    # Run as listener (waits for connection)
 11    python local_ping_test.py --listener --port 8000
 12
 13    # Run as dialer (connects to listener)
 14    python local_ping_test.py --dialer --destination /ip4/127.0.0.1/tcp/8000/p2p/Qm...
 15"""
 16
 17import argparse
 18import json
 19import logging
 20import sys
 21import time
 22
 23import multiaddr
 24import trio
 25
 26from libp2p import create_mplex_muxer_option, create_yamux_muxer_option, new_host
 27from libp2p.crypto.ed25519 import create_new_key_pair
 28from libp2p.crypto.x25519 import create_new_key_pair as create_new_x25519_key_pair
 29from libp2p.custom_types import TProtocol
 30from libp2p.network.stream.net_stream import INetStream
 31from libp2p.peer.peerinfo import info_from_p2p_addr
 32from libp2p.security.insecure.transport import PLAINTEXT_PROTOCOL_ID, InsecureTransport
 33from libp2p.security.noise.transport import (
 34    PROTOCOL_ID as NOISE_PROTOCOL_ID,
 35    Transport as NoiseTransport,
 36)
 37from libp2p.utils.address_validation import get_available_interfaces
 38from libp2p.utils.multiaddr_utils import extract_ip_from_multiaddr
 39
 40PING_PROTOCOL_ID = TProtocol("/ipfs/ping/1.0.0")
 41PING_LENGTH = 32
 42MAX_TEST_TIMEOUT = 300
 43DEFAULT_RESP_TIMEOUT = 30
 44
 45logger = logging.getLogger("libp2p.ping_test")
 46
 47
 48def configure_logging(debug: bool = False):
 49    """Configure logging based on debug flag."""
 50    # Set up basic handler on root logger if not already configured
 51    root_logger = logging.getLogger()
 52    if not root_logger.handlers:
 53        handler = logging.StreamHandler(sys.stderr)
 54        formatter = logging.Formatter(
 55            "%(asctime)s [%(levelname)s] [%(name)s] %(message)s"
 56        )
 57        handler.setFormatter(formatter)
 58        root_logger.addHandler(handler)
 59        root_logger.setLevel(logging.DEBUG if debug else logging.INFO)
 60
 61    if debug:
 62        # Set DEBUG level for all relevant loggers (they will propagate to root)
 63        logger_names = [
 64            "libp2p.ping_test",
 65            "libp2p",
 66            "libp2p.transport",
 67            "libp2p.transport.quic",
 68            "libp2p.transport.quic.connection",
 69            "libp2p.transport.quic.listener",
 70            "libp2p.network",
 71            "libp2p.network.connection",
 72            "libp2p.network.connection.swarm_connection",
 73            "libp2p.protocol_muxer",
 74            "libp2p.protocol_muxer.multiselect",
 75            "libp2p.host",
 76            "libp2p.host.basic_host",
 77        ]
 78        for logger_name in logger_names:
 79            logger = logging.getLogger(logger_name)
 80            logger.setLevel(logging.DEBUG)
 81            # Ensure propagation is enabled (default, but be explicit)
 82            logger.propagate = True
 83        print("Debug logging enabled", file=sys.stderr)
 84    else:
 85        root_logger.setLevel(logging.INFO)
 86        logging.getLogger("libp2p.ping_test").setLevel(logging.INFO)
 87        # Suppress verbose logs from dependencies
 88        for logger_name in [
 89            "multiaddr",
 90            "multiaddr.transforms",
 91            "multiaddr.codecs",
 92            "libp2p",
 93            "libp2p.transport",
 94        ]:
 95            logging.getLogger(logger_name).setLevel(logging.WARNING)
 96
 97
 98class PingTest:
 99    def __init__(
100        self,
101        transport: str = "tcp",
102        muxer: str = "mplex",
103        security: str = "noise",
104        port: int = 0,
105        destination: str | None = None,
106        test_timeout: int = 180,
107        debug: bool = False,
108    ):
109        """Initialize ping test with configuration."""
110        self.transport = transport
111        self.muxer = muxer
112        self.security = security
113        self.port = port
114        self.destination = destination
115        self.is_dialer = destination is not None
116
117        raw_timeout = int(test_timeout)
118        self.test_timeout_seconds = min(raw_timeout, MAX_TEST_TIMEOUT)
119        self.resp_timeout = max(
120            DEFAULT_RESP_TIMEOUT, int(self.test_timeout_seconds * 0.6)
121        )
122        self.debug = debug
123
124        self.host = None
125        self.ping_received = False
126
127    def validate_configuration(self) -> None:
128        """Validate configuration parameters."""
129        valid_transports = ["tcp", "ws", "quic-v1"]
130        valid_security = ["noise", "plaintext"]
131        valid_muxers = ["mplex", "yamux"]
132
133        if self.transport not in valid_transports:
134            raise ValueError(
135                f"Unsupported transport: {self.transport}. "
136                f"Supported: {valid_transports}"
137            )
138        if self.security not in valid_security:
139            raise ValueError(
140                f"Unsupported security: {self.security}. Supported: {valid_security}"
141            )
142        if self.muxer not in valid_muxers:
143            raise ValueError(
144                f"Unsupported muxer: {self.muxer}. Supported: {valid_muxers}"
145            )
146
147    def create_security_options(self):
148        """Create security options based on configuration."""
149        key_pair = create_new_key_pair()
150
151        if self.security == "noise":
152            noise_key_pair = create_new_x25519_key_pair()
153            transport = NoiseTransport(
154                libp2p_keypair=key_pair,
155                noise_privkey=noise_key_pair.private_key,
156                early_data=None,
157            )
158            return {NOISE_PROTOCOL_ID: transport}, key_pair
159        elif self.security == "plaintext":
160            transport = InsecureTransport(
161                local_key_pair=key_pair,
162                secure_bytes_provider=None,
163                peerstore=None,
164            )
165            return {PLAINTEXT_PROTOCOL_ID: transport}, key_pair
166        else:
167            raise ValueError(f"Unsupported security: {self.security}")
168
169    def create_muxer_options(self):
170        """Create muxer options based on configuration."""
171        if self.muxer == "yamux":
172            return create_yamux_muxer_option()
173        elif self.muxer == "mplex":
174            return create_mplex_muxer_option()
175        else:
176            raise ValueError(f"Unsupported muxer: {self.muxer}")
177
178    def _get_ip_value(self, addr: multiaddr.Multiaddr) -> str | None:
179        """Extract IP value from multiaddr (IPv4 or IPv6)."""
180        return extract_ip_from_multiaddr(addr)
181
182    def _get_protocol_names(self, addr) -> list:
183        """Get protocol names from multiaddr."""
184        return [p.name for p in addr.protocols()]
185
186    def _build_quic_addr(self, ip_value: str, port: int) -> multiaddr.Multiaddr:
187        """Build QUIC address from IP and port."""
188        is_ipv6 = ":" in ip_value
189        if is_ipv6:
190            base = multiaddr.Multiaddr(f"/ip6/{ip_value}/udp/{port}")
191        else:
192            base = multiaddr.Multiaddr(f"/ip4/{ip_value}/udp/{port}")
193        return base.encapsulate(multiaddr.Multiaddr("/quic-v1"))
194
195    def create_listen_addresses(self, port: int = 0) -> list:
196        """Create listen addresses based on transport type."""
197        base_addrs = get_available_interfaces(port, protocol="tcp")
198
199        if self.transport == "quic-v1":
200            # Convert TCP addresses to UDP/QUIC addresses
201            quic_addrs = []
202            for addr in base_addrs:
203                try:
204                    ip_value = self._get_ip_value(addr)
205                    tcp_port = addr.value_for_protocol("tcp") or port
206                    if ip_value:
207                        quic_addr = self._build_quic_addr(ip_value, tcp_port)
208                        # Preserve /p2p component if present
209                        if "p2p" in self._get_protocol_names(addr):
210                            p2p_value = addr.value_for_protocol("p2p")
211                            if p2p_value:
212                                quic_addr = quic_addr.encapsulate(
213                                    multiaddr.Multiaddr(f"/p2p/{p2p_value}")
214                                )
215                        quic_addrs.append(quic_addr)
216                except Exception as e:
217                    print(
218                        f"Error converting address {addr} to QUIC: {e}",
219                        file=sys.stderr,
220                    )
221            if quic_addrs:
222                return quic_addrs
223            return [self._build_quic_addr("127.0.0.1", port)]
224
225        elif self.transport == "ws":
226            # Add /ws protocol to TCP addresses
227            ws_addrs = []
228            for addr in base_addrs:
229                try:
230                    protocols = self._get_protocol_names(addr)
231                    if "ws" in protocols or "wss" in protocols:
232                        ws_addrs.append(addr)
233                    else:
234                        # Preserve /p2p component
235                        p2p_value = None
236                        if "p2p" in protocols:
237                            p2p_value = addr.value_for_protocol("p2p")
238                            if p2p_value:
239                                addr = addr.decapsulate(
240                                    multiaddr.Multiaddr(f"/p2p/{p2p_value}")
241                                )
242                        ws_addr = addr.encapsulate(multiaddr.Multiaddr("/ws"))
243                        if p2p_value:
244                            ws_addr = ws_addr.encapsulate(
245                                multiaddr.Multiaddr(f"/p2p/{p2p_value}")
246                            )
247                        ws_addrs.append(ws_addr)
248                except Exception as e:
249                    print(
250                        f"Error converting address {addr} to WebSocket: {e}",
251                        file=sys.stderr,
252                    )
253            if ws_addrs:
254                return ws_addrs
255            return [multiaddr.Multiaddr(f"/ip4/127.0.0.1/tcp/{port}/ws")]
256
257        return base_addrs
258
259    def _get_peer_id(self, stream: INetStream) -> str:
260        """Get peer ID from stream, suppressing warnings."""
261        import warnings
262
263        with warnings.catch_warnings():
264            warnings.simplefilter("ignore")
265            try:
266                return str(stream.muxed_conn.peer_id)  # type: ignore
267            except (AttributeError, Exception):
268                return "unknown"
269
270    async def handle_ping(self, stream: INetStream) -> None:
271        """Handle incoming ping requests."""
272        try:
273            payload = await stream.read(PING_LENGTH)
274            if payload is not None:
275                peer_id = self._get_peer_id(stream)
276                print(f"received ping from {peer_id}", file=sys.stderr)
277                await stream.write(payload)
278                print(f"responded with pong to {peer_id}", file=sys.stderr)
279                self.ping_received = True
280        except Exception as e:
281            import traceback
282
283            error_msg = (
284                str(e) if e else "Unknown error (exception object is None or empty)"
285            )
286            error_type = type(e).__name__ if e else "UnknownException"
287            print(f"Error in ping handler: {error_type}: {error_msg}", file=sys.stderr)
288            if self.debug:
289                traceback.print_exc(file=sys.stderr)
290            try:
291                await stream.reset()
292            except Exception:
293                pass
294
295    def log_protocols(self) -> None:
296        """Log registered protocols for debugging."""
297        try:
298            protocols = self.host.get_mux().get_protocols()  # type: ignore
299            protocols_str = [str(p) for p in protocols if p is not None]
300            print(f"Registered protocols: {protocols_str}", file=sys.stderr)
301        except Exception as e:
302            print(f"Error getting protocols: {e}", file=sys.stderr)
303
304    async def send_ping(self, stream: INetStream) -> float:
305        """Send ping and measure RTT."""
306        try:
307            payload = b"\x01" * PING_LENGTH
308            peer_id = self._get_peer_id(stream)
309            print(f"sending ping to {peer_id}", file=sys.stderr)
310
311            ping_start = time.time()
312            await stream.write(payload)
313
314            with trio.fail_after(self.resp_timeout):
315                response = await stream.read(PING_LENGTH)
316                ping_end = time.time()
317
318                if response == payload:
319                    print(f"received pong from {peer_id}", file=sys.stderr)
320                    return (ping_end - ping_start) * 1000
321                else:
322                    raise Exception("Invalid ping response")
323        except Exception as e:
324            print(f"error occurred: {e}", file=sys.stderr)
325            raise
326
327    def _filter_addresses_by_transport(self, addresses: list) -> list:
328        """Filter addresses to match current transport type."""
329        filtered = []
330        for addr in addresses:
331            protocols = self._get_protocol_names(addr)
332            if self.transport == "ws" and ("ws" in protocols or "wss" in protocols):
333                filtered.append(addr)
334            elif self.transport == "quic-v1" and "quic-v1" in protocols:
335                filtered.append(addr)
336            elif self.transport == "tcp" and not any(
337                p in protocols for p in ["ws", "wss", "quic-v1"]
338            ):
339                filtered.append(addr)
340        return filtered if filtered else addresses
341
342    def _get_publishable_address(self, addresses: list) -> str:
343        """Get the best address to publish, preferring non-loopback."""
344        filtered = self._filter_addresses_by_transport(addresses)
345        if not filtered:
346            print(
347                f"Warning: No addresses matched transport {self.transport}, "
348                f"using all addresses",
349                file=sys.stderr,
350            )
351            filtered = addresses
352
353        # Prefer non-loopback addresses
354        for addr in filtered:
355            ip_value = self._get_ip_value(addr)
356            if ip_value and ip_value not in ["127.0.0.1", "0.0.0.0", "::1", "::"]:
357                return str(addr)
358
359        # Fallback: use first address (for localhost testing)
360        return str(filtered[0])
361
362    async def run_listener(self) -> None:
363        """Run the listener role."""
364        self.validate_configuration()
365
366        # Create security and muxer options
367        sec_opt, key_pair = self.create_security_options()
368        muxer_opt = self.create_muxer_options()
369        listen_addrs = self.create_listen_addresses(self.port)
370
371        self.host = new_host(  # type: ignore
372            key_pair=key_pair,
373            sec_opt=sec_opt,
374            muxer_opt=muxer_opt,
375            listen_addrs=listen_addrs,
376            enable_quic=(self.transport == "quic-v1"),
377        )
378        self.host.set_stream_handler(PING_PROTOCOL_ID, self.handle_ping)  # type: ignore
379        self.log_protocols()
380
381        async with self.host.run(listen_addrs=listen_addrs):  # type: ignore
382            all_addrs = self.host.get_addrs()  # type: ignore
383            if not all_addrs:
384                raise RuntimeError("No listen addresses available")
385
386            actual_addr = self._get_publishable_address(all_addrs)
387            print("Listener ready, listening on:", file=sys.stderr)
388            for addr in all_addrs:
389                print(f"  {addr}", file=sys.stderr)
390            print("\nTo connect, use this address:", file=sys.stderr)
391            print(f"  {actual_addr}", file=sys.stderr)
392            print("Waiting for dialer to connect...", file=sys.stderr)
393
394            wait_timeout = min(self.test_timeout_seconds, MAX_TEST_TIMEOUT)
395            check_interval = 0.5
396            elapsed = 0
397
398            while elapsed < wait_timeout:
399                if self.ping_received:
400                    print(
401                        "Ping received and responded, listener exiting",
402                        file=sys.stderr,
403                    )
404                    return
405                await trio.sleep(float(check_interval))  # type: ignore
406                elapsed += check_interval
407
408            if not self.ping_received:
409                print(
410                    f"Timeout: No ping received within {wait_timeout} seconds",
411                    file=sys.stderr,
412                )
413            sys.exit(1)
414
415    def _debug_connection_state(self, network, peer_id) -> None:
416        """Debug connection state (only if debug logging enabled)."""
417        if not self.debug:
418            return
419        try:
420            if hasattr(network, "get_connections_to_peer"):
421                connections = network.get_connections_to_peer(peer_id)
422            elif hasattr(network, "connections"):
423                connections = [
424                    c
425                    for c in network.connections.values()
426                    if c.get_peer_id() == peer_id
427                ]
428            else:
429                connections = []
430            print(
431                f"[DEBUG] Found {len(connections)} connections to peer {peer_id}",
432                file=sys.stderr,
433            )
434            for i, conn in enumerate(connections):
435                muxed = hasattr(conn, "get_muxer")
436                print(
437                    f"[DEBUG] Connection {i}: {type(conn).__name__}, muxed: {muxed}",
438                    file=sys.stderr,
439                )
440                if muxed:
441                    try:
442                        muxer_type = type(conn.get_muxer()).__name__
443                        print(
444                            f"[DEBUG] Connection {i} muxer: {muxer_type}",
445                            file=sys.stderr,
446                        )
447                    except Exception as e:
448                        print(
449                            f"[DEBUG] Connection {i} muxer error: {e}",
450                            file=sys.stderr,
451                        )
452        except Exception as e:
453            print(f"[DEBUG] Error checking connections: {e}", file=sys.stderr)
454
455    async def _create_stream_with_retry(self, peer_id) -> INetStream:
456        """Create ping stream with retry mechanism for connection readiness."""
457        max_retries = 3
458        retry_delay = 0.5
459
460        print("Creating ping stream", file=sys.stderr)
461        if self.debug:
462            print(
463                f"[DEBUG] About to create stream for protocol {PING_PROTOCOL_ID}",
464                file=sys.stderr,
465            )
466
467        for attempt in range(max_retries):
468            try:
469                stream = await self.host.new_stream(peer_id, [PING_PROTOCOL_ID])  # type: ignore
470                print("Ping stream created successfully", file=sys.stderr)
471                return stream
472            except Exception as e:
473                if attempt < max_retries - 1:
474                    if self.debug:
475                        print(
476                            f"[DEBUG] Stream creation attempt {attempt + 1} "
477                            f"failed: {e}, retrying...",
478                            file=sys.stderr,
479                        )
480                    await trio.sleep(retry_delay)
481                else:
482                    if self.debug:
483                        print(
484                            f"[DEBUG] Stream creation failed after {max_retries} "
485                            f"attempts: {e}",
486                            file=sys.stderr,
487                        )
488                    raise
489        raise RuntimeError("Failed to create ping stream after retries")
490
491    async def run_dialer(self) -> None:
492        """Run the dialer role."""
493        print("Running as dialer", file=sys.stderr)
494
495        try:
496            self.validate_configuration()
497
498            if not self.destination:
499                raise ValueError("Destination address is required for dialer mode")
500
501            listener_addr = self.destination
502            print(f"Connecting to listener at: {listener_addr}", file=sys.stderr)
503
504            # Create security and muxer options
505            sec_opt, key_pair = self.create_security_options()
506            muxer_opt = self.create_muxer_options()
507
508            # WS dialer workaround: need listen addresses to register transport
509            # (py-libp2p limitation)
510            dialer_listen_addrs = (
511                self.create_listen_addresses(self.port)
512                if self.transport == "ws"
513                else None
514            )
515            if dialer_listen_addrs:
516                addrs_str = [str(addr) for addr in dialer_listen_addrs]
517                print(
518                    f"Registering WS transport for dialer with addresses: {addrs_str}",
519                    file=sys.stderr,
520                )
521
522            host_kwargs = {
523                "key_pair": key_pair,
524                "sec_opt": sec_opt,
525                "muxer_opt": muxer_opt,
526                "enable_quic": (self.transport == "quic-v1"),
527            }
528            if dialer_listen_addrs:
529                host_kwargs["listen_addrs"] = dialer_listen_addrs  # type: ignore
530
531            self.host = new_host(**host_kwargs)  # type: ignore
532
533            async with self.host.run(listen_addrs=dialer_listen_addrs or []):  # type: ignore
534                handshake_start = time.time()
535                maddr = multiaddr.Multiaddr(listener_addr)
536                info = info_from_p2p_addr(maddr)
537
538                print(f"Connecting to {listener_addr}", file=sys.stderr)
539                if self.debug:
540                    print(
541                        f"[DEBUG] About to call host.connect() for {info.peer_id}",
542                        file=sys.stderr,
543                    )
544                await self.host.connect(info)  # type: ignore
545                print("Connected successfully", file=sys.stderr)
546                if self.debug:
547                    print(
548                        "[DEBUG] host.connect() completed, checking connection state",
549                        file=sys.stderr,
550                    )
551
552                self._debug_connection_state(self.host.get_network(), info.peer_id)  # type: ignore
553
554                # Brief delay to ensure connection is fully ready for stream creation
555                await trio.sleep(0.1)
556
557                # Retry stream creation to handle cases where connection needs more time
558                stream = await self._create_stream_with_retry(info.peer_id)
559
560                print("Performing ping test", file=sys.stderr)
561                ping_rtt = await self.send_ping(stream)
562                print(f"Ping test completed, RTT: {ping_rtt}ms", file=sys.stderr)
563
564                handshake_plus_one_rtt = (time.time() - handshake_start) * 1000
565                result = {
566                    "handshakePlusOneRTTMillis": handshake_plus_one_rtt,
567                    "pingRTTMilllis": ping_rtt,
568                }
569                print(f"Outputting results: {result}", file=sys.stderr)
570                print(json.dumps(result))
571
572                await stream.close()
573                print("Stream closed successfully", file=sys.stderr)
574
575        except Exception as e:
576            print(f"Dialer error: {e}", file=sys.stderr)
577            if self.debug:
578                import traceback
579
580                traceback.print_exc(file=sys.stderr)
581            sys.exit(1)
582
583    async def run(self) -> None:
584        """Main run method."""
585        try:
586            if self.is_dialer:
587                await self.run_dialer()
588            else:
589                await self.run_listener()
590
591        except Exception as e:
592            print(f"Error: {e}", file=sys.stderr)
593            if self.debug:
594                import traceback
595
596                traceback.print_exc(file=sys.stderr)
597            sys.exit(1)
598
599
600def main():
601    """Main entry point."""
602    parser = argparse.ArgumentParser(
603        description="Local libp2p ping test - standalone console script",
604        formatter_class=argparse.RawDescriptionHelpFormatter,
605        epilog="""
606Examples:
607  # Run as listener
608  python local_ping_test.py --listener --port 8000
609
610  # Run as dialer (connect to listener)
611  python local_ping_test.py --dialer --destination /ip4/127.0.0.1/tcp/8000/p2p/Qm...
612
613  # With custom transport/muxer/security
614  python local_ping_test.py --listener --transport ws --muxer yamux --security noise
615        """,
616    )
617
618    # Mode selection
619    mode_group = parser.add_mutually_exclusive_group(required=True)
620    mode_group.add_argument(
621        "--listener",
622        action="store_true",
623        help="Run as listener (wait for connection)",
624    )
625    mode_group.add_argument(
626        "--dialer", action="store_true", help="Run as dialer (connect to listener)"
627    )
628
629    # Connection options
630    parser.add_argument(
631        "-d",
632        "--destination",
633        type=str,
634        help="Destination multiaddr (required for dialer)",
635    )
636    parser.add_argument(
637        "-p", "--port", type=int, default=0, help="Port number (0 = auto-select)"
638    )
639
640    # Configuration options
641    parser.add_argument(
642        "--transport",
643        choices=["tcp", "ws", "quic-v1"],
644        default="tcp",
645        help="Transport protocol (default: tcp)",
646    )
647    parser.add_argument(
648        "--muxer",
649        choices=["mplex", "yamux"],
650        default="mplex",
651        help="Stream muxer (default: mplex)",
652    )
653    parser.add_argument(
654        "--security",
655        choices=["noise", "plaintext"],
656        default="noise",
657        help="Security protocol (default: noise)",
658    )
659
660    # Test options
661    parser.add_argument(
662        "--test-timeout",
663        type=int,
664        default=180,
665        help="Test timeout in seconds (default: 180)",
666    )
667    parser.add_argument("--debug", action="store_true", help="Enable debug logging")
668
669    args = parser.parse_args()
670
671    # Validate arguments
672    if args.dialer and not args.destination:
673        parser.error("--destination is required when running as dialer")
674
675    configure_logging(debug=args.debug)
676
677    ping_test = PingTest(
678        transport=args.transport,
679        muxer=args.muxer,
680        security=args.security,
681        port=args.port,
682        destination=args.destination,
683        test_timeout=args.test_timeout,
684        debug=args.debug,
685    )
686
687    try:
688        trio.run(ping_test.run)
689    except KeyboardInterrupt:
690        print("\nInterrupted by user", file=sys.stderr)
691        sys.exit(0)
692
693
694if __name__ == "__main__":
695    main()