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:
2026-01-05 01:05:53 -08:00
parent bd9c6e5740
commit 88d094cfe0
5 changed files with 58 additions and 24 deletions
@@ -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,
+1 -1
View File
@@ -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"