Skip to content

Commit d362683

Browse files
authored
feat: Make model loading non-blocking in MCP serve (#136)
1 parent 4ce18fc commit d362683

5 files changed

Lines changed: 146 additions & 38 deletions

File tree

src/semble/index/dense.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@ def load_model(model_path: str | None = None) -> Encoder:
2020
# Disable HF progress bars since the model is loaded silently in the background during indexing.
2121
disable_progress_bars()
2222
try:
23-
model = StaticModel.from_pretrained(model_path)
23+
model = StaticModel.from_pretrained(model_path, force_download=False)
2424
finally:
2525
disable_progress_bars()
2626
return cast(Encoder, model)

src/semble/mcp.py

Lines changed: 64 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -114,27 +114,57 @@ async def find_related(
114114

115115
async def serve(path: str | None = None, ref: str | None = None, include_text_files: bool = False) -> None:
116116
"""Start an MCP stdio server, optionally pre-indexing a default source."""
117-
model = await asyncio.to_thread(load_model)
118-
cache = _IndexCache(model=model, include_text_files=include_text_files)
119-
if path:
120-
await cache.get(path, ref=ref)
121-
if not _is_git_url(path):
122-
await cache.start_watcher(path)
117+
cache = _IndexCache(include_text_files=include_text_files)
123118

119+
async def _load_and_prewarm() -> None:
120+
"""Pre-load the model and optionally pre-index the default source in parallel with starting the server."""
121+
try:
122+
cache._model = await asyncio.to_thread(load_model)
123+
except Exception as exc:
124+
logger.exception("Failed to load embedding model")
125+
cache._model_error = exc
126+
return
127+
finally:
128+
cache._model_ready.set()
129+
if path:
130+
try:
131+
await cache.get(path, ref=ref)
132+
except Exception:
133+
logger.warning("Failed to pre-index %r at startup", path, exc_info=True)
134+
if not _is_git_url(path):
135+
await cache.start_watcher(path)
136+
137+
init_task = asyncio.create_task(_load_and_prewarm())
124138
server = create_server(cache, default_source=path)
125-
await server.run_stdio_async()
139+
try:
140+
await server.run_stdio_async()
141+
finally:
142+
if not init_task.done():
143+
init_task.cancel()
126144

127145

128146
class _IndexCache:
129147
"""Cache of indexed repos and local paths for the lifetime of the MCP server process."""
130148

131-
def __init__(self, model: Encoder, include_text_files: bool = False) -> None:
132-
"""Initialise an empty cache with a shared embedding model."""
133-
self._model = model
149+
def __init__(self, model: Encoder | None = None, include_text_files: bool = False) -> None:
150+
"""Initialise an empty cache."""
151+
self._model: Encoder | None = model
152+
self._model_error: BaseException | None = None
153+
self._model_ready = asyncio.Event()
154+
if model is not None:
155+
self._model_ready.set()
134156
self._include_text_files = include_text_files
135157
self._tasks: OrderedDict[str, asyncio.Task[SembleIndex]] = OrderedDict() # ordered for LRU eviction
136158
self._watcher_task: asyncio.Task[None] | None = None
137159

160+
async def _await_model(self) -> Encoder:
161+
"""Block until the model is installed; re-raise the load error if it failed."""
162+
await self._model_ready.wait()
163+
if self._model_error is not None:
164+
raise self._model_error
165+
assert self._model is not None
166+
return self._model
167+
138168
def _compute_cache_key(self, source: str, ref: str | None = None) -> str:
139169
"""Compute the canonical cache key for a source."""
140170
is_git = _is_git_url(source)
@@ -163,27 +193,32 @@ async def get(self, source: str, ref: str | None = None) -> SembleIndex:
163193
"""Return an index for the requested source, building and caching it on first access."""
164194
cache_key = self._compute_cache_key(source, ref)
165195

166-
if cache_key in self._tasks:
167-
self._tasks.move_to_end(cache_key)
168-
else:
169-
if len(self._tasks) >= _CACHE_MAX_SIZE:
170-
self._tasks.popitem(last=False)
171-
if _is_git_url(source):
172-
self._tasks[cache_key] = asyncio.create_task(
173-
asyncio.to_thread(
174-
SembleIndex.from_git,
175-
source,
176-
ref=ref,
177-
model=self._model,
178-
include_text_files=self._include_text_files,
196+
if cache_key not in self._tasks:
197+
model = await self._await_model()
198+
# Re-check after the await: another caller may have populated the entry.
199+
if cache_key not in self._tasks:
200+
if len(self._tasks) >= _CACHE_MAX_SIZE:
201+
self._tasks.popitem(last=False)
202+
if _is_git_url(source):
203+
self._tasks[cache_key] = asyncio.create_task(
204+
asyncio.to_thread(
205+
SembleIndex.from_git,
206+
source,
207+
ref=ref,
208+
model=model,
209+
include_text_files=self._include_text_files,
210+
)
179211
)
180-
)
181-
else:
182-
self._tasks[cache_key] = asyncio.create_task(
183-
asyncio.to_thread(
184-
SembleIndex.from_path, cache_key, model=self._model, include_text_files=self._include_text_files
212+
else:
213+
self._tasks[cache_key] = asyncio.create_task(
214+
asyncio.to_thread(
215+
SembleIndex.from_path,
216+
cache_key,
217+
model=model,
218+
include_text_files=self._include_text_files,
219+
)
185220
)
186-
)
221+
self._tasks.move_to_end(cache_key)
187222
task = self._tasks[cache_key]
188223
try:
189224
return await asyncio.shield(task)

tests/test_mcp.py

Lines changed: 79 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
import asyncio
2+
import threading
13
from pathlib import Path
24
from typing import Any, AsyncGenerator
35
from unittest.mock import AsyncMock, MagicMock, patch
@@ -240,20 +242,91 @@ async def test_tool_output(
240242

241243

242244
@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()}
246271
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),
249274
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,
251276
):
252277
await (serve(str(tmp_path)) if with_path else serve())
253278

254279
mock_run.assert_called_once()
255280

256281

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+
257330
@pytest.mark.anyio
258331
@pytest.mark.parametrize(
259332
("repo", "tool", "extra_args"),

tests/test_search.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -142,7 +142,7 @@ def test_load_model(model_path: str | None, expected_call_arg: str) -> None:
142142
fake_model = MagicMock(spec=Encoder)
143143
with patch("semble.index.dense.StaticModel.from_pretrained", return_value=fake_model) as mock_fp:
144144
result = load_model(model_path)
145-
mock_fp.assert_called_once_with(expected_call_arg)
145+
mock_fp.assert_called_once_with(expected_call_arg, force_download=False)
146146
assert result is fake_model
147147

148148

uv.lock

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)