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:
+16
-5
@@ -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())
|
||||||
|
|||||||
Reference in New Issue
Block a user