fix(aibox): Batch M 波次5 客户端收尾(M1~M5/M7/M8)

- voice_client: stdin 单守护线程+Queue 替代每行 to_thread(泄漏/丢行);
  arecord 探测 timeout=5 + finally 清理临时文件
- client: main() finally 统一 disconnect websocket
- model_config: 主循环捕获 EOF/KeyboardInterrupt;api 节非 dict 重置;
  fallback 字符串规整(展示/删除清理);api_key_env 正则校验+
  显示字段拒控制字符限长
- 冒烟 7/7 通过;全量 493 passed/4 failed(既有平台项)/1 skipped 零回归
- 审计报告补 §22,Batch M 五波次收束
This commit is contained in:
qing committed 2026-08-20 14:40:00 +08:00
1 parent d1357ae483
commit 3e6e171831
4 files changed
+134 -34

No files matched your search

+8
View File
@@ -815,6 +815,14 @@ async def main():
await cli.run() await cli.run()
except KeyboardInterrupt: except KeyboardInterrupt:
print(f"\n{Colors.SYSTEM}程序已退出{Colors.RESET}") print(f"\n{Colors.SYSTEM}程序已退出{Colors.RESET}")
finally:
# M8(审计 2026-08-20):正常退出(EOF 等)此前不关 websocket,
# 服务端见异常断连——统一 finally 通知
if cli.client:
try:
await cli.client.disconnect()
except Exception:
pass
if __name__ == "__main__": if __name__ == "__main__":
try: try:
+57 -23
View File
@@ -49,6 +49,15 @@ def get_models(config: ConfigManager) -> Dict:
return models return models
def _api_section(config: ConfigManager) -> Dict:
"""M3(审计 2026-08-20):api 节被改坏为非 dict 时重置为空节——
否则 setdefault 后索引赋值抛 TypeError/AttributeError"""
api = config._config.get("api")
if not isinstance(api, dict):
api = config._config["api"] = {}
return api
def list_profiles(config: ConfigManager) -> List[Dict]: def list_profiles(config: ConfigManager) -> List[Dict]:
"""列出所有档案的归一化展示数据。""" """列出所有档案的归一化展示数据。"""
models = get_models(config) models = get_models(config)
@@ -57,6 +66,9 @@ def list_profiles(config: ConfigManager) -> List[Dict]:
for name, profile in models.items(): for name, profile in models.items():
if not isinstance(profile, dict): if not isinstance(profile, dict):
continue continue
# M4(审计 2026-08-20):fallback 被改坏为字符串时原样下传,
# 展示层 join 成单字符误导——统一规整为列表
fallback = profile.get("fallback")
profiles.append({ profiles.append({
"name": name, "name": name,
"is_default": name == default, "is_default": name == default,
@@ -67,7 +79,7 @@ def list_profiles(config: ConfigManager) -> List[Dict]:
"reasoning_enabled": bool(profile.get("reasoning_enabled", False)), "reasoning_enabled": bool(profile.get("reasoning_enabled", False)),
"temperature": profile.get("temperature"), "temperature": profile.get("temperature"),
"timeout": profile.get("timeout"), "timeout": profile.get("timeout"),
"fallback": profile.get("fallback") or [], "fallback": fallback if isinstance(fallback, list) else [],
}) })
return profiles return profiles
@@ -100,6 +112,17 @@ def add_profile(config: ConfigManager, name: str, api_url: str, model: str,
if err: if err:
return [f"❌ {err}"] return [f"❌ {err}"]
# M5(审计 2026-08-20):api_key_env 需为合法环境变量名;展示/地址字段
# 拒控制字符并限长——防终端注入回显(\x1b] 等序列写入配置后被 print)
api_key_env = (api_key_env or "").strip()
if api_key_env and not re.match(r"^[A-Za-z_][A-Za-z0-9_]*$", api_key_env):
return [f"❌ 环境变量名不合法: {api_key_env!r}(须为字母/数字/下划线且不以数字开头)"]
for label, val, limit in (("显示名", display_name, 64),
("模型名", model, 128),
("API 地址", api_url, 256)):
if val and (len(str(val)) > limit or not all(c.isprintable() for c in str(val))):
return [f"❌ {label}含控制字符或超过 {limit} 字符"]
models = get_models(config) models = get_models(config)
profile = { profile = {
"api_url": str(api_url).strip(), "api_url": str(api_url).strip(),
@@ -135,7 +158,7 @@ def add_profile(config: ConfigManager, name: str, api_url: str, model: str,
models[name] = profile models[name] = profile
config._config["models"] = models config._config["models"] = models
if set_default: if set_default:
config._config.setdefault("api", {})["default_model"] = name _api_section(config)["default_model"] = name
saved = config._save_config() saved = config._save_config()
msgs = [f"✅ 接入点 '{name}' 已添加({profile['api_url']},模型 {profile['model']})"] msgs = [f"✅ 接入点 '{name}' 已添加({profile['api_url']},模型 {profile['model']})"]
@@ -163,7 +186,11 @@ def delete_profile(config: ConfigManager, name: str) -> List[str]:
for other, profile in models.items(): for other, profile in models.items():
if not isinstance(profile, dict): if not isinstance(profile, dict):
continue continue
fb = profile.get("fallback") or [] fb = profile.get("fallback")
# M4(审计 2026-08-20):fallback 为字符串时 `name in fb` 退化为子串
# 误判,且下方列表推导会把字符串拆成单字符数组写回配置
if not isinstance(fb, list):
continue
if name in fb: if name in fb:
profile["fallback"] = [f for f in fb if f != name] profile["fallback"] = [f for f in fb if f != name]
cleaned.append(other) cleaned.append(other)
@@ -171,7 +198,7 @@ def delete_profile(config: ConfigManager, name: str) -> List[str]:
# 若删除的是默认档案,清除默认标记(回退旧 api 节语义) # 若删除的是默认档案,清除默认标记(回退旧 api 节语义)
if config.get("api.default_model") == name: if config.get("api.default_model") == name:
config._config.setdefault("api", {}).pop("default_model", None) _api_section(config).pop("default_model", None)
saved = config._save_config() saved = config._save_config()
msgs = [f"✅ 接入点 '{name}' 已删除"] msgs = [f"✅ 接入点 '{name}' 已删除"]
@@ -382,26 +409,33 @@ def run_interactive(config: ConfigManager = None):
print(" 说明:API Key 只存环境变量名,不落盘;免密端点适合 Ollama 等本地服务") print(" 说明:API Key 只存环境变量名,不落盘;免密端点适合 Ollama 等本地服务")
print("=" * 56) print("=" * 56)
while True: while True:
print("\n请选择操作:") # M2(审计 2026-08-20):input 未捕获 EOFError/KeyboardInterrupt——
print(" 1. 📋 列出接入点") # 管道耗尽/Ctrl+C 此前直接 traceback;整循环包 try 同时覆盖子流程
print(" 2. ➕ 添加接入点") # (_add_flow/_delete_flow/_test_flow)内的 input 调用
print(" 3. 🗑️ 删除接入点") try:
print(" 4. 🔍 验证接入点连通性") print("\n请选择操作:")
print(" 0. 退出") print(" 1. 📋 列出接入点")
choice = input("> ").strip() print(" 2. ➕ 添加接入点")
if choice == "1": print(" 3. 🗑️ 删除接入点")
_print_profiles(list_profiles(config)) print(" 4. 🔍 验证接入点连通性")
elif choice == "2": print(" 0. 退出")
_add_flow(config) choice = input("> ").strip()
elif choice == "3": if choice == "1":
_delete_flow(config) _print_profiles(list_profiles(config))
elif choice == "4": elif choice == "2":
_test_flow(config) _add_flow(config)
elif choice in ("0", "q", "exit", "退出"): elif choice == "3":
print("再见") _delete_flow(config)
elif choice == "4":
_test_flow(config)
elif choice in ("0", "q", "exit", "退出"):
print("再见")
return
else:
print("无效选择,请输入 0~4")
except (EOFError, KeyboardInterrupt):
print("\n再见")
return return
else:
print("无效选择,请输入 0~4")
def main(): def main():
+47 -10
View File
@@ -347,12 +347,17 @@ class VoiceInterface:
with tempfile.NamedTemporaryFile(suffix='.wav', delete=False) as f: with tempfile.NamedTemporaryFile(suffix='.wav', delete=False) as f:
test_file = f.name test_file = f.name
result = subprocess.run( try:
['arecord', '-f', 'cd', '-d', '1', test_file], result = subprocess.run(
capture_output=True, text=True ['arecord', '-f', 'cd', '-d', '1', test_file],
) capture_output=True, text=True,
timeout=5, # M7(审计 2026-08-20):设备挂死此前永久卡死
os.unlink(test_file) )
finally:
# M7:超时/异常路径同样清理探测临时文件(此前残留 /tmp);
# TimeoutExpired 传播至外层 except,按"麦克风不可用"降级
if os.path.exists(test_file):
os.unlink(test_file)
if result.returncode == 0: if result.returncode == 0:
print(f"{Colors.INFO}🎤 arecord 检测到设备,将使用 arecord 录音{Colors.RESET}") print(f"{Colors.INFO}🎤 arecord 检测到设备,将使用 arecord 录音{Colors.RESET}")
@@ -614,17 +619,49 @@ class VoiceInterface:
print(f"\n{Colors.USER}👤 用户: {message}{Colors.RESET}") print(f"\n{Colors.USER}👤 用户: {message}{Colors.RESET}")
await self.client.send_message(message) await self.client.send_message(message)
def _stdin_reader(self, loop: asyncio.AbstractEventLoop):
"""M1(审计 2026-08-20):单守护线程读 stdin——替代 asyncio.to_thread
每行一个 input 线程的方案:Ctrl+C 取消 await 后旧线程仍阻塞 readline
永久泄漏,并"偷吃"后续输入整行(表现为偶发丢行)。
方案照搬 client.py:735-755(守护线程 + Queue + call_soon_threadsafe)。"""
def _put(item):
try:
loop.call_soon_threadsafe(self._input_queue.put_nowait, item)
except RuntimeError:
pass # 事件循环已关闭(程序退出中)
while self._running:
try:
line = sys.stdin.readline()
except Exception:
break
if not line: # EOF (Ctrl+D)
_put(None)
break
_put(line)
async def run(self): async def run(self):
self.print_banner() self.print_banner()
prompt = f"{Colors.USER}>>> {Colors.RESET}" prompt = f"{Colors.USER}>>> {Colors.RESET}"
# M1:进程生命周期只启动一次 stdin 守护线程
self._input_queue = asyncio.Queue()
self._running = True
loop = asyncio.get_running_loop()
if getattr(self, '_stdin_thread', None) is None:
self._stdin_thread = threading.Thread(
target=self._stdin_reader, args=(loop,),
daemon=True, name="stdin-reader")
self._stdin_thread.start()
while True: while True:
try: try:
user_input = await asyncio.to_thread( print(prompt, end='', flush=True)
lambda: input(prompt) line = await self._input_queue.get()
) if line is None: # EOF (Ctrl+D)
user_input = user_input.strip() raise EOFError
user_input = line.strip()
if not user_input: if not user_input:
if self.has_microphone: if self.has_microphone:
+22 -1
View File
@@ -952,4 +952,25 @@ Linux 基线(`aibox/venv`)维持 **490 passed** 不受影响。
- 冒烟测试 12/12 通过:`--port` 五例(8765 放行 / 0/65536/-1/abc 拒绝)、`add_memo` 空/纯空格标题拒绝、正常标题入库、i18n 三新键解析、`"a|b|"` 尾段规整 None - 冒烟测试 12/12 通过:`--port` 五例(8765 放行 / 0/65536/-1/abc 拒绝)、`add_memo` 空/纯空格标题拒绝、正常标题入库、i18n 三新键解析、`"a|b|"` 尾段规整 None
- 全量测试 **493 passed / 4 failed(既有平台环境项)/ 1 skipped**,与波次 1~3 基线完全一致,零回归 - 全量测试 **493 passed / 4 failed(既有平台环境项)/ 1 skipped**,与波次 1~3 基线完全一致,零回归
**波次 5 待落地**:客户端收尾(M1~M5/M7/M8,model_config/voice_client/client + aibox.py scheduler 超时/记忆上限,~25 行)。 **波次 5 已落地**:见 §22。
## 二十二、Batch M 修复记录(波次 5:客户端收尾,2026-08-20)
范围:§17.6 波次 5——voice_client/client 收尾(M1/M7/M8)+ model_config 边角(M2~M5),落在 `voice_client.py`/`client.py`/`model_config.py` 三个文件(+118/-39 行)。M6(model_config 与运行中服务并发保存 lost-update)按审计口径"文档约定"跳过——model_config 定位为停机维护工具,与运行中服务同时写配置属使用方式问题。
| # | 修复内容 | 关键变更 |
|---|---|---|
| M1 | voice_client stdin 线程泄漏/丢行 | 单守护线程 `_stdin_reader` + `Queue` + `loop.call_soon_threadsafe`(照搬 client.py:735-755 方案),替代 `asyncio.to_thread` 每行起一个 input 线程——此前 Ctrl+C 取消 await 后旧线程仍阻塞 readline 永久泄漏,并"偷吃"后续输入整行(偶发丢行) |
| M2 | model_config 主循环异常 | 整循环包 `try/except (EOFError, KeyboardInterrupt)`,管道耗尽/Ctrl+C 打印"再见"退出(此前直接 traceback);覆盖 `_add_flow/_delete_flow/_test_flow` 子流程内的 input 调用 |
| M3 | api 节非 dict 崩溃 | 新增 `_api_section()`:`config._config["api"]` 非 dict 时重置为空节——此前 setdefault 后索引赋值抛 TypeError/AttributeError |
| M4 | fallback 字符串三处退化 | `list_profiles` 非 list→`[]`(此前展示层 join 成单字符误导);`delete_profile` 清理悬空引用时跳过非 list(此前 `name in fb` 子串误判 + 列表推导把字符串拆成单字符数组写回配置) |
| M5 | 档案字段注入/限长 | `api_key_env` 正则 `^[A-Za-z_][A-Za-z0-9_]*$` 校验合法环境变量名;display_name/model/api_url 拒控制字符(`isprintable`)并限长 64/128/256——防 `\x1b]` 等终端转义序列写入配置后被 print 回显 |
| M7 | arecord 探测永久卡死 | `subprocess.run(..., timeout=5)` + finally 清理探测临时文件(此前设备挂死永久卡死、异常路径残留 /tmp);TimeoutExpired 传播至外层按"麦克风不可用"降级 |
| M8 | client 退出不关 websocket | `main()` 补 finally:正常退出(EOF 等)统一 `await cli.client.disconnect()`(此前服务端见异常断连) |
**验证**:
- 冒烟测试 7/7 通过(`Temp/_smoke_wave5.py`,跑完即删):M4 展示规整/删除不拆字符、M3 损坏 api 节重置+默认档案写入、M5 非法变量名/控制字符/超长 URL 拒绝 + 合法输入放行。注:ConfigManager 为单例且 `common.constants` import 期即实例化,冒烟脚本每个场景需重置单例(`_instance/_config/_config_path` 置 None)才能吃到新写盘的配置
- 全量测试 **493 passed / 4 failed(既有平台环境项)/ 1 skipped**,与波次 1~4 基线完全一致,零回归
- `py_compile` 三文件通过
**Batch M 全部 5 个波次已收束**(S/F/W/M 四域,累计 §18~§22)。遗留"可不做/产品决策"项:F5(消息列表内存增长)、W9/W10(WS payload 上限/data 归一)、M6(并发保存 lost-update)、§17.5 表外 N 轮未完成项 N6(registry 转发总超时不完整)。