From 95ab16d8dc0c846bbfd4677ec8a4dd4aef41b056 Mon Sep 17 00:00:00 2001 From: Mickael Farina Date: Wed, 7 Oct 2026 13:56:51 +0700 Subject: [PATCH] Keep the vision warmup off the dashboard's event loop _warmup_vision() ran requests.post(..., timeout=60) on the event loop 5 s after startup, so while the local model was stuck no page or API call was answered until the timeout (6 Oct: startup 23:25:31, warmup gave up 23:26:36; the dashboard took 90-129 s to answer after restarts). _vision_keepalive() did the same every 10 minutes for up to 10 s. Both now run the request in a thread. Logged: three photo routes have the same blocking pattern. Co-Authored-By: Claude Opus 5.5 --- codec_dashboard.py | 17 +++++++--- docs/known-issues.md | 12 ++++++- tests/test_startup_not_blocked.py | 53 +++++++++++++++++++++++++++++++ 3 files changed, 76 insertions(+), 6 deletions(-) create mode 100644 tests/test_startup_not_blocked.py diff --git a/codec_dashboard.py b/codec_dashboard.py index a3268ef..8ccbf4d 100644 --- a/codec_dashboard.py +++ b/codec_dashboard.py @@ -1101,9 +1101,16 @@ async def _bg_watcher(): _bg_status["watcher"]["running"] = False +_WARMUP_DELAY_S = 5 # let other services start first + + async def _warmup_vision(): - """Pre-load Qwen Vision model so first real request is fast (~7s vs ~23s cold).""" - await asyncio.sleep(5) # let other services start first + """Pre-load Qwen Vision model so first real request is fast (~7s vs ~23s cold). + + The request runs in a thread: on the event loop it blocked every page and API + call until the model answered or the 60 s timeout passed (a stuck model kept + the dashboard unreachable for over a minute after each restart).""" + await asyncio.sleep(_WARMUP_DELAY_S) try: config = {} try: @@ -1118,8 +1125,8 @@ async def _warmup_vision(): "messages": [{"role": "user", "content": "Hi"}], "max_tokens": 1, } - r = rq.post(f"{vision_url}/chat/completions", json=payload, - headers={"Content-Type": "application/json"}, timeout=60) + r = await asyncio.to_thread(rq.post, f"{vision_url}/chat/completions", json=payload, + headers={"Content-Type": "application/json"}, timeout=60) if r.status_code == 200: log.info("[WARMUP] Vision model pre-loaded successfully") else: @@ -1140,7 +1147,7 @@ async def _vision_keepalive(): pass vision_url = config.get("vision_base_url", "http://localhost:8083/v1") import requests as rq - r = rq.get(f"{vision_url}/models", timeout=10) + r = await asyncio.to_thread(rq.get, f"{vision_url}/models", timeout=10) # off the event loop if r.status_code == 200: log.debug("[KEEPALIVE] Vision model alive") except Exception: diff --git a/docs/known-issues.md b/docs/known-issues.md index f385fec..c435ef9 100644 --- a/docs/known-issues.md +++ b/docs/known-issues.md @@ -419,7 +419,7 @@ owner cannot tell why. Fix: read the JSON body's `error` or `detail` before falling back to the status code. Found in the Mac merge pass (P3.2). Revisit: any item that touches the Inbox drawer. -## The dashboard waits on the local model at startup (2026-10-07) +## The dashboard waits on the local model at startup (2026-10-07) — FIXED (the vision warmup and keep-alive run in a thread) After a restart, `codec-dashboard` took 90-129 s to answer while the local model was stuck, and 1 s once the model answered again, so a startup step most likely @@ -456,3 +456,13 @@ gate still checks it (not in the manifest, so the AST check runs). Fix: write approved skills to `~/.codec/skills/` (or set `skills_dir` there) and say so on the page. Found in the Mac merge pass (P3.12 check). Revisit: before anyone approves a skill from the Learning page. + +## Three photo routes block the dashboard while the vision model answers (2026-10-07) + +`routes/media.py:webcam_capture`, `routes/upload.py:upload_image` and +`routes/vision.py:vision_analyze` are `async` but call the vision model with +blocking `requests`, so while a picture is analysed (about 7-23 s) the dashboard +answers nothing else: pages, polls and the voice socket wait. Same class as the +startup warmup fixed on 2026-10-07. Fix: `await asyncio.to_thread(requests.post, ...)` +in each. Found by an AST scan of async routes. Revisit: the next item that touches +photos or vision. diff --git a/tests/test_startup_not_blocked.py b/tests/test_startup_not_blocked.py new file mode 100644 index 0000000..854489a --- /dev/null +++ b/tests/test_startup_not_blocked.py @@ -0,0 +1,53 @@ +"""The vision warmup does not block the dashboard (docs/known-issues.md, 2026-10-07). + +`_warmup_vision()` ran `requests.post(..., timeout=60)` on the event loop 5 s after +startup, so while the local model was stuck no page or API call was answered until +the timeout: on 6 Oct the log shows startup at 23:25:31 and the warmup giving up at +23:26:36. The requests now run in a thread. +""" +from __future__ import annotations + +import asyncio +import time + + +class _Resp: + status_code = 200 + + +def test_the_vision_warmup_leaves_the_event_loop_free(monkeypatch): + import requests + + import codec_dashboard as dash + monkeypatch.setattr(dash, "_WARMUP_DELAY_S", 0) + + def slow_post(*a, **k): + time.sleep(0.6) # a slow model + return _Resp() + + monkeypatch.setattr(requests, "post", slow_post) + + async def main(): + ticks = [] + + async def ticker(): + for _ in range(12): + ticks.append(time.monotonic()) + await asyncio.sleep(0.05) + + t = asyncio.create_task(ticker()) + await dash._warmup_vision() + await t + return ticks + + ticks = asyncio.run(main()) + gaps = [b - a for a, b in zip(ticks, ticks[1:])] + assert max(gaps) < 0.3, f"the loop stalled for {max(gaps):.2f} s during the warmup" + + +def test_the_keepalive_ping_is_off_the_event_loop_too(): + import inspect + + import codec_dashboard as dash + src = inspect.getsource(dash._vision_keepalive) + assert "asyncio.to_thread(rq.get" in src and "= rq.get(" not in src