|
4 | 4 |
|
5 | 5 | import asyncio |
6 | 6 | import os |
| 7 | +from pathlib import Path |
7 | 8 | import tempfile |
8 | 9 | import unittest |
9 | 10 | from unittest.mock import Mock, patch, MagicMock |
10 | 11 | import time |
11 | | -from concurrent.futures import Future |
| 12 | +from concurrent.futures import Future, ProcessPoolExecutor |
| 13 | + |
| 14 | + |
| 15 | +def _slow_test_worker(marker_path: str) -> str: |
| 16 | + Path(marker_path).write_text(str(os.getpid())) |
| 17 | + time.sleep(5) |
| 18 | + return "finished" |
| 19 | + |
12 | 20 |
|
13 | 21 | # Set dummy API key for testing |
14 | 22 | os.environ["OPENAI_API_KEY"] = "test" |
15 | 23 |
|
16 | 24 | from openevolve.config import Config, DatabaseConfig, EvaluatorConfig, LLMConfig, PromptConfig |
17 | 25 | from openevolve.database import Program, ProgramDatabase |
| 26 | +from openevolve import process_parallel as process_parallel_module |
18 | 27 | from openevolve.process_parallel import ProcessParallelController, SerializableResult |
19 | 28 |
|
20 | 29 |
|
@@ -86,6 +95,55 @@ def test_controller_start_stop(self): |
86 | 95 | self.assertIsNone(controller.executor) |
87 | 96 | self.assertTrue(controller.shutdown_event.is_set()) |
88 | 97 |
|
| 98 | + def test_controller_stop_terminates_running_workers(self): |
| 99 | + """Stopping the controller does not wait for stuck process-pool work.""" |
| 100 | + controller = ProcessParallelController(self.config, self.eval_file, self.database) |
| 101 | + executor = ProcessPoolExecutor(max_workers=1) |
| 102 | + controller.executor = executor |
| 103 | + marker_path = os.path.join(self.test_dir, "worker.pid") |
| 104 | + future = executor.submit(_slow_test_worker, marker_path) |
| 105 | + |
| 106 | + deadline = time.monotonic() + 5 |
| 107 | + while not os.path.exists(marker_path) and time.monotonic() < deadline: |
| 108 | + time.sleep(0.01) |
| 109 | + self.assertTrue(os.path.exists(marker_path)) |
| 110 | + worker_pid = int(Path(marker_path).read_text()) |
| 111 | + |
| 112 | + started = time.monotonic() |
| 113 | + controller.stop() |
| 114 | + elapsed = time.monotonic() - started |
| 115 | + |
| 116 | + self.assertLess(elapsed, 1) |
| 117 | + self.assertIsNone(controller.executor) |
| 118 | + self.assertTrue(controller.shutdown_event.is_set()) |
| 119 | + deadline = time.monotonic() + 1 |
| 120 | + while not future.done() and time.monotonic() < deadline: |
| 121 | + time.sleep(0.01) |
| 122 | + self.assertTrue(future.done()) |
| 123 | + with self.assertRaises(ProcessLookupError): |
| 124 | + os.kill(worker_pid, 0) |
| 125 | + |
| 126 | + # Cleanup is idempotent after the executor reference is cleared. |
| 127 | + controller.stop() |
| 128 | + |
| 129 | + def test_process_pool_shutdown_escalates_to_kill(self): |
| 130 | + """Workers still alive after terminate are killed before returning.""" |
| 131 | + process = Mock() |
| 132 | + process.is_alive.return_value = True |
| 133 | + executor = Mock(spec=["_processes", "shutdown"]) |
| 134 | + executor._processes = {123: process} |
| 135 | + |
| 136 | + with patch.object( |
| 137 | + process_parallel_module, |
| 138 | + "_wait_for_processes", |
| 139 | + side_effect=[[process], []], |
| 140 | + ): |
| 141 | + process_parallel_module._terminate_process_pool(executor) |
| 142 | + |
| 143 | + executor.shutdown.assert_called_once_with(wait=False, cancel_futures=True) |
| 144 | + process.terminate.assert_called_once_with() |
| 145 | + process.kill.assert_called_once_with() |
| 146 | + |
89 | 147 | def test_database_snapshot_creation(self): |
90 | 148 | """Test creating database snapshot for workers""" |
91 | 149 | controller = ProcessParallelController(self.config, self.eval_file, self.database) |
|
0 commit comments