[Bug] ask 答复被注入到无关会话:ask-question-handler 在 document 上注册点击委托且 dispose 被丢弃,SPA 切换会话时持续泄漏监听器
项目:ygncode/pi-web(Go 版,本机版本 0.0.1-beta.36)
备注:repo 当前 issue 创建受限("Issue creation is restricted"),本文档可通过维护者渠道提交,建议附文中的 JSONL 时间戳证据与 file:line。
现象
在 Web UI 的 A 会话页点击内置 ask(pi_web_ask_user_question)弹窗卡片的选项按钮后,除当前会话正常收到答复外,其他无关会话也被注入了同样的 "问题" = "答复" 格式的用户消息,并且每个无关会话各执行了完整的 agent 轮次(模型被无意义地唤醒数轮)。
注入次数呈现明显的不对称模式:
| 时间点(UTC) |
答复内容 |
S1 注入次数 |
S2 注入次数 |
S3(实际操作页) |
| 19:57~19:58 |
答复 A |
3 |
3 |
正常 1 |
| 20:03 |
答复 B |
1 |
0 |
正常 1 |
| 次日 05:32 |
答复 C |
0 |
0 |
正常 1 |
根因
纯前端事件监听器泄漏,导致一次点击触发多个绑定旧会话的处理器发送消息。与服务端、扩展均无关。
泄漏链路(file:line,版本 0.0.1-beta.36)
- document 级事件委托:
web/src/components/session/chat/ask-question-handler.js:66 使用 documentImpl.addEventListener('click', onClick) 把点击处理器注册在 document 上,并在 L67-70 返回 {dispose, onClick};
- dispose 被丢弃:
web/src/components/session/chat/chat-composer-runtime.js:205-208 调用 setupAskQuestionHandlers 后丢弃了返回值(同文件中 setupSteerQueue L210、setupWorkerStatusPolling L223、contextPopover L246 也同样丢弃返回值);
- 无聚合清理:
runChatComposer(L29-278)本身不返回聚合 dispose;ChatComposer.svelte:56-93 的 onMount 也丢弃 runChatComposer 的返回值,其清理函数只移除了 pi-queue-event 监听;
- SPA 切换会话销毁重挂:
web/src/App.svelte:69-72 使用 {#key sessionId}<SessionPage />{/key},每次切换会话页销毁并重挂整棵组件树——但 document 级监听器不随组件销毁。于是每访问一个会话页,就永久泄漏一个闭包持有该会话 sendChatMessage(旧 sessionId)的 document 级点击监听器;
- 错误发送:之后在任何当前页点击 ask 选项按钮,所有泄漏处理器都通过
event.target.closest('.ask-question-option-action')(ask-question-handler.js:35-49)从当前 DOM 读到同一按钮的 question/answer,各自调用 sendChatMessage("问题" = "答复")(L47),经 chat-api.js:1-11 POST /api/chat?id=<旧sessionId> 注入各自的旧会话。
为什么注入次数 = 页面访问次数
一次点击时,当前 tab 内所有泄漏处理器同时入队发送,因此:
- 答复 A 前 S1/S2 各被访问过 3 次 → 各注入 3 次;
- 答复 A 与 B 之间发生过一次整页刷新(S1 在 20:02:13.787 有真实手打消息佐证用户刚访问过 S1 页)→ 刷新清空旧泄漏、S1 重新挂载 1 次 → B 时 S1×1、S2×0;
- 答复 C 是次日全新浏览器会话 → 无泄漏 → 0 注入。
证据
一手证据(会话 JSONL,UTC)
服务端排除(路由正确,无广播 bug)
internal/server/chat.go:29-73 handleChat 按 ?id= 定向对应 worker;
internal/server/server.go:466-490 SSE broadcast 按 c.sessID 过滤;
internal/workers/manager.go:165-184, 310-353 Send/workerFor 按 sessionID 路由。
扩展排除
~/.pi/agent/extensions/ui-sync/ 全部代码只调用 ctx.ui.setStatus/setWidget/notify,无 fetch、无 HTTP、无聊天路由(grep fetch|/api/chat|sendChat 无命中)。
上游检索
GitHub 上无同类报告(关键词 ask answer / cross-session / injected / wrong session,open+closed 两轮检索)。相关度最高的仅 #102「Web UI 不渲染内置 ask_question」(已关闭,不同 bug)。
修复建议
治本(最小改动):让 runChatComposer 收集并返回聚合 dispose(覆盖 setupAskQuestionHandlers / setupSteerQueue / setupWorkerStatusPolling / contextPopover 等全部子控制器),ChatComposer.svelte onMount 的清理函数调用它。
注意:同一批还有 steer-queue 的 window 监听器、worker-status 的 setInterval 轮询也在以同样方式泄漏(每访问一次会话页泄漏一个轮询 interval),应一并处理。
加固(可选):
- 把点击委托从
document 收窄到随组件销毁的容器元素(composer form / messages 区);或
- 在
onClick 内校验目标卡片属于当前会话(按钮带 data-session-id 且与闭包 sessionId 匹配才发送)。
用户侧规避
点 ask 选项前,若曾在同一浏览器 tab 内切换过其他会话页,先整页刷新当前页(刷新即清空全部泄漏监听器)。重启 pi-web 服务端无效(纯前端 bug)。症状识别:答复后无关会话出现重复的 "问题" = "答复" 消息即中招。
复现要点
- 打开 pi-web,依次访问 3 个不同会话页(触发 3 次重挂);
- 回到最后一个会话页,触发 ask 弹窗并点击任一选项;
- 观察前两个会话:各被注入 1 条
"问题" = "答复" 消息并各自执行 agent 轮次。
环境信息
- pi-web:Go 版单一实例(pi-web.exe:31415),内嵌前端与 npm 包
@ygncode/pi-web 0.0.1-beta.36 同版本;
- 浏览器端无 devtools 抓取(泄漏处理器计数由 JSONL 注入计数反推)。
[Bug] ask 答复被注入到无关会话:ask-question-handler 在 document 上注册点击委托且 dispose 被丢弃,SPA 切换会话时持续泄漏监听器
现象
在 Web UI 的 A 会话页点击内置 ask(
pi_web_ask_user_question)弹窗卡片的选项按钮后,除当前会话正常收到答复外,其他无关会话也被注入了同样的"问题" = "答复"格式的用户消息,并且每个无关会话各执行了完整的 agent 轮次(模型被无意义地唤醒数轮)。注入次数呈现明显的不对称模式:
根因
纯前端事件监听器泄漏,导致一次点击触发多个绑定旧会话的处理器发送消息。与服务端、扩展均无关。
泄漏链路(file:line,版本 0.0.1-beta.36)
web/src/components/session/chat/ask-question-handler.js:66使用documentImpl.addEventListener('click', onClick)把点击处理器注册在document上,并在 L67-70 返回{dispose, onClick};web/src/components/session/chat/chat-composer-runtime.js:205-208调用setupAskQuestionHandlers后丢弃了返回值(同文件中setupSteerQueueL210、setupWorkerStatusPollingL223、contextPopoverL246 也同样丢弃返回值);runChatComposer(L29-278)本身不返回聚合 dispose;ChatComposer.svelte:56-93的 onMount 也丢弃runChatComposer的返回值,其清理函数只移除了pi-queue-event监听;web/src/App.svelte:69-72使用{#key sessionId}<SessionPage />{/key},每次切换会话页销毁并重挂整棵组件树——但 document 级监听器不随组件销毁。于是每访问一个会话页,就永久泄漏一个闭包持有该会话sendChatMessage(旧 sessionId)的 document 级点击监听器;event.target.closest('.ask-question-option-action')(ask-question-handler.js:35-49)从当前 DOM 读到同一按钮的 question/answer,各自调用sendChatMessage("问题" = "答复")(L47),经chat-api.js:1-11POST /api/chat?id=<旧sessionId>注入各自的旧会话。为什么注入次数 = 页面访问次数
一次点击时,当前 tab 内所有泄漏处理器同时入队发送,因此:
证据
一手证据(会话 JSONL,UTC)
ask-question-handler.js:47模板逐字符一致("…吗?" = "看不到/没有");pi_web_ask_user_question工具调用**只存在于 S3(会话 64a27202)**的 JSONL 中(19:56:22 / 20:02:51 / 20:36:30);S1(bb3ff899)无相关 ask 调用(19:53:47 的命中是 bash toolResult 正文文本),S2(01a04e7e)完全没有 → 被点击的卡片只可能在 S3 页面 → S1/S2 的注入只能来自绑定旧会话的泄漏处理器;服务端排除(路由正确,无广播 bug)
internal/server/chat.go:29-73handleChat按?id=定向对应 worker;internal/server/server.go:466-490SSE broadcast 按c.sessID过滤;internal/workers/manager.go:165-184, 310-353Send/workerFor 按 sessionID 路由。扩展排除
~/.pi/agent/extensions/ui-sync/全部代码只调用ctx.ui.setStatus/setWidget/notify,无 fetch、无 HTTP、无聊天路由(grepfetch|/api/chat|sendChat无命中)。上游检索
GitHub 上无同类报告(关键词 ask answer / cross-session / injected / wrong session,open+closed 两轮检索)。相关度最高的仅 #102「Web UI 不渲染内置 ask_question」(已关闭,不同 bug)。
修复建议
治本(最小改动):让
runChatComposer收集并返回聚合 dispose(覆盖setupAskQuestionHandlers/setupSteerQueue/setupWorkerStatusPolling/contextPopover等全部子控制器),ChatComposer.svelteonMount 的清理函数调用它。加固(可选):
document收窄到随组件销毁的容器元素(composer form / messages 区);或onClick内校验目标卡片属于当前会话(按钮带data-session-id且与闭包 sessionId 匹配才发送)。用户侧规避
点 ask 选项前,若曾在同一浏览器 tab 内切换过其他会话页,先整页刷新当前页(刷新即清空全部泄漏监听器)。重启 pi-web 服务端无效(纯前端 bug)。症状识别:答复后无关会话出现重复的
"问题" = "答复"消息即中招。复现要点
"问题" = "答复"消息并各自执行 agent 轮次。环境信息
@ygncode/pi-web0.0.1-beta.36 同版本;