mirror of
https://github.com/hanzoai/python-sdk.git
synced 2026-10-23 20:23:00 +00:00
perf: 30s auto-backgrounding + fix hanging processes
- Change default timeout from 45s to 30s for faster response - Fix potential hanging in run_shell by using stream reading instead of communicate() - Add start_new_session=True to prevent zombie processes - Proper cleanup on error with process.kill() - All stream reading uses chunked async reads (8KB chunks) - Partial output preserved when backgrounding 53 tests pass in 1.1s.
This commit is contained in:
@@ -108,13 +108,13 @@ class ProcessManager:
|
||||
class AutoBackgroundExecutor:
|
||||
"""Executor that automatically backgrounds long-running processes.
|
||||
|
||||
IMPORTANT: Always backgrounds after MAX_FOREGROUND_TIMEOUT (45s) to keep
|
||||
IMPORTANT: Always backgrounds after MAX_FOREGROUND_TIMEOUT (30s) to keep
|
||||
the agent loop responsive. Longer timeouts only affect the background
|
||||
process lifetime, not the foreground wait time.
|
||||
"""
|
||||
|
||||
DEFAULT_TIMEOUT = 45.0 # Default timeout for backgrounding
|
||||
MAX_FOREGROUND_TIMEOUT = 45.0 # ALWAYS background after 45s, even if longer timeout given
|
||||
DEFAULT_TIMEOUT = 30.0 # Default timeout for backgrounding
|
||||
MAX_FOREGROUND_TIMEOUT = 30.0 # ALWAYS background after 30s, even if longer timeout given
|
||||
|
||||
def __init__(self, process_manager: ProcessManager, timeout: float = DEFAULT_TIMEOUT):
|
||||
"""Initialize the auto-background executor."""
|
||||
@@ -134,7 +134,7 @@ class AutoBackgroundExecutor:
|
||||
Returns:
|
||||
Tuple of (output/status, was_backgrounded, process_id)
|
||||
|
||||
Note: Always backgrounds after MAX_FOREGROUND_TIMEOUT (60s) to keep
|
||||
Note: Always backgrounds after MAX_FOREGROUND_TIMEOUT (30s) to keep
|
||||
the agent loop responsive. The passed timeout is stored for reference
|
||||
but doesn't extend foreground wait time.
|
||||
"""
|
||||
@@ -463,9 +463,10 @@ class ShellExecutor:
|
||||
|
||||
Ensures consistent auto-backgrounding behavior across dag, zsh, shell, bash tools.
|
||||
Uses a singleton pattern to share process management state.
|
||||
Auto-backgrounds after 30s to keep agent loop responsive.
|
||||
"""
|
||||
|
||||
DEFAULT_TIMEOUT = 45.0 # Auto-background after 45 seconds
|
||||
DEFAULT_TIMEOUT = 30.0 # Auto-background after 30 seconds
|
||||
|
||||
_instance: Optional["ShellExecutor"] = None
|
||||
|
||||
@@ -501,13 +502,13 @@ class ShellExecutor:
|
||||
shell: Shell binary path (e.g., /bin/zsh)
|
||||
cwd: Working directory
|
||||
env: Environment variables
|
||||
timeout: Timeout before auto-backgrounding (default: 45s)
|
||||
timeout: Timeout before auto-backgrounding (default: 30s)
|
||||
tool_name: Tool name for process ID prefix
|
||||
|
||||
Returns:
|
||||
Tuple of (stdout, stderr, exit_code, was_backgrounded, process_id)
|
||||
"""
|
||||
# Cap timeout at 45s for foreground wait - keep agent loop responsive
|
||||
# Cap timeout at 30s for foreground wait - keep agent loop responsive
|
||||
effective_timeout = min(timeout, self.DEFAULT_TIMEOUT)
|
||||
|
||||
run_env = os.environ.copy()
|
||||
@@ -527,30 +528,56 @@ class ShellExecutor:
|
||||
stderr=asyncio.subprocess.PIPE,
|
||||
cwd=cwd,
|
||||
env=run_env,
|
||||
# Prevent zombie processes - use start_new_session on Unix
|
||||
start_new_session=True,
|
||||
)
|
||||
|
||||
stdout_chunks: list[bytes] = []
|
||||
stderr_chunks: list[bytes] = []
|
||||
|
||||
async def read_stdout():
|
||||
if proc.stdout:
|
||||
while True:
|
||||
chunk = await proc.stdout.read(8192)
|
||||
if not chunk:
|
||||
break
|
||||
stdout_chunks.append(chunk)
|
||||
|
||||
async def read_stderr():
|
||||
if proc.stderr:
|
||||
while True:
|
||||
chunk = await proc.stderr.read(8192)
|
||||
if not chunk:
|
||||
break
|
||||
stderr_chunks.append(chunk)
|
||||
|
||||
try:
|
||||
stdout, stderr = await asyncio.wait_for(
|
||||
proc.communicate(),
|
||||
# Read streams and wait for process with timeout
|
||||
await asyncio.wait_for(
|
||||
asyncio.gather(read_stdout(), read_stderr(), proc.wait()),
|
||||
timeout=effective_timeout
|
||||
)
|
||||
|
||||
exit_code = proc.returncode or 0
|
||||
return (
|
||||
stdout.decode("utf-8", errors="replace"),
|
||||
stderr.decode("utf-8", errors="replace"),
|
||||
b"".join(stdout_chunks).decode("utf-8", errors="replace"),
|
||||
b"".join(stderr_chunks).decode("utf-8", errors="replace"),
|
||||
exit_code,
|
||||
False, # Not backgrounded
|
||||
None, # No process_id (completed)
|
||||
)
|
||||
|
||||
except asyncio.TimeoutError:
|
||||
# Background the process
|
||||
# Background the process - don't kill it
|
||||
partial_stdout = b"".join(stdout_chunks).decode("utf-8", errors="replace")
|
||||
partial_stderr = b"".join(stderr_chunks).decode("utf-8", errors="replace")
|
||||
|
||||
await write_file(log_file,
|
||||
f"[{shell_name}] Command backgrounded after {effective_timeout}s timeout\n"
|
||||
f"[{shell_name}] Command: {command}\n"
|
||||
f"[{shell_name}] PID: {proc.pid}\n"
|
||||
+ "-" * 40 + "\n"
|
||||
f"{partial_stdout}{partial_stderr}"
|
||||
)
|
||||
|
||||
self._process_manager.add_process(process_id, proc, str(log_file))
|
||||
@@ -567,6 +594,13 @@ class ShellExecutor:
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
# Clean up on error - kill process if it exists
|
||||
try:
|
||||
if proc and proc.returncode is None:
|
||||
proc.kill()
|
||||
await proc.wait()
|
||||
except Exception:
|
||||
pass
|
||||
return (
|
||||
"",
|
||||
str(e),
|
||||
|
||||
@@ -161,7 +161,7 @@ Use ps --logs <id> to view, ps --kill <id> to stop."""
|
||||
cmd: str,
|
||||
cwd: Optional[str] = None,
|
||||
env: Optional[Dict[str, str]] = None,
|
||||
timeout: int = 45,
|
||||
timeout: int = 30,
|
||||
shell: Optional[str] = None,
|
||||
) -> CmdResult:
|
||||
"""Run a shell command with auto-backgrounding on timeout.
|
||||
@@ -407,7 +407,7 @@ Use ps --logs <id> to view, ps --kill <id> to stop."""
|
||||
shell: Optional[str] = None,
|
||||
cwd: Optional[str] = None,
|
||||
env: Optional[Dict[str, str]] = None,
|
||||
timeout: int = 45,
|
||||
timeout: int = 30,
|
||||
strict: bool = False,
|
||||
quiet: bool = False,
|
||||
**kwargs,
|
||||
@@ -486,7 +486,7 @@ Use ps --logs <id> to view, ps --kill <id> to stop."""
|
||||
shell: Annotated[Optional[str], Field(description="Shell: zsh, bash, sh", default=None)] = None,
|
||||
cwd: Annotated[Optional[str], Field(description="Working directory", default=None)] = None,
|
||||
env: Annotated[Optional[Dict[str, str]], Field(description="Environment variables", default=None)] = None,
|
||||
timeout: Annotated[int, Field(description="Timeout per command (seconds)", default=45)] = 45,
|
||||
timeout: Annotated[int, Field(description="Timeout per command (seconds)", default=30)] = 30,
|
||||
strict: Annotated[bool, Field(description="Stop on first error", default=False)] = False,
|
||||
quiet: Annotated[bool, Field(description="Suppress stdout", default=False)] = False,
|
||||
ctx: MCPContext = None,
|
||||
|
||||
@@ -173,7 +173,7 @@ backgrounded. Use ps tool to monitor: ps --logs <id>, ps --kill <id>"""
|
||||
shell: str,
|
||||
cwd: Optional[str] = None,
|
||||
env: Optional[Dict[str, str]] = None,
|
||||
timeout: int = 45,
|
||||
timeout: int = 30,
|
||||
) -> DagResult:
|
||||
"""Run a shell command with auto-backgrounding on timeout.
|
||||
|
||||
@@ -384,7 +384,7 @@ backgrounded. Use ps tool to monitor: ps --logs <id>, ps --kill <id>"""
|
||||
shell: Optional[str] = None,
|
||||
cwd: Optional[str] = None,
|
||||
env: Optional[Dict[str, str]] = None,
|
||||
timeout: int = 45,
|
||||
timeout: int = 30,
|
||||
strict: bool = False,
|
||||
quiet: bool = False,
|
||||
**kwargs,
|
||||
@@ -462,7 +462,7 @@ backgrounded. Use ps tool to monitor: ps --logs <id>, ps --kill <id>"""
|
||||
shell: Annotated[Optional[str], Field(description="Shell to use (default: zsh)", default=None)] = None,
|
||||
cwd: Annotated[Optional[str], Field(description="Working directory", default=None)] = None,
|
||||
env: Annotated[Optional[Dict[str, str]], Field(description="Environment variables", default=None)] = None,
|
||||
timeout: Annotated[int, Field(description="Timeout per command (seconds)", default=45)] = 45,
|
||||
timeout: Annotated[int, Field(description="Timeout per command (seconds)", default=30)] = 30,
|
||||
strict: Annotated[bool, Field(description="Stop on first error", default=False)] = False,
|
||||
quiet: Annotated[bool, Field(description="Suppress stdout", default=False)] = False,
|
||||
ctx: MCPContext = None,
|
||||
|
||||
@@ -104,7 +104,7 @@ Use ps --logs <id> to view, ps --kill <id> to stop."""
|
||||
parallel: Annotated[bool, Field(description="Run all commands in parallel", default=False)] = False,
|
||||
cwd: Annotated[Optional[str], Field(description="Working directory", default=None)] = None,
|
||||
env: Annotated[Optional[Dict[str, str]], Field(description="Environment variables", default=None)] = None,
|
||||
timeout: Annotated[int, Field(description="Timeout per command (seconds)", default=45)] = 45,
|
||||
timeout: Annotated[int, Field(description="Timeout per command (seconds)", default=30)] = 30,
|
||||
strict: Annotated[bool, Field(description="Stop on first error", default=False)] = False,
|
||||
quiet: Annotated[bool, Field(description="Suppress stdout", default=False)] = False,
|
||||
ctx: MCPContext = None,
|
||||
@@ -172,7 +172,7 @@ AUTO-BACKGROUNDING: Commands exceeding 45s auto-background."""
|
||||
parallel: Annotated[bool, Field(description="Run in parallel", default=False)] = False,
|
||||
cwd: Annotated[Optional[str], Field(description="Working directory", default=None)] = None,
|
||||
env: Annotated[Optional[Dict[str, str]], Field(description="Environment variables", default=None)] = None,
|
||||
timeout: Annotated[int, Field(description="Timeout (seconds)", default=45)] = 45,
|
||||
timeout: Annotated[int, Field(description="Timeout (seconds)", default=30)] = 30,
|
||||
strict: Annotated[bool, Field(description="Stop on first error", default=False)] = False,
|
||||
quiet: Annotated[bool, Field(description="Suppress stdout", default=False)] = False,
|
||||
ctx: MCPContext = None,
|
||||
@@ -252,7 +252,7 @@ AUTO-BACKGROUNDING: Commands exceeding 45s auto-background."""
|
||||
parallel: Annotated[bool, Field(description="Run in parallel", default=False)] = False,
|
||||
cwd: Annotated[Optional[str], Field(description="Working directory", default=None)] = None,
|
||||
env: Annotated[Optional[Dict[str, str]], Field(description="Environment variables", default=None)] = None,
|
||||
timeout: Annotated[int, Field(description="Timeout (seconds)", default=45)] = 45,
|
||||
timeout: Annotated[int, Field(description="Timeout (seconds)", default=30)] = 30,
|
||||
strict: Annotated[bool, Field(description="Stop on first error", default=False)] = False,
|
||||
quiet: Annotated[bool, Field(description="Suppress stdout", default=False)] = False,
|
||||
ctx: MCPContext = None,
|
||||
@@ -334,7 +334,7 @@ AUTO-BACKGROUNDING: Commands exceeding 45s auto-background."""
|
||||
parallel: Annotated[bool, Field(description="Run in parallel", default=False)] = False,
|
||||
cwd: Annotated[Optional[str], Field(description="Working directory", default=None)] = None,
|
||||
env: Annotated[Optional[Dict[str, str]], Field(description="Environment variables", default=None)] = None,
|
||||
timeout: Annotated[int, Field(description="Timeout (seconds)", default=45)] = 45,
|
||||
timeout: Annotated[int, Field(description="Timeout (seconds)", default=30)] = 30,
|
||||
strict: Annotated[bool, Field(description="Stop on first error", default=False)] = False,
|
||||
quiet: Annotated[bool, Field(description="Suppress stdout", default=False)] = False,
|
||||
ctx: MCPContext = None,
|
||||
@@ -394,7 +394,7 @@ AUTO-BACKGROUNDING: Commands exceeding 45s auto-background."""
|
||||
parallel: Annotated[bool, Field(description="Run in parallel", default=False)] = False,
|
||||
cwd: Annotated[Optional[str], Field(description="Working directory", default=None)] = None,
|
||||
env: Annotated[Optional[Dict[str, str]], Field(description="Environment variables", default=None)] = None,
|
||||
timeout: Annotated[int, Field(description="Timeout (seconds)", default=45)] = 45,
|
||||
timeout: Annotated[int, Field(description="Timeout (seconds)", default=30)] = 30,
|
||||
strict: Annotated[bool, Field(description="Stop on first error", default=False)] = False,
|
||||
quiet: Annotated[bool, Field(description="Suppress stdout", default=False)] = False,
|
||||
ctx: MCPContext = None,
|
||||
|
||||
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "hanzo-tools-shell"
|
||||
version = "0.5.3"
|
||||
version = "0.5.4"
|
||||
description = "Shell/command execution tools - cmd (unified DAG), zsh, bash, fish, dash, ps, npx, uvx, open, curl, jq, wget"
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.12"
|
||||
|
||||
Reference in New Issue
Block a user