Skip to content

Commit 4d4457d

Browse files
wehosclaude
andauthored
chore: 引入 async def 同步阻塞静态检查 + 修复存量违规 (Project-N-E-K-O#842)
* chore: 引入 async def 同步阻塞静态检查 + 修复存量违规 动机:主进程运行 FastAPI + asyncio 事件循环,心跳和 WebSocket 对 事件循环 stall 极其敏感(曾出现 end_session 清理期间 ~1s 阻塞)。 仅靠人工 grep 无法阻止同步阻塞调用反复潜入,需要静态守卫。 实现: - 启用 ruff 的 ASYNC210/220/221/222/251 规则,覆盖 time.sleep / requests / urllib.request / subprocess.run / Popen.wait 等。 - 新增 scripts/check_async_blocking.py,用 AST 补齐 ruff 不覆盖的 部分:Thread/Process.join(timeout=) / queue.Queue.get() / 原生 socket.recv/accept/connect。 接收者匹配用 tail-name("tts_thread" / "request_queue" / "sock"…) 以避免 httpx / websockets / zmq socket 的误报。 嵌套 sync def 不会被检查——匹配 ruff 的行为,让放进线程/executor 的 worker 天然免于误报。 - 新增 .github/workflows/analyze.yml,CI 并行跑 ruff 与自定义脚本。 - 支持 `# noqa: ASYNC_BLOCK — <原因>` 行级抑制。 修复存量违规: - main_server.py:546/596 initialize_character_data 两处 sync_process[k].join(timeout=...) 包裹进 asyncio.to_thread。 - main_logic/core.py:1453 tts_thread.join(timeout=1.0) 同上。 - main_logic/cross_server.py:174 maintain_connection 内 message_queue.get() 改为 get_nowait(),和外层 empty() 检查匹配, 消除竞态。 验证:ruff check . 与 scripts/check_async_blocking.py 均通过; 合成违规(time.sleep / Thread.join / Queue.get / socket.recv / requests.get / subprocess.run)均被其中一个工具拦截。 https://claude.ai/code/session_01TC7MP5nYYtXZXMmTMWNQuN * chore(ci): analyze.yml 显式收敛 GITHUB_TOKEN 至 contents:read 静态检查 job 不需要写入 repo 或任何其他资源,按最小权限原则显式 声明 permissions,避免默认宽权限。 https://claude.ai/code/session_01TC7MP5nYYtXZXMmTMWNQuN * refactor(async-check): 精确捕获 Empty + 澄清 plugin/ 为何不进默认扫描 - cross_server.py: `except Exception` 收紧为 `except Empty`(CodeRabbit 建议喵)。语义更清晰,`queue.Queue.get_nowait()` 空队列就是 Empty, 其他异常不该在这里被吞。 - check_async_blocking.py: 在 DEFAULT_PATHS 旁加注释说明为什么不把 `plugin/` 纳入默认扫描——pyzmq Socket 和 asyncio.Queue 的 tail-name 与本检查器的启发式冲撞,在 plugin/ 下会产生 15+ 条误报(zmq `sock.recv`/`connect`、`await asyncio.wait_for(q.get(), ...)` 等)。 ruff 的 ASYNC* 规则已经覆盖 plugin/,后续可以单独用类型推断的方式 再把 plugin/ 纳入这个脚本。 https://claude.ai/code/session_01TC7MP5nYYtXZXMmTMWNQuN --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent b904931 commit 4d4457d

6 files changed

Lines changed: 363 additions & 14 deletions

File tree

.github/workflows/analyze.yml

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
name: Analyze
2+
3+
on:
4+
push:
5+
branches: [main]
6+
pull_request:
7+
branches: [main]
8+
workflow_dispatch:
9+
10+
# Static-check job only needs to read the checked-out source.
11+
permissions:
12+
contents: read
13+
14+
jobs:
15+
python-lint:
16+
name: Python lint (ruff + async-blocking)
17+
runs-on: ubuntu-latest
18+
timeout-minutes: 10
19+
steps:
20+
- uses: actions/checkout@v4
21+
22+
- name: Set up Python 3.11
23+
uses: actions/setup-python@v5
24+
with:
25+
python-version: '3.11'
26+
27+
- name: Install uv
28+
uses: astral-sh/setup-uv@v4
29+
30+
- name: Install ruff
31+
# ruff is the only dev dep we need for the lint job; a full uv sync
32+
# pulls heavy native extensions (playwright, numpy, etc.) that are
33+
# unnecessary for static checks.
34+
run: uv tool install ruff==0.15.4
35+
36+
- name: ruff check (incl. ASYNC210/220/221/222/251)
37+
run: ruff check .
38+
39+
- name: Forbid blocking calls in async def bodies
40+
# Custom AST checker — covers the gaps flake8-async doesn't:
41+
# Thread/Process.join, queue.Queue.get, raw socket recv/accept/connect.
42+
run: python scripts/check_async_blocking.py

main_logic/core.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1450,7 +1450,7 @@ async def start_session(self, websocket: WebSocket, new=False, input_mode='audio
14501450
logger.info("当前模式不需要TTS,关闭TTS线程")
14511451
try:
14521452
self.tts_request_queue.put(("__shutdown__", None)) # 通知线程退出
1453-
self.tts_thread.join(timeout=1.0) # 等待线程结束
1453+
await asyncio.to_thread(self.tts_thread.join, 1.0) # 等待线程结束
14541454
except Exception as e:
14551455
logger.error(f"关闭TTS线程时出错: {e}")
14561456
finally:

main_logic/cross_server.py

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313
import time
1414
import pickle
1515
import aiohttp
16+
from queue import Empty
1617
from config import MONITOR_SERVER_PORT, MEMORY_SERVER_PORT, COMMENTER_SERVER_PORT
1718
from datetime import datetime
1819
import json
@@ -171,7 +172,10 @@ async def maintain_connection(chat_history, lanlan_name):
171172
try:
172173
# 检查消息队列
173174
while not message_queue.empty():
174-
message = message_queue.get()
175+
try:
176+
message = message_queue.get_nowait()
177+
except Empty:
178+
break
175179

176180
if message["type"] == "json":
177181
# Forward to monitor if enabled

plugin/plugins/mijia/__init__.py

Lines changed: 17 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -150,19 +150,24 @@ async def _save_credential(self, credential: Credential):
150150
if sys.platform == "win32":
151151
try:
152152
import subprocess
153-
username = subprocess.check_output(
154-
["cmd", "/c", "echo", "%USERNAME%"], text=True
155-
).strip()
156-
path_str = str(self.credential_path)
157-
# 先移除所有继承权限,再授权当前用户完全控制
158-
result = subprocess.run(
159-
["icacls", path_str, "/inheritance:r", "/grant:r", f"{username}:F"],
160-
check=False, capture_output=True, text=True
161-
)
162-
if result.returncode != 0:
153+
154+
def _apply_windows_acl() -> tuple[int, str]:
155+
username = subprocess.check_output(
156+
["cmd", "/c", "echo", "%USERNAME%"], text=True
157+
).strip()
158+
path_str = str(self.credential_path)
159+
# 先移除所有继承权限,再授权当前用户完全控制
160+
result = subprocess.run(
161+
["icacls", path_str, "/inheritance:r", "/grant:r", f"{username}:F"],
162+
check=False, capture_output=True, text=True
163+
)
164+
return result.returncode, (result.stderr or "").strip()
165+
166+
returncode, stderr = await asyncio.to_thread(_apply_windows_acl)
167+
if returncode != 0:
163168
self.logger.warning(
164-
f"设置凭据文件权限失败(Windows): icacls 返回码 {result.returncode}"
165-
+ (f", stderr: {result.stderr.strip()}" if result.stderr.strip() else "")
169+
f"设置凭据文件权限失败(Windows): icacls 返回码 {returncode}"
170+
+ (f", stderr: {stderr}" if stderr else "")
166171
)
167172
else:
168173
self.logger.debug("凭据文件权限已设置(仅当前用户)")

pyproject.toml

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -106,3 +106,27 @@ dev = [
106106
"ruff>=0.15.4",
107107
]
108108

109+
# ─────────────────────────────────────────────────────────────────────────────
110+
# Ruff: enforce zero-blocking-call policy in async def bodies.
111+
# Covers: time.sleep / requests / urllib.request / subprocess.* (run, Popen…).
112+
# Gaps (threading.Thread.join timeout, queue.Queue.get, threading.Event.wait
113+
# timeout, raw socket.recv/send/accept/connect) are caught by the companion
114+
# script scripts/check_async_blocking.py which the CI Analyze job also runs.
115+
# ─────────────────────────────────────────────────────────────────────────────
116+
[tool.ruff]
117+
target-version = "py311"
118+
extend-exclude = [
119+
"frontend",
120+
"dist",
121+
".venv",
122+
]
123+
124+
[tool.ruff.lint]
125+
select = [
126+
"ASYNC210", # blocking HTTP call (requests / urllib / httpx.Client)
127+
"ASYNC220", # create-subprocess (Popen) in async function
128+
"ASYNC221", # subprocess.run / call / check_output / check_call
129+
"ASYNC222", # Popen.wait / Popen.communicate
130+
"ASYNC251", # time.sleep in async function
131+
]
132+

0 commit comments

Comments
 (0)