|
| 1 | +import asyncio |
| 2 | +import threading |
1 | 3 | from pathlib import Path |
2 | 4 | from typing import Any, AsyncGenerator |
3 | 5 | from unittest.mock import AsyncMock, MagicMock, patch |
@@ -240,20 +242,91 @@ async def test_tool_output( |
240 | 242 |
|
241 | 243 |
|
242 | 244 | @pytest.mark.anyio |
243 | | -@pytest.mark.parametrize("with_path", [True, False], ids=["pre_index", "no_path"]) |
244 | | -async def test_serve_runs_stdio(tmp_path: Path, with_path: bool) -> None: |
245 | | - """serve() loads the model, runs stdio, and optionally pre-indexes when a path is given.""" |
| 245 | +@pytest.mark.parametrize( |
| 246 | + ("with_path", "load_err", "from_path_err", "stdio_yields"), |
| 247 | + [ |
| 248 | + (True, None, None, True), |
| 249 | + (False, None, None, True), |
| 250 | + (False, RuntimeError("boom"), None, True), |
| 251 | + (True, None, RuntimeError("boom"), True), |
| 252 | + (False, None, None, False), |
| 253 | + ], |
| 254 | + ids=["pre_index", "no_path", "model_load_fails", "prewarm_fails", "cancel_pending_init"], |
| 255 | +) |
| 256 | +async def test_serve_runs_stdio( |
| 257 | + tmp_path: Path, |
| 258 | + with_path: bool, |
| 259 | + load_err: Exception | None, |
| 260 | + from_path_err: Exception | None, |
| 261 | + stdio_yields: bool, |
| 262 | +) -> None: |
| 263 | + """serve() runs stdio and handles all background init outcomes without raising.""" |
| 264 | + |
| 265 | + async def fake_stdio() -> None: |
| 266 | + if stdio_yields: |
| 267 | + await asyncio.sleep(0.05) # let the background init task run |
| 268 | + |
| 269 | + load_kwargs = {"side_effect": load_err} if load_err else {"return_value": MagicMock(spec=Encoder)} |
| 270 | + fp_kwargs = {"side_effect": from_path_err} if from_path_err else {"return_value": MagicMock()} |
246 | 271 | with ( |
247 | | - patch("semble.mcp.load_model", return_value=MagicMock(spec=Encoder)), |
248 | | - patch("semble.mcp.SembleIndex.from_path", return_value=MagicMock()), |
| 272 | + patch("semble.mcp.load_model", **load_kwargs), |
| 273 | + patch("semble.mcp.SembleIndex.from_path", **fp_kwargs), |
249 | 274 | patch.object(_IndexCache, "start_watcher", new_callable=AsyncMock), |
250 | | - patch("mcp.server.fastmcp.FastMCP.run_stdio_async", new_callable=AsyncMock) as mock_run, |
| 275 | + patch("mcp.server.fastmcp.FastMCP.run_stdio_async", side_effect=fake_stdio) as mock_run, |
251 | 276 | ): |
252 | 277 | await (serve(str(tmp_path)) if with_path else serve()) |
253 | 278 |
|
254 | 279 | mock_run.assert_called_once() |
255 | 280 |
|
256 | 281 |
|
| 282 | +@pytest.mark.anyio |
| 283 | +async def test_serve_opens_stdio_before_model_loads() -> None: |
| 284 | + """Stdio must open before load_model() finishes.""" |
| 285 | + stdio_opened = threading.Event() |
| 286 | + |
| 287 | + def blocking_load_model() -> Encoder: |
| 288 | + assert stdio_opened.wait(timeout=1.0), "stdio did not open" |
| 289 | + return MagicMock(spec=Encoder) |
| 290 | + |
| 291 | + async def fake_run_stdio() -> None: |
| 292 | + stdio_opened.set() |
| 293 | + await asyncio.sleep(0.05) |
| 294 | + |
| 295 | + with ( |
| 296 | + patch("semble.mcp.load_model", side_effect=blocking_load_model), |
| 297 | + patch("mcp.server.fastmcp.FastMCP.run_stdio_async", side_effect=fake_run_stdio), |
| 298 | + ): |
| 299 | + await serve() |
| 300 | + |
| 301 | + |
| 302 | +@pytest.mark.anyio |
| 303 | +async def test_index_cache_awaits_model(tmp_path: Path) -> None: |
| 304 | + """get() blocks until the model is installed, then proceeds.""" |
| 305 | + cache = _IndexCache() # no model yet |
| 306 | + fake_index = MagicMock() |
| 307 | + with patch("semble.mcp.SembleIndex.from_path", return_value=fake_index): |
| 308 | + get_task = asyncio.create_task(cache.get(str(tmp_path))) |
| 309 | + await asyncio.sleep(0.01) |
| 310 | + assert not get_task.done(), "get() must block until the model is installed" |
| 311 | + cache._model = MagicMock(spec=Encoder) |
| 312 | + cache._model_ready.set() |
| 313 | + result = await asyncio.wait_for(get_task, timeout=1.0) |
| 314 | + assert result is fake_index |
| 315 | + |
| 316 | + |
| 317 | +@pytest.mark.anyio |
| 318 | +async def test_index_cache_propagates_model_error(tmp_path: Path) -> None: |
| 319 | + """If model load fails, awaiting tool calls re-raise the original exception.""" |
| 320 | + cache = _IndexCache() |
| 321 | + get_task = asyncio.create_task(cache.get(str(tmp_path))) |
| 322 | + await asyncio.sleep(0.01) |
| 323 | + assert not get_task.done() |
| 324 | + cache._model_error = RuntimeError("HF download failed") |
| 325 | + cache._model_ready.set() |
| 326 | + with pytest.raises(RuntimeError, match="HF download failed"): |
| 327 | + await asyncio.wait_for(get_task, timeout=1.0) |
| 328 | + |
| 329 | + |
257 | 330 | @pytest.mark.anyio |
258 | 331 | @pytest.mark.parametrize( |
259 | 332 | ("repo", "tool", "extra_args"), |
|
0 commit comments