diff --git a/src/mip_mcp/executor/pyodide_executor.py b/src/mip_mcp/executor/pyodide_executor.py index 7671000..6944fd4 100644 --- a/src/mip_mcp/executor/pyodide_executor.py +++ b/src/mip_mcp/executor/pyodide_executor.py @@ -42,7 +42,11 @@ def __init__(self, config: dict[str, Any]): "execution_timeout", 60.0 ) # Execution timeout for MCP - logger.info("Pyodide executor initialized") + # Create isolated temporary directory for this executor instance + self.temp_dir = tempfile.mkdtemp(prefix="mip_mcp_executor_") + logger.info( + f"Pyodide executor initialized with isolated temp dir: {self.temp_dir}" + ) def set_progress_callback( self, callback: Callable[[SolverProgress], None] | None @@ -213,6 +217,21 @@ async def _initialize_pyodide(self) -> None: f"Pyodide initialization failed: {init_result.get('error', 'unknown error')}" ) + # Mount the isolated temp directory + mount_result = await self._communicate_with_pyodide( + {"action": "mount", "path": self.temp_dir} + ) + + if not mount_result.get("success"): + logger.warning( + f"Failed to mount temp directory: {mount_result.get('error')}" + ) + # Continue anyway, fallback to virtual filesystem + else: + logger.info( + f"Mounted temp directory {self.temp_dir} to /mnt in Pyodide" + ) + self._pyodide_initialized = True logger.info("Pyodide environment initialized successfully") @@ -363,6 +382,17 @@ def _get_pyodide_script(self, pyodide_path: str) -> str: } } +async function mountTempDir(tempDirPath) { + try { + // Mount the host temp directory to /mnt in Pyodide filesystem + pyodide.FS.mkdir('/mnt'); + pyodide.FS.mount(pyodide.FS.filesystems.NODEFS, { root: tempDirPath }, '/mnt'); + return { success: true }; + } catch (error) { + return { success: false, error: error.message }; + } +} + async function executePython(code) { try { const result = await pyodide.runPythonAsync(code); @@ -424,6 +454,9 @@ def _get_pyodide_script(self, pyodide_path: str) -> str: case 'init': response = await initPyodide(); break; + case 'mount': + response = await mountTempDir(request.path); + break; case 'execute': response = await executePython(request.code); break; @@ -597,22 +630,34 @@ async def execute_mip_code( detected_library, ) - # Extract content (LP preferred, then MPS) - lp_content = json_data.get("lp_content") - mps_content = json_data.get("mps_content") - - # Automatic format detection: LP preferred, then MPS - content = None + # Get file paths from Pyodide execution and map to host filesystem + lp_file_path = json_data.get("lp_file_path") + mps_file_path = json_data.get("mps_file_path") + + # Map Pyodide paths (/mnt/...) to host filesystem paths + host_lp_path = None + host_mps_path = None + if lp_file_path and lp_file_path.startswith("/mnt/"): + host_lp_path = str( + Path(self.temp_dir) / lp_file_path[5:] + ) # Remove /mnt/ prefix + if mps_file_path and mps_file_path.startswith("/mnt/"): + host_mps_path = str( + Path(self.temp_dir) / mps_file_path[5:] + ) # Remove /mnt/ prefix + + # Determine which file to use (LP preferred, then MPS) + source_file_path = None file_format = None - if lp_content: - content = lp_content + if host_lp_path and Path(host_lp_path).exists(): + source_file_path = host_lp_path file_format = "lp" - elif mps_content: - content = mps_content + elif host_mps_path and Path(host_mps_path).exists(): + source_file_path = host_mps_path file_format = "mps" - if not content: - # Check if there were problems but failed to generate content + if not source_file_path: + # Check if there were problems but failed to generate files problems_info = json_data.get("problems_info", []) if problems_info: problem_errors = [ @@ -622,25 +667,25 @@ async def execute_mip_code( error_details = "; ".join(problem_errors) return ( json_data.get("stdout", ""), - f"Problem found but failed to generate content: {error_details}", + f"Problem found but failed to generate files: {error_details}", None, detected_library, ) return ( json_data.get("stdout", ""), - "No optimization file content generated", + "No optimization file generated", None, detected_library, ) - # Send progress for file generation + # Send progress for file processing self._send_progress( - "modeling", f"Generating {file_format.upper()} optimization file" + "modeling", f"Processing {file_format.upper()} optimization file" ) - # Write content to temporary file - temp_file = self._create_temp_file(content, file_format) + # Copy file from isolated directory to a new temporary file for return + temp_file = self._copy_optimization_file(source_file_path, file_format) # Send final modeling progress self._send_progress( @@ -689,6 +734,9 @@ def _prepare_execution_code( if data: data_setup = f"__data__ = {json.dumps(data)}\n" + # Pass isolated temp directory to Pyodide + temp_dir_setup = f"__temp_dir__ = '{self.temp_dir}'\n" + wrapper_code = f""" import io import sys @@ -702,6 +750,8 @@ def _prepare_execution_code( # Setup data if provided {data_setup} +# Setup isolated temp directory +{temp_dir_setup} def __convert_to_json_safe(obj, visited=None): '''Convert objects to JSON-safe format, handling tuple keys and complex objects.''' @@ -748,39 +798,24 @@ def __convert_to_json_safe(obj, visited=None): visited.discard(obj_id) def __extract_problem_info(globals_dict): - '''Extract PuLP problem information and generate LP/MPS content.''' + '''Extract PuLP problem information and write files to mounted filesystem.''' problems_info = [] - lp_content = None - mps_content = None + lp_file_path = None + mps_file_path = None for name, obj in globals_dict.items(): if hasattr(obj, 'writeLP') and hasattr(obj, 'writeMPS'): try: - # Generate LP content - import tempfile - import os - - # Create temporary files for LP and MPS - with tempfile.NamedTemporaryFile(mode='w', suffix='.lp', delete=False) as lp_file: - lp_path = lp_file.name - - with tempfile.NamedTemporaryFile(mode='w', suffix='.mps', delete=False) as mps_file: - mps_path = mps_file.name - - # Write LP and MPS files - obj.writeLP(lp_path) - obj.writeMPS(mps_path) + import time - # Read content - with open(lp_path, 'r') as f: - lp_content = f.read() + # Use mounted filesystem at /mnt with unique filenames + unique_id = str(int(time.time() * 1000000) % 100000000) + lp_file_path = f"/mnt/problem_{{unique_id}}.lp" + mps_file_path = f"/mnt/problem_{{unique_id}}.mps" - with open(mps_path, 'r') as f: - mps_content = f.read() - - # Clean up temporary files - os.unlink(lp_path) - os.unlink(mps_path) + # Write LP and MPS files to mounted filesystem (accessible from host) + obj.writeLP(lp_file_path) + obj.writeMPS(mps_file_path) problem_info = {{ 'name': name, @@ -788,7 +823,9 @@ def __extract_problem_info(globals_dict): 'status': str(getattr(obj, 'status', 'unknown')), 'num_variables': len(getattr(obj, 'variables', [])), 'num_constraints': len(getattr(obj, 'constraints', [])), - 'objective': str(getattr(obj, 'objective', 'none')) + 'objective': str(getattr(obj, 'objective', 'none')), + 'lp_file_path': lp_file_path, + 'mps_file_path': mps_file_path }} problems_info.append(problem_info) @@ -801,7 +838,7 @@ def __extract_problem_info(globals_dict): 'error': f"Failed to extract problem info: {{e}}" }}) - return problems_info, lp_content, mps_content + return problems_info, lp_file_path, mps_file_path try: # Execute user code @@ -814,27 +851,15 @@ def __extract_problem_info(globals_dict): # Extract and serialize all relevant information as JSON __globals_copy = dict(globals()) - # Find and extract PuLP problem information - __problems_info, __lp_content, __mps_content = __extract_problem_info(__globals_copy) - - # Convert user variables to JSON-safe format - __variables_info = {{}} - for __var_name in list(__globals_copy.keys()): - if (not __var_name.startswith('_') and - __var_name not in ['pulp', 'io', 'sys', 'tempfile', 'json'] and - not callable(__globals_copy[__var_name])): - try: - __variables_info[__var_name] = __convert_to_json_safe(__globals_copy[__var_name]) - except Exception as __e: - __variables_info[__var_name] = f"" + # Find and extract PuLP problem information (writes files to virtual fs) + __problems_info, __lp_file_path, __mps_file_path = __extract_problem_info(__globals_copy) - # Create comprehensive result data + # Create result data with file paths (avoids large JSON) __result_data = {{ 'stdout': __stdout__, - 'lp_content': __lp_content, - 'mps_content': __mps_content, + 'lp_file_path': __lp_file_path, + 'mps_file_path': __mps_file_path, 'problems_info': __problems_info, - 'variables_info': __variables_info, 'execution_status': 'success' }} @@ -852,8 +877,8 @@ def __extract_problem_info(globals_dict): # Create error result data __result_data = {{ 'stdout': __stdout__, - 'lp_content': None, - 'mps_content': None, + 'lp_file_path': None, + 'mps_file_path': None, 'problems_info': [], 'variables_info': {{}}, 'execution_status': 'error', @@ -917,23 +942,29 @@ def _indent_code(self, code: str, spaces: int) -> str: indent = " " * spaces return "\n".join(indent + line for line in code.split("\n")) - def _create_temp_file(self, content: str, format_type: str) -> str: - """Create temporary file with the given content. + def _copy_optimization_file(self, source_path: str, format_type: str) -> str: + """Copy optimization file from isolated directory to new temporary file. Args: - content: File content + source_path: Path to source file in isolated directory format_type: File format ("lp" or "mps") Returns: - Path to temporary file + Path to new temporary file """ + import shutil + suffix = f".{format_type.lower()}" with tempfile.NamedTemporaryFile(mode="w", suffix=suffix, delete=False) as f: - f.write(content) temp_path = f.name - logger.info(f"Created temporary {format_type.upper()} file: {temp_path}") + # Copy the file content + shutil.copy2(source_path, temp_path) + + logger.info( + f"Copied {format_type.upper()} file from {source_path} to {temp_path}" + ) return temp_path async def validate_code(self, code: str) -> dict[str, Any]: @@ -1060,6 +1091,20 @@ async def cleanup(self): self._pyodide_initialized = False logger.info("Pyodide process cleanup completed") + # Clean up isolated temporary directory + if hasattr(self, "temp_dir") and self.temp_dir: + try: + import shutil + + shutil.rmtree(self.temp_dir, ignore_errors=True) + logger.info(f"Cleaned up isolated temp directory: {self.temp_dir}") + except Exception as e: + logger.warning( + f"Failed to clean up temp directory {self.temp_dir}: {e}" + ) + finally: + self.temp_dir = None + def __del__(self): """Cleanup on destruction.""" if self.pyodide_process and not self._cleanup_started: diff --git a/tests/unit/test_tuple_key_fix.py b/tests/unit/test_tuple_key_fix.py index f279f4b..6450e8f 100644 --- a/tests/unit/test_tuple_key_fix.py +++ b/tests/unit/test_tuple_key_fix.py @@ -82,26 +82,25 @@ async def test_tuple_key_execution_success(self, executor): executor, "_execute_with_periodic_progress", new_callable=AsyncMock ) as mock_execute, ): - # Mock successful JSON-based execution + # Mock successful filesystem-based execution mock_execute.return_value = { "success": True, "json_data": { "execution_status": "success", "stdout": "Tuple key test completed successfully", - "lp_content": "\\* test *\\\nMinimize\nOBJ: x_0_a + x_0_b + x_1_a + x_1_b\nSubject To\n_C1: x_0_a + x_0_b + x_1_a + x_1_b >= 1\nBinaries\nx_0_a\nx_0_b\nx_1_a\nx_1_b\nEnd", - "mps_content": None, + "lp_file_path": "/mnt/problem_12345.lp", + "mps_file_path": "/mnt/problem_12345.mps", "problems_info": [{"name": "prob", "num_variables": 4}], - "variables_info": { - "var_map": { - "0_a": "x_0_a", - "0_b": "x_0_b", - "1_a": "x_1_a", - "1_b": "x_1_b", - } - }, }, } + # Create mock files in the executor's temp directory + from pathlib import Path + + lp_content = "\\* test *\\\nMinimize\nOBJ: x_0_a + x_0_b + x_1_a + x_1_b\nSubject To\n_C1: x_0_a + x_0_b + x_1_a + x_1_b >= 1\nBinaries\nx_0_a\nx_0_b\nx_1_a\nx_1_b\nEnd" + mock_lp_file = Path(executor.temp_dir) / "problem_12345.lp" + mock_lp_file.write_text(lp_content) + stdout, stderr, file_path, library = await executor.execute_mip_code( tuple_key_code )