Tencent / Tencent/BrowserSkill
[Bug][dsh-plugin] start 未返回时残留无人认领的 Agent Window,DSH 侧任何路径都关不掉它 | unregistered Agent Window orphaned by a non-returning browser_session start
Nobody has claimed this yet.
- Dominant language
- TypeScript
- Stars
- 5.7k
- Forks
- 399
- Avg merge
- 2d 9h
- Merged PRs (30d)
- 74
Description
摘要
@wxg-prc-cpg/browser-skill-dsh-plugin@0.2.1 的 browser_session { action: "start" } 只在 bsk session start 成功返回之后才把会话登记进插件注册表。一旦这次调用以任何方式失败/超时/被取消,而 daemon 侧
已经创建了会话并弹出了 Agent Window,该窗口就成为"孤儿":插件不知道它存在,session list 看不到它,
session stop 直接报错,插件卸载和会话归档清理也都基于注册表、碰不到它。用户只能手动关掉那个 Chrome 窗口,
或用 CLI bsk session stop <id> 收尾。
在 Windows 上这不是罕见竞争,而是近乎必现:配合 #180 / #183,凡是"需要自动拉起 daemon 的那次 start"
都会永久挂起 → 必然走进这条无清理的失败路径。
运行环境
| 项目 | 值 |
|---|---|
| 操作系统 | Windows 10 22H2 (19045),x64 |
| 浏览器 | Google Chrome 152.0.0.0 (Chromium),扩展 0.2.1(Chrome 应用商店版) |
bsk CLI / daemon |
0.2.1(协议 1.1),安装在 ~/.local/bin/bsk.exe,BSK_HOME=~/.bsk |
| 插件 | @wxg-prc-cpg/browser-skill-dsh-plugin 0.2.1 |
| 宿主 | DeepSeek Harness Desktop 2.0.10,profile 为 desktop |
| 健康状态 | bsk doctor 全部 ok(exit 0),browsers connected 1,daemon 监听 127.0.0.1:52800 |
上报前已排除的可能原因:
- 端口不是本次的原因。
52800最初落在 Hyper-V/HNS 的动态保留段内(52710–52809→ 绑定时EACCES);
该问题已单独修复:把52800登记为 administered exclusion 并重启winnat。现在doctor全绿、扩展稳定在线。 - "daemon 已经忘记该会话"这条路径是被幂等处理的:
bsk session stop <unknown-id> --json返回
{"code":"not_found", ...},而isSessionNotFoundError()恰好匹配这个码,因此 stop 会成功。
下面报的缺陷走的是另一条路径。
复现步骤
路径 A —— Windows 上最常见的一种(daemon 尚未运行):
bsk daemon stop(或干脆重启系统 / 重启 dsh,让 daemon 不在运行)。- 调用技能,然后调用
browser_session { action: "start", url: "https://example.com" }。 - Agent Window 正常弹出、daemon 侧会话已创建,但工具调用永不返回
(即 #180 / #183:runner 只在子进程close事件上 settle,而 Windows 上被 detach 的 daemon 继承了
插件的 stdout/stderr 管道句柄,导致close永不触发)。 - 中断该回合(点 Stop),或等待宿主放弃这次调用。
- 这时再试图关闭它:
browser_session { action: "list" }→no active browser sessionsbrowser_session { action: "stop" }→ 报错(见下)browser_session { action: "stop", session: "<从 daemon 日志里读到的 id>" }→ 报错(外来 id)- 卸载插件 / 归档该会话,同样都关不掉这个窗口
路径 B —— 任何其他导致 start 不返回的原因:bsk session start 执行期间回合被取消、宿主侧工具超时、
调用中途应用重启。孤儿窗口的后果完全相同。
本次实测中两条路径各出现一次(同一会话内产生两个孤儿):
| 孤儿会话 | 触发方式 | 结果 |
|---|---|---|
apig |
Agent Window 弹出后回合被中断;当时 daemon 已在运行,因此不是 #180 的自动拉起挂起(具体中断原因未进一步定位) | 成为孤儿;该窗口活得比之后所有 browser_session 调用都久 |
tdwu |
需要自动拉起 daemon 的那次 start(路径 A / #180) | 成为孤儿;会话在 daemon 中存活 02:32:35Z → 02:38:05Z,插件完全看不见它 |
所以路径 A 在 Windows 上不是理论推测 —— 重启系统或重启 dsh 之后,daemon 必然是停的,第一次 start 就会走这条路。
实测现象
$ browser_session { action: "stop" }
Error: browser_session action=stop needs a session but none is active — use action=start first
以及用 daemon 日志里读到的 id 去停(原文逐字验证):
$ browser_session { action: "stop", session: "tdwu" }
Error: browser_session(action=stop): session "tdwu" does not belong to this plugin — only sessions created
by browser_session action=start are visible and operable here
与此同时 daemon 里仍然留着该会话,Agent Window 也还在屏幕上。下面是路径 A 中由自动拉起产生的那个孤儿
在 daemon 日志里的记录:
{"timestamp":"2026-09-15T02:32:35Z","level":"INFO","fields":{"message":"daemon ready","pid":38232,"ws_port":52800,...}}
... # ← 插件的 start 从未返回;没有任何 session id 到达模型
{"timestamp":"2026-09-15T02:38:05Z","level":"INFO","fields":{"message":"session removed: user closed Agent Window","session":"tdwu"}}
{"timestamp":"2026-09-15T02:38:05Z","level":"INFO","fields":{"message":"idle session stopped","session":"tdwu"}}
也就是说:创建它的那次工具调用从未返回,而这个会话在 daemon 里存在了约 5.5 分钟。在这段时间里
DSH 侧没有任何路径能关掉它 —— list 看不到、两种 stop 都失败、插件卸载与会话归档清理都基于注册表。
(我们未能确认窗口最终消失是因为用户手动关闭,还是 daemon 侧回收;无论如何,它熬过了所有插件侧尝试。)
放大因素
回合被中断后,六个 browser_* 工具会立刻从对话里消失(unknown tool "browser_session"),必须重新调用
技能才会回来,于是 agent 连"尝试 stop"都做不到。该现象观察到两次,都紧跟在被中断的 start 之后。这可能属于
宿主侧行为(懒加载的工具注册绑定在被中止的回合上),但它抹掉了唯一的软件兜底手段,建议一并确认。
根因
src/session.ts / session.start(0.2.1 构建产物 lib/index.mjs,第 2415–2435 行):
async execute(args, exec) {
...
registry.reserveStart();
const startArgs = ["session", "start"];
...
let reply;
try {
reply = await runBsk(deps, exec, startArgs, "session start");
} catch (error) {
registry.abandonStart(); // ← 只是把名额还回去
throw error;
}
registry.completeStart({ // ← 只有成功返回后才登记
sessionId: reply.session_id, ...
});
registry.trackOwner(reply.session_id, ownerSessionIds(deps.ctx, exec.agent?.id));
deps.observation.addSession(reply.session_id, args.url);
...
}
以及那个"预留名额"本身:
/** Give back a reservation after a start that never produced a session. */
abandonStart() {
this.pendingStarts = Math.max(0, this.pendingStarts - 1);
}
可见 runBsk("session start") 的失败路径完全不做 daemon 侧对账。对比之下,登记之后的任何失败都有清理
(emulate/navigate 失败 → observation.stopSession(reply.session_id),第 2451–2458 行),而其余所有清理路径
都是"注册表驱动"的,因此全部绕开孤儿:
| 路径 | 位置 | 为什么碰不到孤儿 |
|---|---|---|
browser_session action=stop |
registry.resolveForStop()(L4089) |
抛 needs a session but none is active |
显式传 session 参数 |
registry.resolve()(L4074) |
抛 "does not belong to this plugin" |
| 插件卸载 | registry.ownedIds()(L4222) |
只遍历注册表 |
| DSH 对话归档清理 | registry.ownedByDsh()(L54,archive-cleanup) |
只看注册表 |
会话 id 只能从成功的 JSON 回包(reply.session_id)里拿到;所以当调用被取消、或 CLI 在回包落地之前被杀,
插件手里根本没有该会话的任何句柄 —— 尽管 daemon 已经把它创建出来、窗口也已经弹出。
注意与 #180 / #183 的关系:#183 修的是挂起(close 永不触发),但任何其它原因导致的取消或超时仍然
会走这条无清理的路径。要让用户可见的症状("起了个浏览器会话,然后关不掉了")真正消失,两者都必须成立。
期望行为
一次没有完成的 start,不应该有可能留下无法管理的 Agent Window:
- 要么会话根本不被创建;
- 要么它被登记/可归因,从而事后能被对账并停掉;
- 且
browser_session应当能够上报并关闭本插件自己导致创建的会话,同时绝不触碰共享同一 daemon 的
其它程序创建的会话。
修复建议
-
首选 —— 让 start 可由调用方归因。 给 CLI 增加一个可选的调用方标识,例如
bsk session start --label <opaque-id>(或--request-id),并由bsk session list --json/
bsk session stop --label回显。这样在 start 被拒绝/超时后,插件就能精确停掉带自己 label 的那个会话:
既不破坏现有的归属约束(README → "Ownership boundary"),也不存在竞态。 -
次选 —— 在失败尝试前后做对账。 在
catch路径上,把 spawn 之前的bsk session list --json快照与
拒绝之后的快照做 diff,停掉"新增且不在插件注册表里"的会话。这是个启发式方案(别的程序可能恰好同时创建
会话),只有在 bsk 能告诉插件"这次 CLI 调用创建了哪些会话"时才可接受;否则请优先采纳方案 1。 -
最小兜底。 让 start 进行中的"预留名额"被记住,并提供一个对账时机(技能被调用时、插件重载时、或下一次
browser_session调用时),使孤儿不可能无限期存活。哪怕只是让browser_session { action: "list" }
把 daemon 侧、它无权操作的会话也列出来,agent 就能解释当前状态,并给用户一条可用的
bsk session stop <id>。
临时绕过(用户当下可用)
bsk session list # daemon 全局视角,能看到孤儿会话
bsk session stop <session_id> # 关掉它以及它的 Agent Window
或者直接在浏览器里关掉那个 Agent Window;如果被中断的回合让 browser_* 工具消失了,重新调用一次技能
(/browser-skill)即可恢复。
Contributor guide
No contributing guide indexed for this repository
First steps
- Read the whole issue, then the project's contributing guide.
- Comment on the issue to say you are picking it up — it saves two people doing the same work.
- Fork the repository and make your change on a branch.
- Open a pull request that references the issue number.
Research direction
Start by reading src/session.ts around session.start, registry.reserveStart(), abandonStart(), completeStart(), and the registry cleanup paths identified in the report. Trace how a cancelled or non-returning bsk session start could be attributed without affecting other daemon sessions, then verify that failed starts are discoverable and stoppable while successful starts and unrelated sessions retain their current ownership behavior.
Written by the indexing model from the issue text.
Assessment
- Tech stack
- typescript
- Domain
- backend, cli, tooling
- Issue type
- Bug
- Difficulty
- 4/5
- Estimated time
- 3-5 days
- Activity status
- Active
- Clarity
- Mostly clear
- Newbie friendliness
- 55/100