@@ -585,7 +585,9 @@ async def exec_cap(payload: Dict[str, Any], x_internal_key: Optional[str] = Head
585585 return result
586586
587587@router .post ("/drive-turn" )
588- async def drive (payload : Dict [str , Any ], x_internal_key : Optional [str ] = Header (None )):
588+ # `def` 而不是 `async def`——理由与 /drive-full 那条完全相同(见下面那段长注释):
589+ # drive_reasoning_turn 是同步的重活,写在 async 里会占住事件循环。
590+ def drive (payload : Dict [str , Any ], x_internal_key : Optional [str ] = Header (None )):
589591 """Single turn drive (drive_reasoning_turn). Full multi-loop driver authority exposed via /drive-full."""
590592 _auth (x_internal_key )
591593 state = V5SessionState (** payload ["state" ])
@@ -594,7 +596,29 @@ async def drive(payload: Dict[str, Any], x_internal_key: Optional[str] = Header(
594596 return {"state" : new_state .model_dump (), "stateAuthority" : STATE_AUTHORITY_PYTHON , "provenance" : PROVENANCE_PYTHON_RAG , "backend" : PYTHON_BACKEND }
595597
596598@router .post ("/drive-full" )
597- async def drive_full (payload : Dict [str , Any ], x_internal_key : Optional [str ] = Header (None )):
599+ # ⚠️ 这条路由是 `def` 而不是 `async def`——**故意的,别改回去**(2026-08-02)。
600+ #
601+ # 事故形状:只要有人在推演,整个服务就不响应,连 /api/health 都超时。原因是
602+ # drive_full_v5_session 是**同步**函数(v5_full_driver.py),一趟推演 6~20 分钟
603+ # (实测本次 5 个话题 374~1190s),此前它被直接写在 `async def` 里,于是整段
604+ # 跑在事件循环那条线程上——单 worker 下,事件循环被占住 = 全站失联。
605+ #
606+ # 修法用的是 FastAPI 官方口径,不是自己发明的:
607+ # "When you declare a path operation function with normal `def` instead of
608+ # `async def`, it is run in an external threadpool that is then awaited,
609+ # instead of being called directly (as it would block the server)."
610+ # —— fastapi.tiangolo.com/async/
611+ # 底层是 Starlette 的 run_in_threadpool → anyio.to_thread.run_sync。
612+ #
613+ # 为什么不用 asyncio.to_thread 手动包一层:能达到同样效果,但要在每个调用点
614+ # 各写一遍、且容易漏(本文件里 LLM/RAG 那几条就是这么写的,而这两条当初正是
615+ # 漏掉的)。函数签名去掉 async 是**声明式**的,漏不掉。
616+ #
617+ # 代价(记下来,别踩):线程池默认只有 40 个槽(anyio 的
618+ # current_default_thread_limiter),而每趟推演会占住一个槽十几分钟。同时在跑的
619+ # 推演超过 40 个就会开始排队——真到那一天,正确的解法是把推演挪进任务队列,
620+ # 而不是把这个数字调大。
621+ def drive_full (payload : Dict [str , Any ], x_internal_key : Optional [str ] = Header (None )):
598622 """Python driver authority for multiple capability loops until stop condition (coverage/empty picks/max_loops).
599623 Wires drive_full_v5_session as the visible full-path multi-loop API (PYTHON_AUTHORITY).
600624 Real userText (user instruction) is forwarded so it drives pick/orchestrate/execute/artifacts/GCOV/phase.
@@ -1432,20 +1456,34 @@ async def list_generated_apps(
14321456 offset : int = 0 ,
14331457 x_internal_key : Optional [str ] = Header (None ),
14341458):
1435- """应用画廊列表——默认每个应用只出最新版,摘要不含大模型载荷。"""
1459+ """应用画廊列表——默认每个应用只出最新版,摘要不含大模型载荷。
1460+
1461+ **同步的库调用必须 to_thread**(2026-08-02 线上事故修复)。这几条路由是
1462+ `async def`,而 uvicorn 只跑一个 worker、一个事件循环;直接在协程里调同步
1463+ 的 SQLAlchemy,一次慢查询就把整个事件循环冻住——`/api/health` 与
1464+ `/api/agent-loop/health` 跟着一起超时,"存储层拖垮主链路"的承诺当场作废。
1465+ 这正是切回 Neon 后线上观察到的形状。
1466+
1467+ 本文件里 LLM/RAG/附件解析那几条早就是这么写的(见 asyncio.to_thread 的
1468+ 其它调用点),app store 这几条是漏网的。
1469+ """
14361470 _auth (x_internal_key )
14371471 from services import app_store
14381472
1439- return {"apps" : app_store .list_apps (limit = limit , offset = offset )}
1473+ apps = await asyncio .to_thread (app_store .list_apps , limit = limit , offset = offset )
1474+ return {"apps" : apps }
14401475
14411476
14421477@router .get ("/apps/{app_id}" )
14431478async def get_generated_app (app_id : str , x_internal_key : Optional [str ] = Header (None )):
1444- """取一个生成应用的完整记录(含 model_json,可直接重开渲染)。"""
1479+ """取一个生成应用的完整记录(含 model_json,可直接重开渲染)。
1480+
1481+ 同步库调用走 to_thread,理由见 list_generated_apps。
1482+ """
14451483 _auth (x_internal_key )
14461484 from services import app_store
14471485
1448- record = app_store .get_app ( app_id )
1486+ record = await asyncio . to_thread ( app_store .get_app , app_id )
14491487 if record is None :
14501488 raise HTTPException (404 , "app not found" )
14511489 return record
@@ -1454,6 +1492,7 @@ async def get_generated_app(app_id: str, x_internal_key: Optional[str] = Header(
14541492@router .get ("/apps/{app_id}/preview" )
14551493async def get_generated_app_preview (
14561494 app_id : str ,
1495+ request : Request ,
14571496 source : Optional [str ] = None ,
14581497 x_internal_key : Optional [str ] = Header (None ),
14591498):
@@ -1480,14 +1519,30 @@ async def get_generated_app_preview(
14801519 _auth (x_internal_key )
14811520 from services import app_store
14821521
1483- png = app_store .get_app_preview_png (
1484- app_id , source = app_store .normalize_preview_source (source ) if source else None
1522+ # 同步库调用走 to_thread,理由见 list_generated_apps。
1523+ data = await asyncio .to_thread (
1524+ app_store .get_app_preview_png ,
1525+ app_id ,
1526+ source = app_store .normalize_preview_source (source ) if source else None ,
14851527 )
1486- if not png :
1528+ if not data :
14871529 raise HTTPException (404 , "preview not found" )
1530+
1531+ from services .thumb_image import client_accepts_webp , sniff_media_type , to_png
1532+
1533+ # 按内容报 Content-Type,不写死 image/png:库里存量是 PNG、新写入是 WebP,
1534+ # 报错类型浏览器可能拒绝渲染,CDN 也会缓存错。
1535+ media = sniff_media_type (data )
1536+ # Accept 头协商(thumbor / imgproxy / Next.js Image 同款做法):不认 WebP 的
1537+ # 客户端现场转回 PNG。WebP 覆盖率 97%+,这条是给极老客户端和抓取工具留的,
1538+ # 不是主路径。转不动就原样给——宁可让客户端拿到一张它可能不认的图,
1539+ # 也不要 500。
1540+ if media == "image/webp" and not client_accepts_webp (request .headers .get ("accept" )):
1541+ data = await asyncio .to_thread (to_png , data )
1542+ media = sniff_media_type (data )
14881543 return Response (
1489- content = png ,
1490- media_type = "image/png" ,
1544+ content = data ,
1545+ media_type = media ,
14911546 headers = {"Cache-Control" : "public, max-age=31536000, immutable" },
14921547 )
14931548
@@ -1497,9 +1552,22 @@ async def get_generated_app_preview(
14971552#: 这是一张缩略图,几 MB 的东西进来只会把列表接口和 Neon 拖慢。
14981553_MAX_SHOT_BYTES = 3 * 1024 * 1024
14991554
1500- #: PNG 的魔数。只认 PNG:取图路由是按 image/png 回的,别的格式进来会让
1501- #: 浏览器拿到一个声称是 PNG 的 JPEG。
1502- _PNG_MAGIC = b"\x89 PNG\r \n \x1a \n "
1555+ #: 允许回传的图片格式。采集端出的是 WebP(见 client/src/lib/thumb-capture.ts),
1556+ #: 但 canvas.toBlob 对不认识的 type 会静默回落成 PNG,所以两种都得收。
1557+ #: 仍然只收这两种:取图路由按内容嗅探报 Content-Type,收进来一个声称是图片的
1558+ #: 任意字节流只会让浏览器拿到一张渲染不出来的东西。
1559+ _ALLOWED_SHOT_MAGIC = ("image/png" , "image/webp" )
1560+
1561+
1562+ def _looks_like_image (data : bytes ) -> bool :
1563+ """真的按魔数验一遍。
1564+
1565+ sniff_media_type 认不出时会**兜底返回 image/png**(为了让历史存量能正常
1566+ 显示),所以它不能单独用来做入口校验——不然任意字节流都会被判成 PNG 放进来。
1567+ """
1568+ return data .startswith (b"\x89 PNG\r \n \x1a \n " ) or (
1569+ data [:4 ] == b"RIFF" and data [8 :12 ] == b"WEBP"
1570+ )
15031571
15041572
15051573@router .post ("/apps/{app_id}/preview" )
@@ -1533,20 +1601,23 @@ async def upload_generated_app_shot(
15331601 _auth (x_internal_key )
15341602 from services import app_store
15351603
1536- if app_store .get_app (app_id ) is None :
1604+ # 同步库调用走 to_thread,理由见 list_generated_apps。
1605+ if await asyncio .to_thread (app_store .get_app , app_id ) is None :
15371606 raise HTTPException (404 , "app not found" )
1538- if app_store .app_has_shot ( app_id ):
1607+ if await asyncio . to_thread ( app_store .app_has_shot , app_id ):
15391608 return {"stored" : False , "reason" : "already_has_shot" }
15401609
15411610 body = await request .body ()
15421611 if not body :
15431612 raise HTTPException (400 , "empty body" )
15441613 if len (body ) > _MAX_SHOT_BYTES :
15451614 raise HTTPException (413 , "screenshot too large" )
1546- if not body .startswith (_PNG_MAGIC ):
1547- raise HTTPException (415 , "expected a PNG" )
1615+ from services .thumb_image import sniff_media_type
1616+
1617+ if sniff_media_type (body ) not in _ALLOWED_SHOT_MAGIC or not _looks_like_image (body ):
1618+ raise HTTPException (415 , "expected a PNG or WebP image" )
15481619
1549- stored = app_store .save_app_shot ( app_id , body )
1620+ stored = await asyncio . to_thread ( app_store .save_app_shot , app_id , body )
15501621 return {"stored" : stored , "bytes" : len (body )}
15511622
15521623
@@ -1556,7 +1627,8 @@ async def list_generated_app_versions(root_id: str, x_internal_key: Optional[str
15561627 _auth (x_internal_key )
15571628 from services import app_store
15581629
1559- return {"versions" : app_store .list_versions (root_id )}
1630+ versions = await asyncio .to_thread (app_store .list_versions , root_id )
1631+ return {"versions" : versions }
15601632
15611633
15621634@router .post ("/apps/{app_id}/fork" )
0 commit comments