|
10 | 10 | import queue |
11 | 11 | import signal |
12 | 12 | import threading |
| 13 | +from unittest.mock import Mock, patch |
13 | 14 |
|
14 | 15 | import pytest |
15 | 16 |
|
16 | 17 | from conductor.client.automator import task_handler, worker_isolation |
| 18 | +from conductor.client.automator.task_handler import TaskHandler |
| 19 | +from conductor.client.automator.task_runner import TaskRunner |
| 20 | +from conductor.client.configuration.configuration import Configuration |
| 21 | +from tests.unit.resources.workers import ClassWorker |
17 | 22 | from conductor.client.automator.worker_isolation import ( |
18 | 23 | ISOLATION_PROCESS, |
19 | 24 | ISOLATION_THREAD, |
@@ -158,3 +163,76 @@ def target(a, b=None): |
158 | 163 | p.start() |
159 | 164 | p.join(timeout=5) |
160 | 165 | assert seen == {"a": 1, "b": 2} |
| 166 | + |
| 167 | + |
| 168 | +# ── TaskHandler gate (G2 / G5) ─────────────────────────────────────────────── |
| 169 | + |
| 170 | + |
| 171 | +def _stop_logger(th): |
| 172 | + """End the logger thread via its None sentinel (same as the SDK's atexit).""" |
| 173 | + th.queue.put(None) |
| 174 | + th.logger_process.join(timeout=2) |
| 175 | + |
| 176 | + |
| 177 | +def test_task_handler_construction_applies_thread_isolation(monkeypatch, restore_globals): |
| 178 | + monkeypatch.setenv(WORKER_ISOLATION_ENV, "thread") |
| 179 | + th = TaskHandler( |
| 180 | + configuration=Configuration(), |
| 181 | + workers=[], |
| 182 | + scan_for_annotated_workers=False, |
| 183 | + monitor_processes=False, |
| 184 | + ) |
| 185 | + try: |
| 186 | + assert task_handler.Process is _ThreadAsProcess |
| 187 | + assert task_handler.Queue is queue.Queue |
| 188 | + assert isinstance(th.queue, queue.Queue) |
| 189 | + assert isinstance(th.logger_process, _ThreadAsProcess) |
| 190 | + finally: |
| 191 | + _stop_logger(th) |
| 192 | + |
| 193 | + |
| 194 | +def test_task_handler_default_mode_untouched(monkeypatch, restore_globals): |
| 195 | + monkeypatch.delenv(WORKER_ISOLATION_ENV, raising=False) |
| 196 | + th = TaskHandler( |
| 197 | + configuration=Configuration(), |
| 198 | + workers=[], |
| 199 | + scan_for_annotated_workers=False, |
| 200 | + monitor_processes=False, |
| 201 | + ) |
| 202 | + try: |
| 203 | + assert task_handler.Process is multiprocessing.Process |
| 204 | + assert task_handler.Queue is multiprocessing.Queue |
| 205 | + assert not getattr(task_handler, "_thread_isolation_applied", False) |
| 206 | + assert isinstance(th.logger_process, multiprocessing.Process) |
| 207 | + finally: |
| 208 | + th.queue.put(None) |
| 209 | + th.logger_process.join(timeout=5) |
| 210 | + |
| 211 | + |
| 212 | +def test_thread_mode_start_stop_smoke(monkeypatch, restore_globals): |
| 213 | + """G5: full worker lifecycle in thread mode — no signal ValueError.""" |
| 214 | + monkeypatch.setenv(WORKER_ISOLATION_ENV, "thread") |
| 215 | + thread_errors = [] |
| 216 | + monkeypatch.setattr(threading, "excepthook", lambda args: thread_errors.append(args)) |
| 217 | + |
| 218 | + with patch.object(TaskRunner, "run", Mock(return_value=None)): |
| 219 | + th = TaskHandler( |
| 220 | + configuration=Configuration(), |
| 221 | + workers=[ClassWorker("task")], |
| 222 | + scan_for_annotated_workers=False, |
| 223 | + monitor_processes=False, |
| 224 | + ) |
| 225 | + try: |
| 226 | + th.start_processes() |
| 227 | + assert len(th.task_runner_processes) == 1 |
| 228 | + for p in th.task_runner_processes: |
| 229 | + assert isinstance(p, _ThreadAsProcess) |
| 230 | + # run() is mocked to return, so the target must complete — |
| 231 | + # proving signal.signal + TaskRunner construction worked in |
| 232 | + # a non-main thread. |
| 233 | + p.join(timeout=5) |
| 234 | + assert not p.is_alive() |
| 235 | + th.stop_processes() |
| 236 | + finally: |
| 237 | + _stop_logger(th) |
| 238 | + assert thread_errors == [] |
0 commit comments