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
2 changes: 1 addition & 1 deletion docs/known-issues.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
5 changes: 3 additions & 2 deletions routes/media.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
5 changes: 4 additions & 1 deletion routes/upload.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
"""
from __future__ import annotations

import asyncio
import base64
import json
import logging
Expand Down Expand Up @@ -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()
Expand Down
4 changes: 3 additions & 1 deletion routes/vision.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
"""
from __future__ import annotations

import asyncio
import json
import logging
import re as _re_b64
Expand Down Expand Up @@ -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])
Expand Down
48 changes: 48 additions & 0 deletions tests/test_no_blocking_requests_in_async.py
Original file line number Diff line number Diff line change
@@ -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
Loading