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:
2025-12-24 16:16:19 -08:00
parent 2bb4b4ab38
commit fcc786884e
5 changed files with 62 additions and 31 deletions
@@ -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:
+2 -1
View File
@@ -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",
+3 -1
View File
@@ -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" },