diff --git a/docs/known-issues.md b/docs/known-issues.md index 3f61971..1e1e65d 100644 --- a/docs/known-issues.md +++ b/docs/known-issues.md @@ -457,7 +457,7 @@ 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) +## Three photo routes block the dashboard while the vision model answers (2026-10-07) — FIXED 2026-10-08 (asyncio.to_thread; tests/test_no_blocking_requests_in_async.py guards every async handler) `routes/media.py:webcam_capture`, `routes/upload.py:upload_image` and `routes/vision.py:vision_analyze` are `async` but call the vision model with diff --git a/routes/media.py b/routes/media.py index f90d8a9..29a07f2 100644 --- a/routes/media.py +++ b/routes/media.py @@ -66,8 +66,9 @@ async def webcam_capture(request: Request): ]}], "max_tokens": 4000, "temperature": 0.7 } - r = rq.post(f"{vision_url}/chat/completions", json=payload, - headers={"Content-Type": "application/json"}, timeout=120) + # In a thread: on the event loop this froze the whole dashboard while the model answered. + r = await asyncio.to_thread(rq.post, f"{vision_url}/chat/completions", json=payload, + headers={"Content-Type": "application/json"}, timeout=120) result["analysis"] = r.json()["choices"][0]["message"]["content"].strip() result["model"] = vision_model except Exception as e: diff --git a/routes/upload.py b/routes/upload.py index b083945..8adacf5 100644 --- a/routes/upload.py +++ b/routes/upload.py @@ -16,6 +16,7 @@ """ from __future__ import annotations +import asyncio import base64 import json import logging @@ -277,7 +278,9 @@ async def upload_image(request: Request): "chat_template_kwargs": {"enable_thinking": False}, } t0 = time.time() - r = rq.post(f"{vision_url}/chat/completions", json=payload, headers={"Content-Type": "application/json"}, timeout=90) + # In a thread: on the event loop this froze the whole dashboard while the model answered. + r = await asyncio.to_thread(rq.post, f"{vision_url}/chat/completions", json=payload, + headers={"Content-Type": "application/json"}, timeout=90) answer = "" try: data = r.json() diff --git a/routes/vision.py b/routes/vision.py index dc3df03..60cac37 100644 --- a/routes/vision.py +++ b/routes/vision.py @@ -7,6 +7,7 @@ """ from __future__ import annotations +import asyncio import json import logging import re as _re_b64 @@ -63,7 +64,8 @@ async def vision_analyze(request: Request): "temperature": 0.7 } headers = {"Content-Type": "application/json"} - r = rq.post(f"{vision_url}/chat/completions", json=payload, headers=headers, timeout=120) + # In a thread: on the event loop this froze the whole dashboard while the model answered. + r = await asyncio.to_thread(rq.post, f"{vision_url}/chat/completions", json=payload, headers=headers, timeout=120) data = r.json() answer = data["choices"][0]["message"]["content"].strip() _audit_event("vision_request", prompt_len=len(prompt), prompt_preview=prompt[:100]) diff --git a/tests/test_no_blocking_requests_in_async.py b/tests/test_no_blocking_requests_in_async.py new file mode 100644 index 0000000..277964b --- /dev/null +++ b/tests/test_no_blocking_requests_in_async.py @@ -0,0 +1,48 @@ +"""No blocking `requests` call inside an `async def` of the dashboard. + +A blocking call there stops the event loop: every page, poll and the voice socket +wait until it returns. The vision warmup did this at startup (fixed 2026-10-07, +#446), and three photo routes did it while a picture was analysed (webcam +capture, image upload, vision). Put the call in `asyncio.to_thread(...)`. +""" +from __future__ import annotations + +import ast +from pathlib import Path + +REPO = Path(__file__).resolve().parent.parent +FILES = [REPO / "codec_dashboard.py", *sorted((REPO / "routes").glob("*.py"))] +REQUEST_NAMES = {"requests", "rq", "_requests"} +BLOCKING = {"get", "post", "put", "delete", "patch", "head", "request"} + + +def _blocking_calls(path: Path) -> list: + tree = ast.parse(path.read_text(encoding="utf-8")) + hits = [] + for fn in ast.walk(tree): + if not isinstance(fn, ast.AsyncFunctionDef): + continue + for node in ast.walk(fn): + if (isinstance(node, ast.Call) and isinstance(node.func, ast.Attribute) + and node.func.attr in BLOCKING and isinstance(node.func.value, ast.Name) + and node.func.value.id in REQUEST_NAMES): + hits.append(f"{path.relative_to(REPO)}:{node.lineno} in {fn.name}()") + return hits + + +def test_no_async_handler_calls_requests_on_the_event_loop(): + hits = [h for f in FILES for h in _blocking_calls(f)] + assert not hits, "blocking requests call on the event loop: " + ", ".join(hits) + + +def test_the_scan_catches_a_blocking_call(tmp_path): + bad = tmp_path / "bad.py" + bad.write_text("import requests as rq\nasync def h():\n return rq.post('http://x')\n") + ok = tmp_path / "ok.py" + ok.write_text("import asyncio, requests as rq\nasync def h():\n return await asyncio.to_thread(rq.post, 'http://x')\n") + global REPO + saved, REPO = REPO, tmp_path + try: + assert _blocking_calls(bad) == ["bad.py:3 in h()"] and _blocking_calls(ok) == [] + finally: + REPO = saved