Skip to content

[Bug]: 插件与遥测缺陷汇总:Default Chat terminate 空实现导致任务泄漏 / 前台 exec 超时不杀进程组 / 遥测重复计数 #251

Description

@LyaQanYi

概述

对 v2.30.0(1228ac2)做代码审查时,在 core/plugin/、core/telemetry/、core/db/、core/sticker_manager.py 发现以下缺陷。

先说好的部分:数据库层全部走 SQLAlchemy 表达式(无注入面),三处解压路径都正确接入了 is_within_directory 的 zip-slip 防御,也没有 eval/exec/pickle/yaml.load 之类的危险反序列化。下面的问题主要集中在生命周期清理与遥测一致性。


1. DefaultChatPlugin.terminate() 空实现,去抖后台任务与按会话字典在禁用/重载后泄漏(Medium)

位置:core/plugin/builtin_plugins/chat/main.py:29-33

async def terminate(self):
    """
    Cleanup when plugin is terminated
    """
    pass

实际行为:handle_msg 会为每个会话创建 self.session_tasks[sid] = asyncio.create_task(self._debounce_loop(sid))(:70),而 _debounce_loop 是 while True 常驻循环。terminate() 什么都不做,因此插件被禁用/重载、或 update_plugin_config 触发重初始化时:

  • 这些任务不会被取消,会继续持有旧的 self.ctx 运行,仍在向 message_processor flush,可能对已过期的上下文产生副作用
  • session_tasks/session_events 字典也从不清理,随会话数无上限增长

复现:反复"禁用 → 启用" Default Chat 插件,或多次修改其配置,僵尸去抖任务会不断堆积。

期望行为:terminate() 取消所有 session_tasks 并清空两个字典。


2. 前台 exec 超时只杀 shell 不杀子进程组(Medium)

位置:core/plugin/builtin_plugins/file/main.py:150-155

result = subprocess.run(
    shell_command, shell=True, capture_output=True,
    stdin=subprocess.DEVNULL,
    timeout=exec_timeout, env=env, cwd=exec_work_dir,
    encoding='utf-8', errors='replace'
)

实际行为:前台执行路径没有创建独立进程组。命令超时时 subprocess.run 只终止顶层 shell,其派生的子/孙进程会成为孤儿继续运行。LLM 触发一个会 fork 长驻子进程的命令并超时后,子进程泄漏、继续占用 CPU/端口/文件句柄。

对比后台路径处理是正确的::227 设置了 start_new_session=True(Windows 走 :225 的 CREATE_NEW_PROCESS_GROUP)并配合 killpg。前台路径缺失同等清理。

期望行为:前台路径同样创建进程组并在超时时 killpg。


3. 遥测每 30 分钟重新聚合会重复入队未标记记录,导致服务端重复计数(Medium)

位置:core/telemetry/client.py:469、:486、:350-389

msg_rows = await self.db.get_unreported_telemetry_message_rows(since_ts)
...
        self.send_event(TelemetryEventType.MESSAGE_STATS, {...},
            on_success=lambda ids=ids: self.db.mark_telemetry_messages_by_ids(ids))

实际行为:记录只有在事件被 worker 成功发送后(on_success 回调)才置 reported=True,而 _report_stats 每 30 分钟(以及 shutdown() 里再调一次,:138)会重新查询所有 reported=False 的行并重新入队。

当遥测服务端不可达或 worker 积压超过 30 分钟时,同一批未标记行会被反复构造成新事件塞进队列;网络恢复后 worker 会把这些重复事件全部发出(mark_*_by_ids 幂等,但事件本身已重复),服务端对同一小时/同一模型的消息数、token 数出现重复累加。shutdown 紧接调度运行之后调用 _report_stats 时几乎必然产生一次重复入队。

期望行为:入队时即标记"在途"状态,或用队列内去重避免同一批 id 被重复构造。


4. _report_lock 依据 .locked() 释放,可能释放非本协程持有的锁(Medium)

位置:core/telemetry/client.py:455-456、:460、:555-556

finally:
    # Guarantee the lock is released if this task is cancelled while inside _report_stats
    if self._report_lock.locked():
        self._report_lock.release()

实际行为:asyncio.Lock 不记录持有者,release() 只看"是否被锁"。_stats_loop 的 finally 与 _report_stats 的 finally 都用 if locked(): release()。若 _stats_loop 在取消清理时锁正被另一处(如 shutdown 直接调用的 _report_stats)持有,这里会把别人的锁释放掉,破坏互斥。

当前 shutdown 会先取消并 await 掉 _stats_task 再自己调 _report_stats,使窗口较窄,但这是脆弱写法,容易在后续改动中被触发。

期望行为:各自记录"本次调用是否 acquire 成功"再决定释放。


5. restricted_paths 用子串匹配,正常文件名被误拦(Low)

位置:core/plugin/builtin_plugins/file/main.py:21-22、:375-377

'key' in path 会让 data/files/monkey.txt("monkey")被判为受限。同理 secret/token 等关键词也会误伤。功能性误伤,非安全问题,但会让合法读写莫名被拒。

期望行为:按路径段/文件名精确匹配,而非裸子串。


6. 其他 Low 级观察

  • core/plugin/plugin_registry.py:2086-2096:load_plugin_from_dir 在"未找到 BasePlugin 子类"分支直接 return None,但模块已 exec_module 成功并写入 sys.modules,装饰器注册进 _plugin_components 的内容也残留。后续 _cleanup_plugin_modules 能靠路径匹配兜底,但组件残留仍在。
  • core/plugin/plugin_registry.py:279-338、:347:get_obj_plugin_id 在无法解析归属时返回 "",_ensure_components("") 会把不同来源的工具/hook 混入同一空 id 桶;而 is_plugin_enabled("") 恒为 False(:652-653),行为不一致。
  • core/db/db_mgr.py:89-97:DatabaseManager.execute() 在 async with get_session() 退出(会话已关闭)后返回未物化的 Result。当前无调用方(fetch_one/fetch_all 在块内已 .all() 物化所以安全),属潜在坑,建议移除或改为块内消费。
  • core/db/service.py:505-516:mark_telemetry_reported 按时间窗全量标记 reported.is_(False),正是 mark_*_by_ids 系列想避免的竞态(聚合后新插入的当前小时行会被误标)。当前未见调用,建议删除或标注弃用。
  • core/sticker_manager.py:101:asyncio.create_task(self._fire_registered(...)) 未保留引用,可能被 GC 回收导致 VLM 描述回调中途丢失;:93-94 的 _sticker_index += 1 无锁,并发注册可能产生重复 id。
  • core/telemetry/client.py:399-403:国家码查询走明文 HTTP(http://ip-api.com/json/?fields=countryCode),会把客户端公网 IP 以未加密方式暴露给第三方及链路观察者。响应本身不敏感,但建议改 HTTPS。
  • core/plugin/builtin_plugins/search/main.py:57、:88:tavily_search 的 topic 类型标注含 "finance" 但 schema enum 只有 general/news;tavily_extract 向 client.extract(...) 传了 max_results,该接口未必接受此参数。

补充:测试覆盖

core/plugin/plugin_registry.py(2132 行,本模块最核心)几乎无直接测试 —— load_plugin_from_dir/init_plugin/terminate/reload/uninstall_plugin 的加载-卸载-重载、hook/tool/tag 的注册与清理(_cleanup_plugin_registration)、_cleanup_plugin_modules 的 sys.modules 清理、_remove_plugin_routes 的路由回收均无覆盖(仅 test_plugin_widget_lifecycle.py 覆盖 widget 声明保留一项)。

另外三处 zip-slip 防御分支(_extract_and_install、path_utils.safe_extract_zip、dist_checker._extract_dist)都没有构造 ../ 成员的拒绝测试;file 插件的 _normalize_path/_is_path_allowed/read_file/write_file/edit_file 越权与扩展名拒绝逻辑也无测试;core/telemetry/client.py(556 行)的聚合/去重/on_success 标记/锁处理无测试。


KiraAI version: v2.30.0(1228ac2)
Environment: 静态代码审查,非特定 OS/Python 版本相关

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions