Repository: libp2p/py-libp2p
Pull request #622: TODO: Handle `ls` command in `multiselect.py`
Author: login: lla-dane
Created: 2025-05-22T07:41:52Z | Merged: 2025-05-28T02:18:37Z

Added utility functions for handling `ls` command in `libp2p/protocol_muxer/multiselect.py` here: [TODO: handle ls command](https://github.com/libp2p/py-libp2p/blob/18c6f529c688e9b02a8323e9b9d7a1daa9a60052/libp2p/protocol_muxer/multiselect.py#L62C1-L65C9), to send the list of all supported protocols by the listener to dialer. 

@seetadev @pacrob @acul71 


Diff:
diff --git a/libp2p/host/basic_host.py b/libp2p/host/basic_host.py
index 240143217..60b31fe01 100644
--- a/libp2p/host/basic_host.py
+++ b/libp2p/host/basic_host.py
@@ -195,6 +195,29 @@ async def new_stream(
         net_stream.set_protocol(selected_protocol)
         return net_stream
 
+    async def send_command(self, peer_id: ID, command: str) -> list[str]:
+        """
+        Send a multistream-select command to the specified peer and return
+        the response.
+
+        :param peer_id: peer_id that host is connecting
+        :param command: supported multistream-select command (e.g., "ls)
+        :raise StreamFailure: If the stream cannot be opened or negotiation fails
+        :return: list of strings representing the response from peer.
+        """
+        new_stream = await self._network.new_stream(peer_id)
+
+        try:
+            response = await self.multiselect_client.query_multistream_command(
+                MultiselectCommunicator(new_stream), command
+            )
+        except MultiselectClientError as error:
+            logger.debug("fail to open a stream to peer %s, error=%s", peer_id, error)
+            await new_stream.reset()
+            raise StreamFailure(f"failed to open a stream to peer {peer_id}") from error
+
+        return response
+
     async def connect(self, peer_info: PeerInfo) -> None:
         """
         Ensure there is a connection between this host and the peer
diff --git a/libp2p/protocol_muxer/multiselect.py b/libp2p/protocol_muxer/multiselect.py
index ed9bccca2..b7ee20045 100644
--- a/libp2p/protocol_muxer/multiselect.py
+++ b/libp2p/protocol_muxer/multiselect.py
@@ -60,8 +60,14 @@ async def negotiate(
                 raise MultiselectError() from error
 
             if command == "ls":
-                # TODO: handle ls command
-                pass
+                supported_protocols = list(self.handlers.keys())
+                response = "\n".join(supported_protocols) + "\n"
+
+                try:
+                    await communicator.write(response)
+                except MultiselectCommunicatorError as error:
+                    raise MultiselectError() from error
+
             else:
                 protocol = TProtocol(command)
                 if protocol in self.handlers:
diff --git a/libp2p/protocol_muxer/multiselect_client.py b/libp2p/protocol_muxer/multiselect_client.py
index abbab54cb..8d8c02a1e 100644
--- a/libp2p/protocol_muxer/multiselect_client.py
+++ b/libp2p/protocol_muxer/multiselect_client.py
@@ -70,6 +70,36 @@ async def select_one_of(
 
         raise MultiselectClientError("protocols not supported")
 
+    async def query_multistream_command(
+        self, communicator: IMultiselectCommunicator, command: str
+    ) -> list[str]:
+        """
+        Send a multistream-select command over the given communicator and return
+        parsed response.
+
+        :param communicator: communicator to use to communicate with counterparty
+        :param command: supported multistream-select command(e.g., ls)
+        :raise MultiselectClientError: If the communicator fails to process data.
+        :return: list of strings representing the response from peer.
+        """
+        await self.handshake(communicator)
+
+        if command == "ls":
+            try:
+                await communicator.write("ls")
+            except MultiselectCommunicatorError as error:
+                raise MultiselectClientError() from error
+        else:
+            raise ValueError("Command not supported")
+
+        try:
+            response = await communicator.read()
+            response_list = response.strip().splitlines()
+        except MultiselectCommunicatorError as error:
+            raise MultiselectClientError() from error
+
+        return response_list
+
     async def try_select(
         self, communicator: IMultiselectCommunicator, protocol: TProtocol
     ) -> TProtocol:
diff --git a/newsfragments/622.feature.rst b/newsfragments/622.feature.rst
new file mode 100644
index 000000000..c9a83bb5e
--- /dev/null
+++ b/newsfragments/622.feature.rst
@@ -0,0 +1,2 @@
+Feature: Support for sending `ls` command over `multistream-select` to list supported protocols from remote peer.
+This allows inspecting which protocol handlers a peer supports at runtime.
diff --git a/tests/core/protocol_muxer/test_protocol_muxer.py b/tests/core/protocol_muxer/test_protocol_muxer.py
index 151dfd071..98f48533a 100644
--- a/tests/core/protocol_muxer/test_protocol_muxer.py
+++ b/tests/core/protocol_muxer/test_protocol_muxer.py
@@ -116,3 +116,35 @@ async def test_multiple_protocol_fails(security_protocol):
         await perform_simple_test(
             "", protocols_for_client, protocols_for_listener, security_protocol
         )
+
+
+@pytest.mark.trio
+async def test_multistream_command(security_protocol):
+    supported_protocols = [PROTOCOL_ECHO, PROTOCOL_FOO, PROTOCOL_POTATO, PROTOCOL_ROCK]
+
+    async with HostFactory.create_batch_and_listen(
+        2, security_protocol=security_protocol
+    ) as hosts:
+        listener, dialer = hosts[1], hosts[0]
+
+        for protocol in supported_protocols:
+            listener.set_stream_handler(
+                protocol, create_echo_stream_handler(ACK_PREFIX)
+            )
+
+        # Ensure dialer knows how to reach the listener
+        dialer.get_peerstore().add_addrs(listener.get_id(), listener.get_addrs(), 10)
+
+        # Dialer asks peer to list the supported protocols using `ls`
+        response = await dialer.send_command(listener.get_id(), "ls")
+
+        # We expect all supported protocols to show up
+        for protocol in supported_protocols:
+            assert protocol in response
+
+        assert "/does/not/exist" not in response
+        assert "/foo/bar/1.2.3" not in response
+
+        # Dialer asks for unspoorted command
+        with pytest.raises(ValueError, match="Command not supported"):
+            await dialer.send_command(listener.get_id(), "random")


Reviews:
user: login: pacrob
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-05-26T00:37:15Z
user: login: pacrob
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-05-26T00:41:53Z
user: login: lla-dane
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-26T13:43:46Z
user: login: lla-dane
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-26T17:00:58Z
user: login: pacrob
state: APPROVED
author_association: MEMBER
submitted_at: 2025-05-28T02:18:23Z

Review comments:
diff_hunk: @@ -70,6 +70,34 @@ async def select_one_of(
 
         raise MultiselectClientError("protocols not supported")
 
+    async def query_multistream_command(
+        self, communicator: IMultiselectCommunicator, command: str
+    ) -> list[str]:
+        """
+        Send a multistream-select command over the given communicator and return
+        parsed response.
+
+        :param communicator: communicator to use to communicate with counterparty
+        :param command: supporeted multistream-select command(e.g., ls)
path: libp2p/protocol_muxer/multiselect_client.py
user: login: pacrob
body: ```suggestion
        :param command: supported multistream-select command(e.g., "ls")
```
created_at: 2025-05-26T00:37:15Z
author_association: MEMBER
path: tests/core/protocol_muxer/test_protocol_muxer.py
user: login: pacrob
body: I think we could use a negative test too, something that shows what is expected when an unsupported command is sent.
created_at: 2025-05-26T00:41:53Z
author_association: MEMBER
path: tests/core/protocol_muxer/test_protocol_muxer.py
user: login: lla-dane
body: @pacrob: Added a negative case for unsupported commands, returning `ValueError("Unsupported commands)`. 

```py
 # Dialer asks for unspoorted command
  with pytest.raises(ValueError, match="Command not supported"):
      await dialer.send_command(listener.get_id(), "random")
```
created_at: 2025-05-26T13:43:45Z
author_association: CONTRIBUTOR
diff_hunk: @@ -70,6 +70,34 @@ async def select_one_of(
 
         raise MultiselectClientError("protocols not supported")
 
+    async def query_multistream_command(
+        self, communicator: IMultiselectCommunicator, command: str
+    ) -> list[str]:
+        """
+        Send a multistream-select command over the given communicator and return
+        parsed response.
+
+        :param communicator: communicator to use to communicate with counterparty
+        :param command: supporeted multistream-select command(e.g., ls)
path: libp2p/protocol_muxer/multiselect_client.py
user: login: lla-dane
body: Done
created_at: 2025-05-26T17:00:58Z
author_association: CONTRIBUTOR

PR comments:
user: login: lla-dane
created_at: 2025-05-22T07:42:33Z
body: Will add the tests, newfragment and docstrings shortly.
author_association: CONTRIBUTOR
user: login: acul71
created_at: 2025-05-22T10:41:44Z
body: > Added utility functions for handling `ls` command in `libp2p/protocol_muxer/multiselect.py`

Thanks. 
Can you write some more context in PR and explain what `ls` command is ?
author_association: CONTRIBUTOR
user: login: lla-dane
created_at: 2025-05-22T11:43:53Z
body: This PR adds support for sending the `ls` command to a peer after connection has been established. This feature could be used to query which protocols a peer supports on a given connection, when a peer wants to discover the list of protocols that the remote peer is willing to negotiate. 

Lets see how it can be used in the ping example:
1. Lets add this line: 
```
	print(await host.send_command(info.peer_id, "ls"))
``` 

in the [async def run()](https://github.com/libp2p/py-libp2p/blob/18c6f529c688e9b02a8323e9b9d7a1daa9a60052/examples/ping/ping.py#L57C1-L84C1) function in the `dialer` logic at `./examples/ping/ping.py` in the following way:

<details>
<summary>Code</summary>

```python
async def run(port: int, destination: str) -> None:
    localhost_ip = "127.0.0.1"
    listen_addr = multiaddr.Multiaddr(f"/ip4/0.0.0.0/tcp/{port}")
    host = new_host()

    async with host.run(listen_addrs=[listen_addr]), trio.open_nursery() as nursery:
        if not destination:
            host.set_stream_handler(PING_PROTOCOL_ID, handle_ping)

            print(
                "Run this from the same folder in another console:\n\n"
                f"ping-demo -p {int(port) + 1} "
                f"-d /ip4/{localhost_ip}/tcp/{port}/p2p/{host.get_id().pretty()}\n"
            )
            print("Waiting for incoming connection...")

        else:
            maddr = multiaddr.Multiaddr(destination)
            info = info_from_p2p_addr(maddr)
            await host.connect(info)

            # Query the connected peer for available protocols
            print(await host.send_command(info.peer_id, "ls"))
            
            stream = await host.new_stream(info.peer_id, [PING_PROTOCOL_ID])

            nursery.start_soon(send_ping, stream)

            return

        await trio.sleep_forever()
```

</details>

2. Now lets see how this works out in runtime:
![image](https://github.com/user-attachments/assets/9921cc53-2114-482a-84ed-34a7e4069ce9)

3. It is clear from the logs that when the `dialer` sends the `ls` command to the listener, the listener responds with `['/ipfs/id/1.0.0', '/ipfs/ping/1.0.0']`: list of supported protocols.

@acul71 
author_association: CONTRIBUTOR
user: login: lla-dane
created_at: 2025-05-22T12:19:25Z
body: Added a test for successfully executing `ls` command between two peers.
author_association: CONTRIBUTOR
user: login: acul71
created_at: 2025-05-22T20:18:25Z
body: @lla-dane 
`FAILED tests/core/network/test_notify.py::test_notify`
Because there are some race conditions sometimes it fails somtimes it does not
https://github.com/libp2p/py-libp2p/pull/614#issuecomment-2890383837

Don't have time now:
See if this is different

`test_notify.py`
```python
"""
Test Notify and Notifee by ensuring that the proper events get called, and that
the stream passed into opened_stream is correct.

Note: Listen event does not get hit because MyNotifee is passed
into network after network has already started listening

TODO: Add tests for closed_stream, listen_close when those
features are implemented in swarm
"""
import enum

import pytest
import trio

from libp2p.abc import (
    INotifee,
)
from libp2p.tools.async_service import (
    background_trio_service,
)
from libp2p.tools.constants import (
    LISTEN_MADDR,
)
from libp2p.tools.utils import (
    connect_swarm,
)
from tests.utils.factories import (
    SwarmFactory,
)


class Event(enum.Enum):
    OpenedStream = 0
    ClosedStream = 1  # Not implemented
    Connected = 2
    Disconnected = 3
    Listen = 4
    ListenClose = 5  # Not implemented


class MyNotifee(INotifee):
    def __init__(self, events):
        self.events = events

    async def opened_stream(self, network, stream):
        self.events.append(Event.OpenedStream)

    async def closed_stream(self, network, stream):
        # TODO: It is not implemented yet.
        pass

    async def connected(self, network, conn):
        self.events.append(Event.Connected)

    async def disconnected(self, network, conn):
        self.events.append(Event.Disconnected)

    async def listen(self, network, _multiaddr):
        self.events.append(Event.Listen)

    async def listen_close(self, network, _multiaddr):
        # TODO: It is not implemented yet.
        pass


@pytest.mark.trio
async def test_notify(security_protocol):
    swarms = [SwarmFactory(security_protocol=security_protocol) for _ in range(2)]

    events_0_0 = []
    events_1_0 = []
    events_0_without_listen = []

    # Helper to wait for specific event
    async def wait_for_event(events_list, expected_event, timeout=1.0):
        start_time = trio.current_time()
        while trio.current_time() - start_time < timeout:
            if expected_event in events_list:
                return True
            await trio.sleep(0.01)
        return False

    # Run swarms.
    async with background_trio_service(swarms[0]), background_trio_service(swarms[1]):
        # Register events before listening
        swarms[0].register_notifee(MyNotifee(events_0_0))
        swarms[1].register_notifee(MyNotifee(events_1_0))

        # Listen
        async with trio.open_nursery() as nursery:
            nursery.start_soon(swarms[0].listen, LISTEN_MADDR)
            nursery.start_soon(swarms[1].listen, LISTEN_MADDR)

        # Wait for Listen events
        assert await wait_for_event(events_0_0, Event.Listen)
        assert await wait_for_event(events_1_0, Event.Listen)

        swarms[0].register_notifee(MyNotifee(events_0_without_listen))

        # Connected
        await connect_swarm(swarms[0], swarms[1])
        assert await wait_for_event(events_0_0, Event.Connected)
        assert await wait_for_event(events_1_0, Event.Connected)
        assert await wait_for_event(events_0_without_listen, Event.Connected)

        # OpenedStream: first
        await swarms[0].new_stream(swarms[1].get_peer_id())
        # OpenedStream: second
        await swarms[0].new_stream(swarms[1].get_peer_id())
        # OpenedStream: third, but different direction.
        await swarms[1].new_stream(swarms[0].get_peer_id())

        # Clear any duplicate events that might have occurred
        events_0_0.copy()
        events_1_0.copy()
        events_0_without_listen.copy()

        # TODO: Check `ClosedStream` and `ListenClose` events after they are ready.

        # Disconnected
        await swarms[0].close_peer(swarms[1].get_peer_id())
        assert await wait_for_event(events_0_0, Event.Disconnected)
        assert await wait_for_event(events_1_0, Event.Disconnected)
        assert await wait_for_event(events_0_without_listen, Event.Disconnected)

        # Connected again, but different direction.
        await connect_swarm(swarms[1], swarms[0])

        # Get the index of the first disconnected event
        disconnect_idx_0_0 = events_0_0.index(Event.Disconnected)
        disconnect_idx_1_0 = events_1_0.index(Event.Disconnected)
        disconnect_idx_without_listen = events_0_without_listen.index(
            Event.Disconnected
        )

        # Check for connected event after disconnect
        assert await wait_for_event(
            events_0_0[disconnect_idx_0_0 + 1 :], Event.Connected
        )
        assert await wait_for_event(
            events_1_0[disconnect_idx_1_0 + 1 :], Event.Connected
        )
        assert await wait_for_event(
            events_0_without_listen[disconnect_idx_without_listen + 1 :],
            Event.Connected,
        )

        # Disconnected again, but different direction.
        await swarms[1].close_peer(swarms[0].get_peer_id())

        # Find index of the second connected event
        second_connect_idx_0_0 = events_0_0.index(
            Event.Connected, disconnect_idx_0_0 + 1
        )
        second_connect_idx_1_0 = events_1_0.index(
            Event.Connected, disconnect_idx_1_0 + 1
        )
        second_connect_idx_without_listen = events_0_without_listen.index(
            Event.Connected, disconnect_idx_without_listen + 1
        )

        # Check for second disconnected event
        assert await wait_for_event(
            events_0_0[second_connect_idx_0_0 + 1 :], Event.Disconnected
        )
        assert await wait_for_event(
            events_1_0[second_connect_idx_1_0 + 1 :], Event.Disconnected
        )
        assert await wait_for_event(
            events_0_without_listen[second_connect_idx_without_listen + 1 :],
            Event.Disconnected,
        )

        # Verify the core sequence of events
        expected_events_without_listen = [
            Event.Connected,
            Event.Disconnected,
            Event.Connected,
            Event.Disconnected,
        ]

        # Filter events to check only pattern we care about
        # (skipping OpenedStream which may vary)
        filtered_events_0_0 = [
            e
            for e in events_0_0
            if e in [Event.Listen, Event.Connected, Event.Disconnected]
        ]
        filtered_events_1_0 = [
            e
            for e in events_1_0
            if e in [Event.Listen, Event.Connected, Event.Disconnected]
        ]
        filtered_events_without_listen = [
            e
            for e in events_0_without_listen
            if e in [Event.Connected, Event.Disconnected]
        ]

        # Check that the pattern matches
        assert filtered_events_0_0[0] == Event.Listen, "First event should be Listen"
        assert filtered_events_1_0[0] == Event.Listen, "First event should be Listen"

        # Check pattern: Connected -> Disconnected -> Connected -> Disconnected
        assert filtered_events_0_0[1:5] == expected_events_without_listen
        assert filtered_events_1_0[1:5] == expected_events_without_listen
        assert filtered_events_without_listen[:4] == expected_events_without_listen

```
author_association: CONTRIBUTOR
user: login: seetadev
created_at: 2025-05-22T21:28:08Z
body: @lla-dane : Re-ran the CI/CD test pipeline. Only 1 failing check remaining. Please try and arrive at a good conclusion on the issue.
author_association: CONTRIBUTOR
user: login: lla-dane
created_at: 2025-05-23T05:52:21Z
body: > @lla-dane : Re-ran the CI/CD test pipeline. Only 1 failing check remaining. Please try and arrive at a good conclusion on the issue.

@seetadev : Rebased with the last commit, the changes in `test_notify.py` were passing locally, lets see what happens in the CI run.
author_association: CONTRIBUTOR
user: login: pacrob
created_at: 2025-05-26T00:49:13Z
body: Thanks @lla-dane! This looks useful. I would like to clarify what happens if a command that is not "ls" is sent - in my manual testing it just hangs.
author_association: MEMBER
user: login: seetadev
created_at: 2025-05-26T16:03:59Z
body: @lla-dane : This is coming along nicely. All checks are passing on running CI/CD pipeline. Neat progress. Please reply to (@pacrob) Paul's pointer/feedback on a test condition: "what happens if a command that is not "ls" is sent - in my manual testing it just hangs."

@pacrob : Thank you so much for sharing the pointers and feedback. Very helpful indeed.
author_association: CONTRIBUTOR
user: login: lla-dane
created_at: 2025-05-26T16:10:29Z
body: > Please reply to (@pacrob) Paul's pointer/feedback on a test condition: "what happens if a command that is not "ls" is sent - in my manual testing it just hangs."

@seetadev: Already added the negative test case to handle unsupported commands, returning `ValueError("Command not supported")`. 
author_association: CONTRIBUTOR
user: login: seetadev
created_at: 2025-05-26T16:59:08Z
body: > > Please reply to (@pacrob) Paul's pointer/feedback on a test condition: "what happens if a command that is not "ls" is sent - in my manual testing it just hangs."
> 
> @seetadev: Already added the negative test case to handle unsupported commands, returning `ValueError("Command not supported")`.

@lla-dane : Great, thank you for sharing the update. Appreciate it. 
author_association: CONTRIBUTOR