Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 12 additions & 5 deletions codec_dashboard.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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:
Expand All @@ -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:
Expand Down
12 changes: 11 additions & 1 deletion docs/known-issues.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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.
53 changes: 53 additions & 0 deletions tests/test_startup_not_blocked.py
Original file line number Diff line number Diff line change
@@ -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
Loading