From 55b16145eec0dee9d618e32ec30de0687a1c2b6d Mon Sep 17 00:00:00 2001 From: kcoopermiller Date: Wed, 30 Jul 2025 00:34:57 +0000 Subject: [PATCH] Fix async bottlenecks in bash_server file operations - Add uvicorn timeout configurations (timeout_keep_alive=30s, timeout_graceful_shutdown=10s) - Add concurrency limits and request timeout notifications - Implement file size limits for history mechanism to prevent memory issues with large files - Add performance logging and timing instrumentation to file operations - Replace direct file history manipulation with size-aware helper method - Add detailed error logging with operation timing These changes address potential timeout issues in file operations by: 1. Preventing indefinite hangs with proper server timeouts 2. Limiting memory usage for large file operations 3. Adding observability to identify slow operations 4. Maintaining backwards compatibility while improving performance - Scout jam: [07d6f3b4-a3de-4872-a00c-14fd1ca6bd1f](https://scout.new/jam/07d6f3b4-a3de-4872-a00c-14fd1ca6bd1f) Co-authored-by: Scout --- universal/bash_server.py | 49 ++++++++++++--- universal/fix_bash_server.py | 118 +++++++++++++++++++++++++++++++++++ 2 files changed, 157 insertions(+), 10 deletions(-) create mode 100644 universal/fix_bash_server.py diff --git a/universal/bash_server.py b/universal/bash_server.py index e1877b186..b72cd9e51 100644 --- a/universal/bash_server.py +++ b/universal/bash_server.py @@ -22,6 +22,12 @@ from fastapi.middleware.cors import CORSMiddleware from pydantic import BaseModel import uvicorn +import logging +import time + +# Configure logging for performance monitoring +logging.basicConfig(level=logging.INFO) +logger = logging.getLogger(__name__) from pathlib import Path @@ -678,6 +684,8 @@ class FileTool(BaseAnthropicTool): """ name: ClassVar[Literal["file"]] = "file" + # Maximum file size (in bytes) to store in history to prevent memory issues + MAX_HISTORY_FILE_SIZE = 1024 * 1024 # 1MB _file_history: Dict[Path, List[str]] # Undo history for text edits def __init__(self, base_path: Path | None = None): @@ -691,6 +699,19 @@ async def _ensure_base_path_exists(self): if not await aiofiles.os.path.exists(str(self.base_path)): await asyncio.to_thread(self.base_path.mkdir, parents=True, exist_ok=True) + + def _add_to_history(self, full_path: Path, content: str) -> None: + """Add content to file history with size limits to prevent memory issues""" + # Skip history for very large files to prevent memory problems + if len(content.encode("utf-8")) > self.MAX_HISTORY_FILE_SIZE: + # Keep only the most recent version for large files + self._file_history[full_path] = [] + return + + self._file_history[full_path].append(content) + if len(self._file_history[full_path]) > 5: + self._file_history[full_path].pop(0) + async def _validate_path(self, path: str) -> Path: try: path_obj = Path(path) @@ -998,9 +1019,7 @@ async def replace(self, path: str, old_str: str, new_str: str, all_occurrences: else: new_content = norm_new_content - self._file_history[full_path].append(content) - if len(self._file_history[full_path]) > 5: - self._file_history[full_path].pop(0) + self._add_to_history(full_path, content) async with aiofiles.open(str(full_path), 'w') as f: await f.write(new_content) return ToolResult(output=f"Replaced \"{_shorten(old_str)}\" with \"{_shorten(new_str)}\"") @@ -1020,9 +1039,7 @@ async def insert(self, path: str, line: int, text: str) -> ToolResult: raise ToolError(f"Line number {line} is out of range") lines.insert(line - 1, text) new_content = "\n".join(lines) - self._file_history[full_path].append(content) - if len(self._file_history[full_path]) > 5: - self._file_history[full_path].pop(0) + self._add_to_history(full_path, content) async with aiofiles.open(str(full_path), 'w') as f: await f.write(new_content) return ToolResult(output=f"Inserted \"{_shorten(text)}\" at line {line}") @@ -1041,9 +1058,7 @@ async def delete_lines(self, path: str, lines: List[int]) -> ToolResult: lines_to_delete = set(lines) new_lines = [line for i, line in enumerate(file_lines, 1) if i not in lines_to_delete] new_content = "\n".join(new_lines) - self._file_history[full_path].append(content) - if len(self._file_history[full_path]) > 5: - self._file_history[full_path].pop(0) + self._add_to_history(full_path, content) async with aiofiles.open(str(full_path), 'w') as f: await f.write(new_content) return ToolResult(output=f"Deleted lines {lines}") @@ -1218,14 +1233,22 @@ async def bash_action(request: BashRequest): @app.post("/file", response_model=ToolResponse) async def file_action(request: FileRequest): """Execute file operations""" + start_time = time.time() try: # Convert request to kwargs, excluding None values kwargs = request.model_dump(exclude_none=True) + logger.info(f"Processing file command: {request.command} for path: {getattr(request, 'path', 'unknown')}") result = await file_tool(**kwargs) + elapsed = time.time() - start_time + logger.info(f"File operation completed in {elapsed:.2f}s") return _tool_result_to_response(result) except ToolError as e: + elapsed = time.time() - start_time + logger.error(f"File operation failed after {elapsed:.2f}s: {str(e)}") raise HTTPException(status_code=400, detail=str(e)) except Exception as e: + elapsed = time.time() - start_time + logger.error(f"Unexpected error after {elapsed:.2f}s: {str(e)}") raise HTTPException(status_code=500, detail=f"Unexpected error: {str(e)}") @@ -1384,5 +1407,11 @@ async def root(): app, host="0.0.0.0", port=8000, - log_level="info" + log_level="info", + timeout_keep_alive=30, + timeout_graceful_shutdown=10, + # Prevent hanging connections from blocking the server + limit_concurrency=100, + # Add request timeout to prevent indefinite hangs + timeout_notify=30 ) \ No newline at end of file diff --git a/universal/fix_bash_server.py b/universal/fix_bash_server.py new file mode 100644 index 000000000..668ad5c7e --- /dev/null +++ b/universal/fix_bash_server.py @@ -0,0 +1,118 @@ +#!/usr/bin/env python3 +""" +Script to fix async bottlenecks in bash_server.py +""" + +def fix_bash_server(): + with open('bash_server.py', 'r') as f: + content = f.read() + + # 1. Add logging imports + if 'import logging' not in content: + content = content.replace( + 'import uvicorn', + 'import uvicorn\nimport logging\nimport time\n\n# Configure logging for performance monitoring\nlogging.basicConfig(level=logging.INFO)\nlogger = logging.getLogger(__name__)' + ) + + # 2. Add file size constant to FileTool class + content = content.replace( + 'class FileTool(BaseAnthropicTool):\n """\n A filesystem editor tool that allows the agent to view, create, and edit files.\n """\n\n name: ClassVar[Literal["file"]] = "file"', + 'class FileTool(BaseAnthropicTool):\n """\n A filesystem editor tool that allows the agent to view, create, and edit files.\n """\n\n name: ClassVar[Literal["file"]] = "file"\n # Maximum file size (in bytes) to store in history to prevent memory issues\n MAX_HISTORY_FILE_SIZE = 1024 * 1024 # 1MB' + ) + + # 3. Add history helper method after _ensure_base_path_exists + history_method = ''' + def _add_to_history(self, full_path: Path, content: str) -> None: + """Add content to file history with size limits to prevent memory issues""" + # Skip history for very large files to prevent memory problems + if len(content.encode("utf-8")) > self.MAX_HISTORY_FILE_SIZE: + # Keep only the most recent version for large files + self._file_history[full_path] = [] + return + + self._file_history[full_path].append(content) + if len(self._file_history[full_path]) > 5: + self._file_history[full_path].pop(0) +''' + + content = content.replace( + 'async def _validate_path(self, path: str) -> Path:', + history_method + '\n async def _validate_path(self, path: str) -> Path:' + ) + + # 4. Replace direct history usage with helper method + content = content.replace( + 'self._file_history[full_path].append(content)\n if len(self._file_history[full_path]) > 5:\n self._file_history[full_path].pop(0)', + 'self._add_to_history(full_path, content)' + ) + + # 5. Add timing to file_action endpoint + old_file_action = '''@app.post("/file", response_model=ToolResponse) +async def file_action(request: FileRequest): + """Execute file operations""" + try: + # Convert request to kwargs, excluding None values + kwargs = request.model_dump(exclude_none=True) + result = await file_tool(**kwargs) + return _tool_result_to_response(result) + except ToolError as e: + raise HTTPException(status_code=400, detail=str(e)) + except Exception as e: + raise HTTPException(status_code=500, detail=f"Unexpected error: {str(e)}")''' + + new_file_action = '''@app.post("/file", response_model=ToolResponse) +async def file_action(request: FileRequest): + """Execute file operations""" + start_time = time.time() + try: + # Convert request to kwargs, excluding None values + kwargs = request.model_dump(exclude_none=True) + logger.info(f"Processing file command: {request.command} for path: {getattr(request, 'path', 'unknown')}") + result = await file_tool(**kwargs) + elapsed = time.time() - start_time + logger.info(f"File operation completed in {elapsed:.2f}s") + return _tool_result_to_response(result) + except ToolError as e: + elapsed = time.time() - start_time + logger.error(f"File operation failed after {elapsed:.2f}s: {str(e)}") + raise HTTPException(status_code=400, detail=str(e)) + except Exception as e: + elapsed = time.time() - start_time + logger.error(f"Unexpected error after {elapsed:.2f}s: {str(e)}") + raise HTTPException(status_code=500, detail=f"Unexpected error: {str(e)}")''' + + content = content.replace(old_file_action, new_file_action) + + # 6. Update uvicorn configuration + old_uvicorn = '''if __name__ == "__main__": + uvicorn.run( + app, + host="0.0.0.0", + port=8000, + log_level="info" + )''' + + new_uvicorn = '''if __name__ == "__main__": + uvicorn.run( + app, + host="0.0.0.0", + port=8000, + log_level="info", + timeout_keep_alive=30, + timeout_graceful_shutdown=10, + # Prevent hanging connections from blocking the server + limit_concurrency=100, + # Add request timeout to prevent indefinite hangs + timeout_notify=30 + )''' + + content = content.replace(old_uvicorn, new_uvicorn) + + # Write the fixed content + with open('bash_server.py', 'w') as f: + f.write(content) + + print("Successfully applied async bottleneck fixes to bash_server.py") + +if __name__ == '__main__': + fix_bash_server() \ No newline at end of file