Repository: Chia-Network/chia-blockchain
Pull request: Add `running_services` command to list all running services
Issue #13313 

Added a `running_services` command to return the list of running services registered with the daemon. This complements the existing `is_running` command which only returns a response for a single requested service name.

The request doesn't take any params.

Request:
```{"ack": false, "command": "running_services", "data": {}, "destination": "daemon", "origin": "client", "request_id": "43dc226cef76963ddd56c7068972947f373918c24b0fdf5bfa767a1340271da6"}```

Response:
```{"ack": true, "command": "running_services", "data": {"running_services": ["chia_wallet", "chia_farmer", "chia_harvester", "chia_full_node"], "success": true}, "destination": "client", "origin": "daemon", "request_id": "43dc226cef76963ddd56c7068972947f373918c24b0fdf5bfa767a1340271da6"}```


Problem statement:
Optimize daemon `is_running` command to return all services
Currently the `is_running` command takes a single service name and returns a response indicating whether that service is running or not. To reduce traffic/logging, the GUI should be able to request status for all registered services in a single request.

Hints from issue discussion:

Commit message: mypy

Patch to review:
diff --git a/chia/daemon/server.py b/chia/daemon/server.py
index f54ca6fadbe4..febb09609a16 100644
--- a/chia/daemon/server.py
+++ b/chia/daemon/server.py
@@ -1102,30 +1102,40 @@ async def stop_service(self, request: Dict[str, Any]) -> Dict[str, Any]:
         return response
 
     async def is_running(self, request: Dict[str, Any]) -> Dict[str, Any]:
-        service_name = request["service"]
-
-        if service_name == service_plotter:
-            processes = self.services.get(service_name)
-            is_running = processes is not None and len(processes) > 0
-            response = {
-                "success": True,
-                "service_name": service_name,
-                "is_running": is_running,
-            }
+        service = request.get("service", "*")
+        services_to_check = (
+            list(map(str.strip, service.split(",")))
+            if service != "*"
+            else list(set([*self.services.keys(), *self.connections.keys()]))
+        )
+        responses = []
+        for service_name in services_to_check:
+            if service_name == service_plotter:
+                processes = self.services.get(service_name)
+                is_running = processes is not None and len(processes) > 0
+                response = {
+                    "service_name": service_name,
+                    "is_running": is_running,
+                }
+            else:
+                process = self.services.get(service_name)
+                is_running = process is not None and process.poll() is None
+                if not is_running:
+                    # Check if we have a connection to the requested service. This might be the
+                    # case if the service was started manually (i.e. not started by the daemon).
+                    service_connections = self.connections.get(service_name)
+                    if service_connections is not None:
+                        is_running = len(service_connections) > 0
+                response = {
+                    "service_name": service_name,
+                    "is_running": is_running,
+                }
+            responses.append(response)
+
+        if len(responses) == 1:
+            response = {"success": True, **responses[0]}
         else:
-            process = self.services.get(service_name)
-            is_running = process is not None and process.poll() is None
-            if not is_running:
-                # Check if we have a connection to the requested service. This might be the
-                # case if the service was started manually (i.e. not started by the daemon).
-                service_connections = self.connections.get(service_name)
-                if service_connections is not None:
-                    is_running = len(service_connections) > 0
-            response = {
-                "success": True,
-                "service_name": service_name,
-                "is_running": is_running,
-            }
+            response = {"success": True, "services": responses}
 
         return response
 
diff --git a/tests/core/daemon/test_daemon.py b/tests/core/daemon/test_daemon.py
index dc1c71e61101..512bdd9be026 100644
--- a/tests/core/daemon/test_daemon.py
+++ b/tests/core/daemon/test_daemon.py
@@ -4,10 +4,11 @@
 import logging
 import pytest
 
-from dataclasses import replace
-from typing import Any, Dict, List, Type
+from dataclasses import dataclass, replace
+from typing import Any, Dict, List, Optional, Type, Union
 
 from chia.daemon.keychain_server import DeleteLabelRequest, SetLabelRequest
+from chia.daemon.server import WebSocketServer
 from chia.server.outbound_message import NodeType
 from chia.types.peer_info import PeerInfo
 from chia.util.ints import uint16
@@ -169,6 +170,100 @@ async def reader(ws, queue):
     assert blockchain_state_found
 
 
+@pytest.mark.asyncio
+async def test_is_running_wildcard():
+    # Simple class that responds to a poll() call used by WebSocketServer.is_running()
+    @dataclass
+    class Service:
+        running: bool
+
+        def poll(self) -> Optional[int]:
+            return None if self.running else 1
+
+    # Mock daemon server that forwards to WebSocketServer
+    @dataclass
+    class Daemon:
+        # Instance variables used by WebSocketServer.is_running()
+        services: Dict[str, Union[List[Service], Service]]
+        connections: Dict[str, Optional[List[Any]]]
+
+        async def is_running(self, request: Dict[str, Any]) -> Dict[str, Any]:
+            return await WebSocketServer.is_running(self, request)  # type: ignore
+
+    # Mock daemon server without any registered services/connections
+    lonelyDaemon = Daemon(services={}, connections={})
+    # When: querying for status of a service that isn't running
+    result = await lonelyDaemon.is_running({"service": "chia_farmer"})
+    # Expect: service is not running
+    assert result == {"success": True, "service_name": "chia_farmer", "is_running": False}
+
+    # When: querying for statuses of all services/connections
+    result = await lonelyDaemon.is_running({"service": "*"})
+    # Expect: no services/connections are returned
+    assert result == {"success": True, "services": []}
+
+    # Mock daemon server with a couple running services and one stopped service
+    daemon_with_services = Daemon(
+        services={"my_refrigerator": Service(True), "the_river": Service(True), "your_nose": Service(False)},
+        connections={},
+    )
+    # When: querying for statuses of all services/connections
+    result = await daemon_with_services.is_running({"service": "*"})
+    # Expect: all registered services are returned, included those that are not running
+    assert result["success"] is True
+    assert {"service_name": "my_refrigerator", "is_running": True} in result["services"]
+    assert {"service_name": "the_river", "is_running": True} in result["services"]
+    assert {"service_name": "your_nose", "is_running": False} in result["services"]
+
+    # When: querying for status of a service that is running
+    result = await daemon_with_services.is_running({"service": "my_refrigerator"})
+    # Expect: service is running
+    assert result == {"success": True, "service_name": "my_refrigerator", "is_running": True}
+
+    # When: querying for status of a service that is not running
+    result = await daemon_with_services.is_running({"service": "the_clock"})
+    # Expect: service is not running
+    assert result == {"success": True, "service_name": "the_clock", "is_running": False}
+
+    # When: querying for statuses of specific services, registered or not
+    result = await daemon_with_services.is_running({"service": "apple,banana,my_refrigerator"})
+    # Expect: all named services are reported-on, including those that are not running
+    assert result["success"] is True
+    assert {"service_name": "my_refrigerator", "is_running": True} in result["services"]
+    assert {"service_name": "apple", "is_running": False} in result["services"]
+    assert {"service_name": "banana", "is_running": False} in result["services"]
+
+    # When: query includes spaces around service names
+    result = await daemon_with_services.is_running({"service": " apple, banana,   my_refrigerator "})
+    # Expect: spaces are ignored
+    assert result["success"] is True
+    assert {"service_name": "my_refrigerator", "is_running": True} in result["services"]
+    assert {"service_name": "apple", "is_running": False} in result["services"]
+    assert {"service_name": "banana", "is_running": False} in result["services"]
+
+    # Mock daemon server with a couple running services, a plotter, and a couple active connections
+    daemon_with_connections = Daemon(
+        services={
+            "my_refrigerator": Service(True),
+            "chia_plotter": [Service(True), Service(True)],
+            "apple": Service(True),
+        },
+        connections={
+            "apple": [1],
+            "banana": [1, 2],
+        },
+    )
+
+    # When: querying for statuses of all services/connections
+    result = await daemon_with_connections.is_running({"service": "*", "include_connections": True})
+    # Expect: all registered services and connections are returned
+    assert result["success"] is True
+    assert {"service_name": "my_refrigerator", "is_running": True} in result["services"]
+    assert {"service_name": "chia_plotter", "is_running": True} in result["services"]
+    assert {"service_name": "apple", "is_running": True} in result["services"]
+    assert {"service_name": "banana", "is_running": True} in result["services"]
+
+
 @pytest.mark.asyncio
 async def test_validate_keyring_passphrase_rpc(daemon_connection_and_temp_keychain):
     ws, keychain = daemon_connection_and_temp_keychain


Code review comments:
diff_hunk: @@ -1102,30 +1102,40 @@ async def stop_service(self, request: Dict[str, Any]) -> Dict[str, Any]:
         return response
 
     async def is_running(self, request: Dict[str, Any]) -> Dict[str, Any]:
-        service_name = request["service"]
-
-        if service_name == service_plotter:
-            processes = self.services.get(service_name)
-            is_running = processes is not None and len(processes) > 0
-            response = {
-                "success": True,
-                "service_name": service_name,
-                "is_running": is_running,
-            }
+        service = request.get("service", "*")
+        services_to_check = (
+            list(map(str.strip, service.split(",")))
+            if service != "*"
+            else list(set([*self.services.keys(), *self.connections.keys()]))
+        )
+        responses = []
+        for service_name in services_to_check:
+            if service_name == service_plotter:
+                processes = self.services.get(service_name)
+                is_running = processes is not None and len(processes) > 0
+                response = {
+                    "service_name": service_name,
+                    "is_running": is_running,
+                }
+            else:
+                process = self.services.get(service_name)
+                is_running = process is not None and process.poll() is None
+                if not is_running:
+                    # Check if we have a connection to the requested service. This might be the
+                    # case if the service was started manually (i.e. not started by the daemon).
+                    service_connections = self.connections.get(service_name)
+                    if service_connections is not None:
+                        is_running = len(service_connections) > 0
+                response = {
+                    "service_name": service_name,
+                    "is_running": is_running,
+                }
+            responses.append(response)
+
+        if len(responses) == 1:
+            response = {"success": True, **responses[0]}
         else:
-            process = self.services.get(service_name)
-            is_running = process is not None and process.poll() is None
-            if not is_running:
-                # Check if we have a connection to the requested service. This might be the
-                # case if the service was started manually (i.e. not started by the daemon).
-                service_connections = self.connections.get(service_name)
-                if service_connections is not None:
-                    is_running = len(service_connections) > 0
-            response = {
-                "success": True,
-                "service_name": service_name,
-                "is_running": is_running,
-            }
+            response = {"success": True, "services": responses}
path: chia/daemon/server.py
text: @user1:
I guess there's some argument for this in terms of backwards compatibility.  Maybe though we could define a consistent form?  Put the 'first' result into the root object as before but also always provide the `services` attribute?  Then callers don't need to special case one result.  They can just always use the new form from now on.

@author:
No longer applicable. `is_running` behaves as it did before.

diff_hunk: @@ -169,6 +170,100 @@ async def reader(ws, queue):
     assert blockchain_state_found
 
 
+@pytest.mark.asyncio
+async def test_is_running_wildcard():
+    # Simple class that responds to a poll() call used by WebSocketServer.is_running()
+    @dataclass
+    class Service:
+        running: bool
+
+        def poll(self) -> Optional[int]:
+            return None if self.running else 1
+
+    # Mock daemon server that forwards to WebSocketServer
+    @dataclass
+    class Daemon:
+        # Instance variables used by WebSocketServer.is_running()
+        services: Dict[str, Union[List[Service], Service]]
+        connections: Dict[str, Optional[List[Any]]]
+
+        async def is_running(self, request: Dict[str, Any]) -> Dict[str, Any]:
+            return await WebSocketServer.is_running(self, request)  # type: ignore
+
+    # Mock daemon server without any registered services/connections
+    lonelyDaemon = Daemon(services={}, connections={})
path: tests/core/daemon/test_daemon.py
text: @user1:
Looks like we need to let pylint complain about camel case.  Anyways, `lonely_daemon`.

@author:
ack, been spending too much time in JavaScript lately.

diff_hunk: @@ -1102,30 +1102,40 @@ async def stop_service(self, request: Dict[str, Any]) -> Dict[str, Any]:
         return response
 
     async def is_running(self, request: Dict[str, Any]) -> Dict[str, Any]:
-        service_name = request["service"]
-
-        if service_name == service_plotter:
-            processes = self.services.get(service_name)
-            is_running = processes is not None and len(processes) > 0
-            response = {
-                "success": True,
-                "service_name": service_name,
-                "is_running": is_running,
-            }
+        service = request.get("service", "*")
path: chia/daemon/server.py
text: @user1:
What if this were a list of strings instead and we didn't have to apply nested parsing to the existing JSON parsed result?  Or perhaps `service` remains entirely as is, there's a new key named `services` that's a list, we just prepend `service` to `services` and move forward with only looking at `services`.  I don't think I've quite thought through all the little corners.

@author:
@user2 and I have been discussing the merits of just creating a new `running_services` command rather than trying to shoehorn this new behavior into `is_running`. I was initially going for something small and quick, but I'm onboard with leaving `is_running` as-is and moving this new functionality to a new command.

@author:
Added `running_services` instead of modifying `is_running`

diff_hunk: @@ -169,6 +170,100 @@ async def reader(ws, queue):
     assert blockchain_state_found
 
 
+@pytest.mark.asyncio
+async def test_is_running_wildcard():
+    # Simple class that responds to a poll() call used by WebSocketServer.is_running()
+    @dataclass
+    class Service:
+        running: bool
+
+        def poll(self) -> Optional[int]:
+            return None if self.running else 1
+
+    # Mock daemon server that forwards to WebSocketServer
+    @dataclass
+    class Daemon:
+        # Instance variables used by WebSocketServer.is_running()
+        services: Dict[str, Union[List[Service], Service]]
+        connections: Dict[str, Optional[List[Any]]]
+
+        async def is_running(self, request: Dict[str, Any]) -> Dict[str, Any]:
+            return await WebSocketServer.is_running(self, request)  # type: ignore
path: tests/core/daemon/test_daemon.py
text: @user1:
Please provide the full ignore form such as `# type: ignore[no-any-return]` or whatever it is as well as a comment on the previously line either with a link to a bug or PR that would address the need for the ignore or a long-term justification for it.

@author:
Updated