mirror of
https://github.com/hanzoai/python-sdk.git
synced 2026-10-23 20:23:00 +00:00
fix(hanzo-mcp): use aiofiles for non-blocking file I/O
- Add aiofiles as runtime dependency - Convert all file I/O in base_process.py to async with aiofiles - Convert log writing in auto_background.py to async - Convert log reading in process_tool.py to async - Make create_log_file() async - Add _ensure_log_dir() async method for deferred initialization This ensures the event loop is never blocked by file operations, which was causing hangs in MCP tools. Fixes blocking file I/O identified by agent swarm review.
This commit is contained in:
@@ -2,6 +2,7 @@
|
||||
|
||||
This module provides automatic backgrounding of long-running processes.
|
||||
Commands that take more than 2 minutes automatically continue in background.
|
||||
All file I/O uses aiofiles for non-blocking operations.
|
||||
"""
|
||||
|
||||
import time
|
||||
@@ -10,6 +11,8 @@ import asyncio
|
||||
from typing import Tuple, Optional
|
||||
from pathlib import Path
|
||||
|
||||
import aiofiles
|
||||
|
||||
from hanzo_mcp.tools.shell.base_process import ProcessManager
|
||||
|
||||
|
||||
@@ -74,8 +77,8 @@ class AutoBackgroundExecutor:
|
||||
# Generate process ID
|
||||
process_id = f"{tool_name}_{uuid.uuid4().hex[:8]}"
|
||||
|
||||
# Create log file
|
||||
log_file = self.process_manager.create_log_file(process_id)
|
||||
# Create log file (async)
|
||||
log_file = await self.process_manager.create_log_file(process_id)
|
||||
|
||||
# Start the process
|
||||
process = await asyncio.create_subprocess_exec(
|
||||
@@ -96,14 +99,14 @@ class AutoBackgroundExecutor:
|
||||
try:
|
||||
# Create tasks for reading output and waiting for process
|
||||
async def read_output():
|
||||
"""Read output from process."""
|
||||
"""Read output from process (non-blocking)."""
|
||||
if process.stdout:
|
||||
async for line in process.stdout:
|
||||
line_str = line.decode("utf-8", errors="replace")
|
||||
output_lines.append(line_str)
|
||||
# Also write to log file
|
||||
with open(log_file, "a") as f:
|
||||
f.write(line_str)
|
||||
# Also write to log file (async)
|
||||
async with aiofiles.open(log_file, "a") as f:
|
||||
await f.write(line_str)
|
||||
|
||||
async def wait_for_process():
|
||||
"""Wait for process to complete."""
|
||||
@@ -181,17 +184,19 @@ class AutoBackgroundExecutor:
|
||||
async def _background_reader(self, process, process_id: str, log_file: Path):
|
||||
"""Continue reading output from a backgrounded process.
|
||||
|
||||
Uses aiofiles for non-blocking file I/O.
|
||||
|
||||
Args:
|
||||
process: The subprocess
|
||||
process_id: Process identifier
|
||||
log_file: Log file path
|
||||
"""
|
||||
try:
|
||||
# Continue reading output
|
||||
# Continue reading output (non-blocking)
|
||||
if process.stdout:
|
||||
async for line in process.stdout:
|
||||
with open(log_file, "a") as f:
|
||||
f.write(line.decode("utf-8", errors="replace"))
|
||||
async with aiofiles.open(log_file, "a") as f:
|
||||
await f.write(line.decode("utf-8", errors="replace"))
|
||||
|
||||
# Wait for process to complete
|
||||
return_code = await process.wait()
|
||||
@@ -199,14 +204,14 @@ class AutoBackgroundExecutor:
|
||||
# Mark as completed
|
||||
self.process_manager.mark_completed(process_id, return_code)
|
||||
|
||||
# Add completion marker to log
|
||||
with open(log_file, "a") as f:
|
||||
f.write(f"\n\n=== Process completed with exit code {return_code} ===\n")
|
||||
# Add completion marker to log (async)
|
||||
async with aiofiles.open(log_file, "a") as f:
|
||||
await f.write(f"\n\n=== Process completed with exit code {return_code} ===\n")
|
||||
|
||||
except Exception as e:
|
||||
# Log error
|
||||
with open(log_file, "a") as f:
|
||||
f.write(f"\n\n=== Background reader error: {str(e)} ===\n")
|
||||
# Log error (async)
|
||||
async with aiofiles.open(log_file, "a") as f:
|
||||
await f.write(f"\n\n=== Background reader error: {str(e)} ===\n")
|
||||
|
||||
self.process_manager.mark_completed(process_id, -1)
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"""Base classes for process execution tools.
|
||||
|
||||
All process execution uses asyncio.subprocess for consistency.
|
||||
All file I/O uses aiofiles for non-blocking operations.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
@@ -11,6 +12,9 @@ from abc import abstractmethod
|
||||
from typing import Any, Dict, List, Optional, override
|
||||
from pathlib import Path
|
||||
|
||||
import aiofiles
|
||||
import aiofiles.os
|
||||
|
||||
from hanzo_mcp.tools.common.base import BaseTool
|
||||
from hanzo_mcp.tools.common.truncate import truncate_response
|
||||
from hanzo_mcp.tools.common.permissions import PermissionManager
|
||||
@@ -27,12 +31,19 @@ class ProcessManager:
|
||||
_logs: Dict[str, str] = {}
|
||||
_log_dir = Path(tempfile.gettempdir()) / "hanzo_mcp_logs"
|
||||
|
||||
_initialized: bool = False
|
||||
|
||||
def __new__(cls):
|
||||
if cls._instance is None:
|
||||
cls._instance = super().__new__(cls)
|
||||
cls._instance._log_dir.mkdir(exist_ok=True)
|
||||
return cls._instance
|
||||
|
||||
async def _ensure_log_dir(self) -> None:
|
||||
"""Ensure log directory exists (async-safe)."""
|
||||
if not self._initialized:
|
||||
await aiofiles.os.makedirs(self._log_dir, exist_ok=True)
|
||||
self._initialized = True
|
||||
|
||||
def add_process(self, process_id: str, process: asyncio.subprocess.Process, log_file: str) -> None:
|
||||
"""Add a process to track.
|
||||
|
||||
@@ -92,8 +103,8 @@ class ProcessManager:
|
||||
"""Get the log directory."""
|
||||
return self._log_dir
|
||||
|
||||
def create_log_file(self, process_id: str) -> Path:
|
||||
"""Create a log file for a process.
|
||||
async def create_log_file(self, process_id: str) -> Path:
|
||||
"""Create a log file for a process (async-safe).
|
||||
|
||||
Args:
|
||||
process_id: Process identifier
|
||||
@@ -101,8 +112,11 @@ class ProcessManager:
|
||||
Returns:
|
||||
Path to the created log file
|
||||
"""
|
||||
await self._ensure_log_dir()
|
||||
log_file = self._log_dir / f"{process_id}.log"
|
||||
log_file.touch()
|
||||
# Create empty file asynchronously
|
||||
async with aiofiles.open(log_file, "w") as f:
|
||||
pass
|
||||
return log_file
|
||||
|
||||
def mark_completed(self, process_id: str, return_code: int) -> None:
|
||||
@@ -274,31 +288,33 @@ class BaseProcessTool(BaseTool):
|
||||
) -> None:
|
||||
"""Write process output to log file in background.
|
||||
|
||||
Uses aiofiles for non-blocking file I/O to avoid blocking the event loop.
|
||||
|
||||
Args:
|
||||
process: The asyncio subprocess
|
||||
log_file: Path to write output
|
||||
process_id: Process identifier for cleanup
|
||||
"""
|
||||
try:
|
||||
with open(log_file, "w") as f:
|
||||
async with aiofiles.open(log_file, "w") as f:
|
||||
if process.stdout:
|
||||
async for line in process.stdout:
|
||||
f.write(line.decode("utf-8", errors="replace"))
|
||||
f.flush()
|
||||
await f.write(line.decode("utf-8", errors="replace"))
|
||||
await f.flush()
|
||||
|
||||
# Wait for process to complete
|
||||
return_code = await process.wait()
|
||||
|
||||
# Add completion marker
|
||||
with open(log_file, "a") as f:
|
||||
f.write(f"\n=== Process completed with exit code {return_code} ===\n")
|
||||
async with aiofiles.open(log_file, "a") as f:
|
||||
await f.write(f"\n=== Process completed with exit code {return_code} ===\n")
|
||||
|
||||
# Mark as completed
|
||||
self.process_manager.mark_completed(process_id, return_code)
|
||||
|
||||
except Exception as e:
|
||||
with open(log_file, "a") as f:
|
||||
f.write(f"\n=== Error: {str(e)} ===\n")
|
||||
async with aiofiles.open(log_file, "a") as f:
|
||||
await f.write(f"\n=== Error: {str(e)} ===\n")
|
||||
self.process_manager.mark_completed(process_id, -1)
|
||||
|
||||
|
||||
|
||||
@@ -1,8 +1,13 @@
|
||||
"""Process management tool."""
|
||||
"""Process management tool.
|
||||
|
||||
Uses aiofiles for non-blocking log file reads.
|
||||
"""
|
||||
|
||||
import signal
|
||||
from typing import Optional, override
|
||||
|
||||
import aiofiles
|
||||
|
||||
from mcp.server import FastMCP
|
||||
from mcp.server.fastmcp import Context as MCPContext
|
||||
|
||||
@@ -101,8 +106,10 @@ process --action logs --id bash_ghi789 --lines 50"""
|
||||
return f"No log file found for process {id}"
|
||||
|
||||
try:
|
||||
with open(log_file, "r") as f:
|
||||
log_lines = f.readlines()
|
||||
# Read log file asynchronously (non-blocking)
|
||||
async with aiofiles.open(log_file, "r") as f:
|
||||
content = await f.read()
|
||||
log_lines = content.splitlines(keepends=True)
|
||||
|
||||
# Get last N lines
|
||||
if len(log_lines) > lines:
|
||||
|
||||
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "hanzo-mcp"
|
||||
version = "0.9.15"
|
||||
version = "0.9.16"
|
||||
description = "The Zen of Hanzo MCP: One server to rule them all. The ultimate MCP that orchestrates all others."
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.12"
|
||||
@@ -17,6 +17,7 @@ classifiers = [
|
||||
]
|
||||
keywords = ["mcp", "claude", "hanzo", "code", "agent"]
|
||||
dependencies = [
|
||||
"aiofiles>=24.1.0",
|
||||
"mcp>=1.9.4",
|
||||
"fastmcp>=2.9.2",
|
||||
"httpx>=0.28.1",
|
||||
|
||||
Generated
+3
-1
@@ -1003,9 +1003,10 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "hanzo-mcp"
|
||||
version = "0.9.10"
|
||||
version = "0.9.15"
|
||||
source = { editable = "." }
|
||||
dependencies = [
|
||||
{ name = "aiofiles" },
|
||||
{ name = "bashlex" },
|
||||
{ name = "fastmcp" },
|
||||
{ name = "ffind" },
|
||||
@@ -1074,6 +1075,7 @@ test = [
|
||||
|
||||
[package.metadata]
|
||||
requires-dist = [
|
||||
{ name = "aiofiles", specifier = ">=24.1.0" },
|
||||
{ name = "bashlex", specifier = ">=0.18" },
|
||||
{ name = "black", marker = "extra == 'dev'", specifier = ">=23.3.0" },
|
||||
{ name = "build", marker = "extra == 'publish'", specifier = ">=1.0.3" },
|
||||
|
||||
Reference in New Issue
Block a user