DeepSeek Harness:一个可选组件失败为何拖垮整棵树,EPERM 闪退与 MCP 连坐
两个看起来毫不相干的故障,根因其实是一句话:一个可选组件的失败,被升级成了整棵树的失败。 第一个是 dsh web 明明打印出了 Web URL,几秒后却自己退出,只留一行 dsh: fatal load failure: Error: EPERM: operation not permitted, realpath 'D:\codes\skills\audio-transcribe\.pytest_cache'——你以为「读不到那个目录」是致命的,其实 provider 早就接住了这个错误,真正致命的是 chokidar 内部一个没人 catch 的 Promise 在 watcher close() 之后逃逸成了 unhandledRejection(#7537)。第二个是 Web 页面里所有新会话都建不出来、session/create 一律返回 500,报 mcp-client(playwright-mcp): initial connection or tool synchronization failed——一个实验性、可选的浏览器工具启动失败,把整个会话创建流程连坐了(#7865)。两例的共同修法方向也一样:把「工具不可用」限制在该工具自己的作用域内,对外只降级、不致命。 下面按「先分诊 → 机制一 → 机制二 → 修法 → 排查」展开,每步都给能直接粘进终端的命令。
先分诊:两条症状长得完全不同,先别急着删目录
先看症状落点:崩溃发生在启动过程、终端直接退出,走第一条;崩溃发生在 Web 交互中、终端还活着,走第二条。
| 判据 | 机制一:技能目录 EPERM 闪退(#7537) | 机制二:MCP 连坐会话创建(#7865) |
|---|---|---|
| 触发时机 | dsh web 启动扫描阶段 | 运行中新建会话 / 打开会话 |
| 终端表现 | 打印 URL 后 fatal load failure,exit 1 | Host 进程存活,Web 页面报错 |
| 关键日志 | EPERM ... realpath '...\.pytest_cache' | mcp-client(playwright-mcp): initial connection or tool synchronization failed |
| 协议层表现 | 无(进程已退出) | POST /api/session/create → 500 |
| 进程残留 | 无 | 12 个孤儿 Edge 进程(约 1.5 GB) |
| 一行定性命令 | icacls <dir> / ls -ld <dir> | curl -s -X POST .../session/create -d '…' 看状态码 |
第一步,先确认机制一那条:customSkillDirs 指向的树里,是不是存在至少两个当前用户读不了的目录。注意报告里的原始证据——目录本身存在,但 ACL 读不了:
Resolve-Path 'D:\codes\skills\audio-transcribe\.pytest_cache' # 正常返回
Get-Item -Force 'D:\codes\skills\audio-transcribe\.pytest_cache' # 正常返回
icacls 'D:\codes\skills\audio-transcribe\.pytest_cache'
# D:\codes\skills\audio-transcribe\.pytest_cache: Access is denied.
# Successfully processed 0 files; Failed processing 1 files
第二步,确认机制二那条:不需要看 UI,直接看 session/create 的返回码与耗时。约 500 ms 即失败、且之后每次都失败,是这条的典型指纹:
curl -s -o /dev/null -w '%{http_code} %{time_total}s\n' \
-X POST http://127.0.0.1:3080/api/session/create \
-H 'content-type: application/json' \
-d '{"type":"client-request","rpcId":"'"$(uuidgen)"'","method":"session/create","payload":{"args":{"request":{"workspaceId":"<id>"}}}}'
# 500 0.541s
# 500 0.513s
# 500 0.500s ← 稳定复现、耗时稳定,不像握手超时
第三步,做一个能立刻区分「环境坏」还是「进程内部状态坏」的对照实验。机制二那条报告里最有说服力的一步,就是在同一台机器、同一份配置、同一个 @playwright/mcp@0.0.80、同一个 executablePath、同一个 cwd 下,离线跑一次等价的 MCP 挂载:
SUCCESS after 483 ms; registered tools: 24
配置没问题、环境没问题、可执行文件也没问题——问题在常驻 Host 进程内部。这一条对照直接决定了后面的修法方向:不是去改 YAML,而是去限制失败的作用域。
机制一:错误被 provider 接住了,却从「事件通道」逃逸到了进程级
这一节的结论:致命点不在 realpath 失败,而在 watcher 被 close() 之后,一个 fire-and-forget 的兄弟扫描 Promise 还带着同一个错误对象在飞。
1. 先看 chokidar 选项块:provider 没有传 ignorePermissionErrors
在 packages/skill/skill-filesystem/src/index.ts:492-505,传给 chokidar.watch 的选项是:
{
persistent: /* … */,
ignoreInitial: /* … */,
depth: 1,
followSymlinks: /* … */,
atomic: /* … */,
awaitWriteFinish: /* … */,
usePolling: /* … */,
interval: /* … */,
}
没有 ignorePermissionErrors。 于是 chokidar 对被监视目录的 EPERM 会走默认路径,重新 emit('error')。报告人这半段判断是对的。
2. 完整逃逸链(6 步)
按源码顺序把它串起来,每一步都标了位置,方便你对着自己那份产物核:
chokidar/handler.js:591里await fsrealpath(path)reject,错误EPERM。_addToNodeFs把错误交给_handleError,后者重新emit('error')(chokidar/index.js:552-562)。只有ENOENT/ENOTDIR,或开了ignorePermissionErrors的EPERM/EACCES,才会被抑制——本例两个条件都不满足。- provider 在
ready之前就收到了首个 error,于是readiness.reject(error)(skill-filesystem/src/index.ts:520-525)。 - 外层 catch(同文件
:536-540)关闭 watcher 并 rethrow。 close()内部调用removeAllListeners()(chokidar/index.js:419)——此刻之后,同一个树上任何emit('error')都没有 listener 了。- 另一条并发分支上,
this._addToNodeFs(path, initialAdd, wh, depth + 1)(handler.js:488)是 fire-and-forget:没有await、没有.catch()。它的 rejection 落在 listener 已被摘除之后,Node 只能从emit里把它重抛出来,升级为unhandledRejection,被启动器的installFailLoud统一处理,打印而出:
dsh: fatal load failure: Error: EPERM: operation not permitted, realpath 'D:\codes\skills\audio-transcribe\.pytest_cache'
at async realpath (node:internal/fs/promises:1177:10)
at async NodeFsHandler._addToNodeFs (.../chokidar/handler.js:591:45)
[ELIFECYCLE] Command failed with exit code 1.
注意这只有两帧——因为 app-boot/src/index.ts:660 打印的是 ${err.stack},而错误对象在 emit 的往返里被原样重抛,栈没有增加上下文。这也是「看起来像 realpath 直接崩了」的错觉来源。
3. 6 臂测量:把「哪一步才是致命点」量化出来
这是最有价值的一组数据。同一份代码,只改一个变量,统计 emitted error、_handleError 调用、逃逸的 unhandledRejection:
| 实验臂 | emitted error | _handleError 次数 | unhandledRejection |
|---|---|---|---|
| provider 原样 | 1 | 16 | 15 |
| watcher 不 close | 16 | 16 | 0 |
ignorePermissionErrors: true | 0 | 0 | 0 |
| close 后重挂一个吞错 listener | 1 | 16 | 0 |
close 后对 handler.js:488 加 .catch | 1 | 16 | 0 |
close 后对 add() 链加 .catch | 1 | 16 | 15 |
三行关键读数:
- watcher 不 close → 0 逃逸。说明致命不是「错误本身」,而是「close 摘掉 listener」这个动作。
handler.js:488加.catch→ 0 逃逸,而对add()链加.catch却没效果(仍 15)。说明逃逸点在_addToNodeFs的内部调用上,不在外层add()返回的 Promise 上——你想在外层兜是兜不住的。ignorePermissionErrors: true→ 三列全 0。它从最上游就不产生 error 事件,所以连 close 的时机都无关了;代价见后文。
4. 竞态边界:一个不可读条目根本不复现
很多人会以为「只要有不可读目录就必崩」,实测不是。每组 10 次:
| 不可读条目数 | 10 次里复现 | watcher 不 close |
|---|---|---|
| 1 | 0 / 10 | 0 / 10 |
| 2 | 8–10 / 10 | 0 / 10 |
| 16 | 10 / 10 | 0 / 10 |
这就是「删了目录换个 Skill 又崩」的解释:不是目录有魔法,而是需要并发的第二个 rejection 才会踩中窗口。窗口也不是毫秒级——close 路径走 microtask,而兄弟 rejection 来自 fs threadpool 的后续 turn,两者顺序取决于线程池调度。
5. 为什么会走到这条路径上:浏览器是触发者,不是根因
FileSystemSkillProvider.list() 只经由 ctx.skills.list(),唯二的生产调用点是:
packages/client/ui-skill/src/client/index.ts:121——在 composer scope birth 时预热(packages/client/ui-input-trigger/src/types.ts:202);packages/skill/tool-skill/src/index.ts:134——工具侧列举。
也就是说,是「打开/切换输入框作用域」这一动作触发了 provider 的实时列举,进而让 watcher 去扫那棵树。浏览器只是那个按下开关的人。
6. 附带说清:为什么 macOS 复现不出来
在 macOS 上 chmod 000 一个目录,readdir 会失败,但 realpath 依然能解析出来,而且 readdirp 会在进入 _handleError 之前把这类错误吸收掉——实测 0 次 _handleError 调用、0 次 emit。所以这是 Windows ACL 语义(.pytest_cache 这类由其他工具留下的、普通用户连读 ACL 都被拒的目录)叠加出来的路径,遇到时不要怀疑「是不是我系统坏了」。
机制二:可选工具的启动失败,靠 failOnStartupError 升级成了会话级致命
这一节的结论:failOnStartupError: true 与 reconnect: { enabled: false } 的组合,让「一个可选 MCP 连不上」变成「这个 Host 之后再也建不出会话」。
1. 挂载发生在 agent/created 钩子里,而且是 await 的
packages/experimental/browser-use-runtime/src/mcp.ts 的最终形态(行号为定稿版本):
ctx.on('agent/created', async ({ agent, signal }) => {
// :177 注册钩子,{ prepend: true }
const resources = /* … */
await resources.get(agent, signal) // :188
// open() // :129
await scope.ctx.plugin(McpClient, McpClient.Config({ // :144
transport: 'stdio',
serverName: options.name,
command: options.command,
args: options.args,
failOnStartupError: true, // :152 ← 唯一的开关
reconnect: { enabled: false }, // :152
}))
}, { prepend: true })
关键点有两个。第一,这次 plugin() 调用被 await 了,所以它的抛错会沿着钩子一路上浮到 session/create,变成 500。第二,同一个函数在 :183-187 已经有一段优雅降级分支——资源被占用时只把该工具标成 blocked 然后 return,并不抛错。也就是说,同一个文件里已经有「失败只降级」的先例,只是 MCP 启动失败这条路径没有走它。这就是修法最自然的落点(见下文修法二)。
2. 抛出点与它的 cause
packages/mcp/mcp-client/src/index.ts:201(已发布产物里对应 lib/index.js:832):
const outcome = await connection.ready
if (outcome.error !== void 0 && config.failOnStartupError) {
throw new Error(
`mcp-client(${config.serverName}): initial connection or tool synchronization failed`,
{ cause: outcome.error },
)
}
它其实已经带了 cause,只是上层报错文案与 Host 日志都没把 cause 展示出来,所以用户侧只能看到那句毫无信息量的话。这是报告里第二条诉求的由来。
3. A/B:一个 Host 进程里,工具清单在 24 与 0 之间跳变
用 request/header 里 mcp__playwright-mcp__* 的计数来数「这个会话到底注册了几个浏览器工具」:
| Host PID | 第 1 个 Agent | 之后新建会话 |
|---|---|---|
| 2464 | 24 | 全部失败 |
| 24532 | 0 | 24 |
| 26644 | 24 | 24 / 0 |
数值在 0 / 24 之间切换,与「exclusive 独占挂载」的语义一致:同一个 Host 进程里这个工具只被允许挂载一次,谁先拿到谁成功。这解释了为什么「开机后干净启动 → 一开始能用 → 用着用着全崩」——第一次挂载把独占位置占住了,之后的挂载失败就一路把会话创建打穿。
4. rc.2 明确不改变行为(这点很重要,别白升)
报告人做了两件事来排除「是不是已经修了」:
git log --oneline dsh-v0.1.7-rc.1..HEAD -- packages/mcp/ packages/experimental/browser-use-runtime/—— 只有两个提交:787b746b80 release(dsh): 0.1.7-rc.2和e7def469e1 feat(i18n): … (#5036),没有一个碰这条路径(alpha.1→alpha.2 的 162 个提交、rc.1→rc.2 的 346 个提交,合计 508 个提交未触碰)。npm pack后逐文件哈希比对——lib/types/mcp.js等产物内仍然是failOnStartupError: true+reconnect: { enabled: false },与 rc.1 逐字节相同。
结论:升级到 rc.2 不能解决这条。 唯一有效的现场止血是改配置。
5. 资源泄漏是「越用越坏」的放大器
崩溃后残留 12 个孤儿 Edge 进程(约 1.5 GB),父链指向一棵已经不再关联任何 agent 的浏览器树,必须手动清:
# Windows:按进程树强杀
taskkill /T /F /PID <orphan-edge-pid>
# macOS / Linux:清掉挂死的 MCP 与隔离浏览器
pkill -f playwright-mcp
此外每个会话会各自拉起一个 playwright-mcp 进程 + 一个隔离 headless Edge,部分进程在会话结束后仍常驻。这些残留持续占内存与浏览器 profile,会提高后续 MCP 启动失败的概率——于是形成「越用越坏、重启才好」的循环。
6. 一个未证实的候选根因
同一台机器上,宿主安装的 @deepseek-ai/dsh-scope 是 0.1.7-alpha.1,而 profile 内 browser-use-runtime 解析到的 @deepseek-ai/dsh-scope 是 0.1.0-rc.8(junction 目标实测),dsh-mcp-client 也是另一份物理拷贝;profile 的 pnpm-lock.yaml 里同时存在 ^0.1.0-rc.8 与 ^0.1.7-alpha.1 两条 dsh-scope 需求。而 dsh-scope 两版都用 const kScope = Symbol("dsh.scope")(每模块实例一枚,而非 Symbol.for),所以两个实例的 scope 键互不相等。这条与 #4573 描述的现象一致,但报告人明确未能稳定复现,因此只作为候选、不当结论。
修法一(机制一):三层选择,从最窄到最省事
优先级建议:先做 3 止血,再按你能接受的粒度选 1 或 2。
方案 A:最省事——provider 侧一行,代价最粗
给 chokidar.watch 的选项块加一个开关:
chokidar.watch(roots, {
persistent: /* … */,
ignoreInitial: /* … */,
depth: 1,
followSymlinks: /* … */,
atomic: /* … */,
awaitWriteFinish: /* … */,
usePolling: /* … */,
interval: /* … */,
ignorePermissionErrors: true, // ← 新增
})
实测三列全 0(0 emits / 0 _handleError / 0 rejections)。代价:不可读的根目录也会被静音,provider 返回 complete: true 且候选为空。而按契约,只有 complete: false 才会让上层降级到缓存并记录 skill provider "filesystem" skipped。也就是说,这个方案会让「目录整个读不了」看起来像「目录里就是没有技能」。
方案 B:最窄——close 之后保留一个吞错 listener
在 skill-filesystem/src/index.ts:594-601 的 closeWatcher 里,不要直接把 watcher 丢弃,而是先挂一个只吞错、不做别的的 listener:
async function closeWatcher(handle: WatcherHandle): Promise<void> {
const { watcher } = handle
// Keep a swallow-only listener so sibling scans that reject after
// removeAllListeners() cannot escape as an unhandled rejection.
watcher.on('error', () => {})
await watcher.close()
}
实测:1 emitted / 16 _handleError / 0 unhandledRejection。首个错误仍然走到 handleWatcherError,complete: false 语义保留,可观测性不丢。这是与故障范围最匹配的修法。
方案 C:上游两处,任一处单独即足够
chokidar 5 里两处改动,各自都能堵住逃逸:
// 1) index.js:没有 listener 时不要 emit,避免 Node 从 emit 重抛
if (this.listenerCount(EV.ERROR) === 0) return
this.emit(EV.ERROR, error)
// 2) handler.js:488:给 fire-and-forget 的递归扫描挂上 catch
this._addToNodeFs(path, initialAdd, wh, depth + 1).catch((err) => {
this._handleError(err)
})
注意上面第 6 臂的实测已经说明:外层 add() 链上加 .catch 没用,必须打在 _addToNodeFs 这一层(或等价的 handler.js:488)。
现场止血(不改代码):watch: false
如果你现在就要跑起来,可以在同一个配置块里关掉热重载:
- id: skill-filesystem
name: '@deepseek-ai/dsh-skill-filesystem'
config:
includeDefaultRoots: false
customSkillDirs:
- D:/codes/skills
watch: false # ← 不再启动 watcher(retainRoot / ensureWatcher)
代价是失去技能热重载:改完 SKILL.md 要重启才生效。另外别忘了那个前置条件——必须存在 ≥2 个不可读条目才会崩,所以「把唯一那个 .pytest_cache 删掉就不崩了」是符合规律的,不是运气。
修法二(机制二):把失败限制在该工具自己的作用域内
核心思路:session/create 不应该因为一个可选工具挂不上就返回 500;正确行为是「该工具在这个会话里不可用」并且明确提示。
方案 A:用户侧一行止血(可立即恢复使用)
- id: 1afc9ad1
name: '@deepseek-ai/dsh-experimental-browser-use-playwright-mcp'
config:
mode: launch
headless: true
executablePath: <EDGE>\msedge.exe
failOnStartupError: false # ← 从 true 改为 false
改完新建会话立刻恢复。但要清楚:这只是止血,浏览器工具本身仍然不可用(见上文 FAQ 第 4 条)。若同时还想让它自愈,把 reconnect 也打开:
reconnect: { enabled: true }
方案 B:上游——在已有的降级分支上收口
同一个文件 :183-187 已经是这个形状:资源被占用时只标 blocked、然后 return。把 MCP 挂载失败接到同一条路上即可:
try {
await scope.ctx.plugin(McpClient, McpClient.Config({
transport: 'stdio',
serverName: options.name,
command: options.command,
args: options.args,
failOnStartupError: false, // 失败交给下面的 catch 决定
reconnect: { enabled: true }, // 允许后续自愈
}))
} catch (error) {
// A failed optional tool must not fail session creation.
markToolUnavailable(agent, options.name, error)
return
}
要点三条:① 失败只降级,不抛;② 降级要有可见提示,不能静默;③ 打开 reconnect,否则该 Host 进程会一直瘸着。
方案 C:错误文案带上 cause
mcp-client 已经构造了 { cause: outcome.error },只是没被展示。把 Host 日志与 RPC 错误文案改成展开 cause(含 MCP 子进程 stderr),用户侧才能定位到底是 ENOENT、权限还是握手:
throw new Error(
`mcp-client(${config.serverName}): initial connection or tool synchronization failed: ` +
`${formatCause(outcome.error)}`,
{ cause: outcome.error },
)
方案 D:资源回收
会话释放或启动失败时,确保 playwright-mcp 进程与 --isolated 的 headless 浏览器进程树被清理。这一条与上面的止血是互补关系:不清理 → 残留进程提高下次失败概率 → 更容易踩中连坐。
排查注意事项与总结
排查注意事项
- 先分清「崩溃在启动」还是「崩溃在运行」。前者看
dsh: fatal load failure,后者看 RPC 状态码与页面报错——两条根因、两套修法,不要互相套用。 - 看到
realpath就以为是权限问题是误判。本例 provider 已经接住错误,致命的是无人catch的 rejection。判断依据是栈只有两帧、且报错由启动器的installFailLoud(unhandledRejection)打出。 - 单条目不复现是正常的。需要 ≥2 个不可读条目、且 watcher 会在扫描中途
close()。所以「删了一个目录好像好了」不代表修好了。 watch: false是绕行,不是修复:它去掉 watcher,也就去掉了热重载。适合现场止血。- 别把
ignorePermissionErrors: true当成无代价开关。它连根目录不可读也一起静音,complete会从false变true,上层不再降级缓存。 - 机制二先做离线等价挂载对照(同配置、同
@playwright/mcp、同executablePath、同cwd)。实测 483 ms 成功、注册 24 个工具,说明配置没问题,别在 YAML 上耗时间。 session/create500 是症状不是根因。500 里那句话没有cause,需要去 Host 日志或自己打点拿底层错误。- 升级前先确认版本线是否真含修复。机制二在 rc.2 明确未修(git log 仅两个提交、
npm pack逐文件哈希一致),别指望升级治好。 - 注意 npm
latest的陷阱:它当时仍停在0.1.5-rc.3,比0.1.7-rc.2更旧;要用npm i -g @deepseek-ai/dsh@next。 - 清理孤儿进程再排查:残留的 headless Edge(本次 12 个、约 1.5 GB)会持续占用内存与浏览器 profile,不清干净会把「已经修好的假象」重新打回原形。
总结:两条链,一个共同的设计缺陷
把两条链并排看,形状完全一样:
机制一: realpath EPERM
→ provider 接住(没致命)
→ watcher.close() 摘掉 listener
→ 兄弟扫描的 rejection 无人接
→ unhandledRejection
→ installFailLoud → exit 1 ← 失败半径 = 整个进程
机制二: MCP 连接失败(可选工具)
→ connection.ready 带回 error
→ failOnStartupError: true 决定抛
→ agent/created 钩子里 await 上浮
→ session/create 500 ← 失败半径 = 所有会话创建
两处的失败半径都远大于故障源。修法因此也是一致的:让「这条链路失败」只影响到它自己——机制一用 close 后的吞错 listener(或上游那两处),机制二用已有的 blocked 降级分支 + reconnect + 展开 cause。这不是修补某个 bug,而是把「可选组件不可用」这条常见路径显式建模出来。
来源:
- #7537 — [Bug][Windows] skill-filesystem crashes dsh web when a custom skill root contains an inaccessible directory
- #7865 — [bug] 实验性 Playwright MCP 启动失败会导致所有会话创建失败(failOnStartupError: true + reconnect: false),并残留孤儿浏览器进程
文中涉及的提交、文件路径与行号均来自上述讨论贴中的实测与源码引用;关于 dsh-scope 双实例的推断来自 #4573 方向的核对,报告人明确未能稳定复现,仅作候选记录。
排查完这类「一个可选组件拖垮整棵树」的故障后,往往要顺手检查环境里到底装了哪些插件、哪些版本、哪些挂了却还在列表里。DSH Plugin Hub 提供插件市场、已安装列表、自定义安装、设置与系统日志五个界面,可以按来源筛选(目录插件 / 自定义安装)、查看版本与更新时间,并在系统日志页按级别与分类回溯安装、卸载、设置变更的执行轨迹——很适合用来确认「到底是哪个插件在拖后腿」,再决定卸载还是改配置。

常见问题
因为打印 URL 与加载插件是并行推进的:Web 服务先起来了,随后 skill-filesystem 扫描 customSkillDirs 时,chokidar 对被监视目录调用 realpath() 抛了个 EPERM(Windows 上常见于 ACL 损坏的 .pytest_cache)。这个错误本身 provider 已经接住了,真正致命的是一个没人 catch 的 Promise 在 watcher close() 之后逃逸成了 unhandledRejection,被启动器的 installFailLoud 统一捕获后按致命错误退出。也就是说崩溃点是「未处理的 rejection」,不是「读不到目录」。
因为触发条件不是「某个特定目录」,而是「同一次扫描里出现 ≥2 个不可读条目」。实测把 1 个不可读条目放进监视树,10 次里 0 次复现;放 2 个,10 次里 8–10 次复现。单条目时那条 rejection 的时序恰好能在 removeAllListeners() 之前被 _handleError 消化掉,所以看起来「没事」;一旦有并发兄弟扫描,第二条就会落在 listener 已被摘除之后。换句话说,修目录只是碰运气,得从 watch 配置或上游代码上收口。
代价是「连根目录读不了也一起静音」。这个选项会让 chokidar 对 EPERM/EACCES 直接不发 error 事件,于是不可读的**根**目录也会变成「零候选、无报错」,provider 返回 complete: true,上层就不会降级到缓存,也不会记 skill provider "filesystem" skipped。如果你能接受「技能目录整体读不了时表现为没有技能」就够用;想保留可观测性,就选更窄的修法(close() 之后保留一个吞错 listener),让首个错误仍然走到 handleWatcherError、complete 仍然为 false。
两件事被混在了一起。failOnStartupError: false 只改变「挂载失败要不要让会话创建失败」,不改变「MCP 能不能连上」——所以改成 false 之后会话能建了,但那个会话里依然没有浏览器工具。真正要查的是 MCP 为什么连不上(通常是宿主进程里累积的挂载/资源状态问题:实测同配置离线挂载 483 ms 成功并注册 24 个工具,说明配置本身没问题)。另外注意这个组合还带 reconnect: { enabled: false },失败后不会自愈,只能重启 Host。
截至本文所引报告,两条都还在讨论阶段,没有被合入修复。当下的可分诊做法:#7537 那条先确认「是不是至少两个不可读条目同时存在」(单条目不复现),能删/能改 ACL 就清干净,或者给该 provider 配 watch: false 换掉热重载;#7865 那条最直接的止血就是把该插件配置里的 failOnStartupError 置为 false 先恢复会话创建,再用 taskkill /T /F(Windows)或 pkill -f playwright-mcp 清掉孤儿浏览器进程,最后升级到 @next 通道(别用 npm latest,它仍停在更旧的 0.1.5-rc.3)。
相关术语
- unhandledRejection 逃逸
- 指某个异步函数返回的 Promise 既没有被 `await`、也没有挂 `.catch()`,它 reject 时没有任何 handler 接住,Node 便把它升级成 `process` 级的 `unhandledRejection`。本例里 chokidar 的 `this._addToNodeFs(path, initialAdd, wh, depth + 1)` 正是这种 fire-and-forget 调用;当它 reject 的时机晚于 watcher 的 `close()`(`close()` 会 `removeAllListeners()`),连 `emit('error')` 都无处投递,于是错误从事件通道「逃逸」到进程级。— https://github.com/deepseek-ai/deepseek-harness/discussions/7537
- ignorePermissionErrors(chokidar 选项)
- chokidar 的 watch 选项,为 `true` 时对 `EPERM`/`EACCES` 既不打日志也不发 `error` 事件(等价于把这两类错误当 `ENOENT` 处理)。它是「一行止血」但粒度很粗:因为按目录树级别静音,连**根**不可读也不会报错,上层无法区分「目录里确实没有技能」和「目录整个读不了」。— https://github.com/deepseek-ai/deepseek-harness/discussions/7537
- complete 标志(技能 provider 契约)
- provider 返回候选列表时携带的完整性声明。`complete: true` 表示「这就是全部结果」,上层据此认定目录确实为空;`complete: false` 表示结果可能不全(例如某个源被跳过),上层会降级到缓存并记录 `skill provider "filesystem" skipped`。故障隔离的关键就在于让「读不全」表现为 `false` 而不是伪造一个完整的空集。— https://github.com/deepseek-ai/deepseek-harness/discussions/7537
- failOnStartupError(MCP 客户端配置)
- `@deepseek-ai/dsh-mcp-client` 的启动策略开关。为 `true` 时,`connection.ready` 一旦带回错误,插件注册阶段就 `throw`;由于这次注册发生在 `agent/created` 钩子内并被 `await`,抛错会一路上浮让 `session/create` 返回 500。改动为 `false` 只调整「失败的作用域」(降级为该会话没有这个工具),并不修复连接本身。— https://github.com/deepseek-ai/deepseek-harness/discussions/7865
来源
- #7537 — [Bug][Windows] skill-filesystem crashes dsh web when a custom skill root contains an inaccessible directory· deepseek-ai(GitHub Discussions)
- #7865 — [bug] 实验性 Playwright MCP 启动失败会导致所有会话创建失败(failOnStartupError: true + reconnect: false),并残留孤儿浏览器进程· deepseek-ai(GitHub Discussions)