shell-mcp: don't block the event loop; honor longer timeouts

subprocess.run inside the async handler serialized every request behind
the slowest command (found 2026-08-01 when a du wedged the server mid-
migration). Run it in the default thread pool via run_in_executor so
concurrent calls actually run concurrently. Timeout default 30->60,
cap 300->600, schema text matched.
This commit is contained in:
2026-08-02 03:45:46 +01:00
parent 973c4bc44b
commit d6760a709d
+16 -5
View File
@@ -15,9 +15,14 @@ arrsstack Pi 5 (arm64). Differences that matter:
* Dependencies are pinned (see requirements.txt). The original installed * Dependencies are pinned (see requirements.txt). The original installed
`mcp starlette uvicorn` unpinned; starlette has since gone 1.x. An `mcp starlette uvicorn` unpinned; starlette has since gone 1.x. An
unpinned install inside a first-boot script is a time bomb. unpinned install inside a first-boot script is a time bomb.
* 2026-08-02: commands run in a worker thread (run_in_executor), not
inline in the async handler. The original blocked uvicorn's event loop
for the duration of every command, serializing ALL requests behind the
slowest one — discovered mid-migration when a long du wedged the server.
Commands run in the host namespaces via nsenter — full root on the media box. Commands run in the host namespaces via nsenter — full root on the media box.
""" """
import asyncio
import os import os
import shlex import shlex
import subprocess import subprocess
@@ -69,8 +74,8 @@ async def list_tools() -> list[Tool]:
}, },
"timeout": { "timeout": {
"type": "integer", "type": "integer",
"description": "Timeout in seconds, default 30, max 300", "description": "Timeout in seconds, default 60, max 600",
"default": 30, "default": 60,
}, },
}, },
"required": ["command"], "required": ["command"],
@@ -86,7 +91,7 @@ async def call_tool(name: str, arguments: dict) -> list[TextContent]:
command = arguments["command"] command = arguments["command"]
working_dir = arguments.get("working_directory") working_dir = arguments.get("working_directory")
timeout = min(int(arguments.get("timeout", 30)), 300) timeout = min(int(arguments.get("timeout", 60)), 600)
inner = f"cd {shlex.quote(working_dir)} && {command}" if working_dir else command inner = f"cd {shlex.quote(working_dir)} && {command}" if working_dir else command
@@ -95,8 +100,11 @@ async def call_tool(name: str, arguments: dict) -> list[TextContent]:
f"bash -c {shlex.quote(inner)}" f"bash -c {shlex.quote(inner)}"
) )
try: # Run the blocking subprocess in the default thread pool. Doing this inline
result = subprocess.run( # would block uvicorn's event loop and serialize every request behind the
# slowest command — a long du froze the whole server for minutes once.
def _run() -> subprocess.CompletedProcess:
return subprocess.run(
host_cmd, host_cmd,
shell=True, shell=True,
executable="/bin/bash", executable="/bin/bash",
@@ -104,6 +112,9 @@ async def call_tool(name: str, arguments: dict) -> list[TextContent]:
text=True, text=True,
timeout=timeout, timeout=timeout,
) )
try:
result = await asyncio.get_running_loop().run_in_executor(None, _run)
parts = [] parts = []
if result.stdout: if result.stdout:
parts.append(result.stdout.rstrip()) parts.append(result.stdout.rstrip())