DeepSeek Harness:一个可选组件失败为何拖垮整棵树,EPERM 闪退与 MCP 连坐

故障排查发布于 2026-10-03作者: DeepSeek Plugin 插件市场
DeepSeek HarnessDSHchokidarunhandledRejectionMCPPlaywrightfailOnStartupError错误隔离
技能目录里一个读不了的子目录就让 dsh web 直接 fatal load failure 退出;实验性 Playwright MCP 启动失败又让 session/create 一律 500、所有新会话建不出来。两条故障同源:可选组件的失败被升级成了整棵树失败。

两个看起来毫不相干的故障,根因其实是一句话:一个可选组件的失败,被升级成了整棵树的失败。 第一个是 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 1Host 进程存活,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 读不了:

powershell
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 即失败、且之后每次都失败,是这条的典型指纹:

bash
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 挂载:

text
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 的选项是:

ts
{
  persistent: /* … */,
  ignoreInitial: /* … */,
  depth: 1,
  followSymlinks: /* … */,
  atomic: /* … */,
  awaitWriteFinish: /* … */,
  usePolling: /* … */,
  interval: /* … */,
}

没有 ignorePermissionErrors。 于是 chokidar 对被监视目录的 EPERM 会走默认路径,重新 emit('error')。报告人这半段判断是对的。

2. 完整逃逸链(6 步)

按源码顺序把它串起来,每一步都标了位置,方便你对着自己那份产物核:

  1. chokidar/handler.js:591 里 await fsrealpath(path) reject,错误 EPERM。
  2. _addToNodeFs 把错误交给 _handleError,后者重新 emit('error')(chokidar/index.js:552-562)。只有 ENOENT/ENOTDIR,或开了 ignorePermissionErrors 的 EPERM/EACCES,才会被抑制——本例两个条件都不满足。
  3. provider 在 ready 之前就收到了首个 error,于是 readiness.reject(error)(skill-filesystem/src/index.ts:520-525)。
  4. 外层 catch(同文件 :536-540)关闭 watcher 并 rethrow。
  5. close() 内部调用 removeAllListeners()(chokidar/index.js:419)——此刻之后,同一个树上任何 emit('error') 都没有 listener 了。
  6. 另一条并发分支上,this._addToNodeFs(path, initialAdd, wh, depth + 1)(handler.js:488)是 fire-and-forget:没有 await、没有 .catch()。它的 rejection 落在 listener 已被摘除之后,Node 只能从 emit 里把它重抛出来,升级为 unhandledRejection,被启动器的 installFailLoud 统一处理,打印而出:
text
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 原样11615
watcher 不 close16160
ignorePermissionErrors: true000
close 后重挂一个吞错 listener1160
close 后对 handler.js:488 加 .catch1160
close 后对 add() 链加 .catch11615

三行关键读数:

  1. watcher 不 close → 0 逃逸。说明致命不是「错误本身」,而是「close 摘掉 listener」这个动作。
  2. handler.js:488 加 .catch → 0 逃逸,而对 add() 链加 .catch 却没效果(仍 15)。说明逃逸点在 _addToNodeFs 的内部调用上,不在外层 add() 返回的 Promise 上——你想在外层兜是兜不住的。
  3. ignorePermissionErrors: true → 三列全 0。它从最上游就不产生 error 事件,所以连 close 的时机都无关了;代价见后文。

4. 竞态边界:一个不可读条目根本不复现

很多人会以为「只要有不可读目录就必崩」,实测不是。每组 10 次:

不可读条目数10 次里复现watcher 不 close
10 / 100 / 10
28–10 / 100 / 10
1610 / 100 / 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 的最终形态(行号为定稿版本):

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):

ts
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之后新建会话
246424全部失败
24532024
266442424 / 0

数值在 0 / 24 之间切换,与「exclusive 独占挂载」的语义一致:同一个 Host 进程里这个工具只被允许挂载一次,谁先拿到谁成功。这解释了为什么「开机后干净启动 → 一开始能用 → 用着用着全崩」——第一次挂载把独占位置占住了,之后的挂载失败就一路把会话创建打穿。

4. rc.2 明确不改变行为(这点很重要,别白升)

报告人做了两件事来排除「是不是已经修了」:

  1. 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 个提交未触碰)。
  2. npm pack 后逐文件哈希比对——lib/types/mcp.js 等产物内仍然是 failOnStartupError: true + reconnect: { enabled: false },与 rc.1 逐字节相同。

结论:升级到 rc.2 不能解决这条。 唯一有效的现场止血是改配置。

5. 资源泄漏是「越用越坏」的放大器

崩溃后残留 12 个孤儿 Edge 进程(约 1.5 GB),父链指向一棵已经不再关联任何 agent 的浏览器树,必须手动清:

powershell
# Windows:按进程树强杀
taskkill /T /F /PID <orphan-edge-pid>
bash
# 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 的选项块加一个开关:

ts
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:

ts
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 里两处改动,各自都能堵住逃逸:

js
// 1) index.js:没有 listener 时不要 emit,避免 Node 从 emit 重抛
if (this.listenerCount(EV.ERROR) === 0) return
this.emit(EV.ERROR, error)
js
// 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

如果你现在就要跑起来,可以在同一个配置块里关掉热重载:

yaml
- 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:用户侧一行止血(可立即恢复使用)

yaml
- 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 也打开:

yaml
    reconnect: { enabled: true }

方案 B:上游——在已有的降级分支上收口

同一个文件 :183-187 已经是这个形状:资源被占用时只标 blocked、然后 return。把 MCP 挂载失败接到同一条路上即可:

ts
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、权限还是握手:

ts
throw new Error(
  `mcp-client(${config.serverName}): initial connection or tool synchronization failed: ` +
  `${formatCause(outcome.error)}`,
  { cause: outcome.error },
)

方案 D:资源回收

会话释放或启动失败时,确保 playwright-mcp 进程与 --isolated 的 headless 浏览器进程树被清理。这一条与上面的止血是互补关系:不清理 → 残留进程提高下次失败概率 → 更容易踩中连坐。

排查注意事项与总结

排查注意事项

  1. 先分清「崩溃在启动」还是「崩溃在运行」。前者看 dsh: fatal load failure,后者看 RPC 状态码与页面报错——两条根因、两套修法,不要互相套用。
  2. 看到 realpath 就以为是权限问题是误判。本例 provider 已经接住错误,致命的是无人 catch 的 rejection。判断依据是栈只有两帧、且报错由启动器的 installFailLoud(unhandledRejection)打出。
  3. 单条目不复现是正常的。需要 ≥2 个不可读条目、且 watcher 会在扫描中途 close()。所以「删了一个目录好像好了」不代表修好了。
  4. watch: false 是绕行,不是修复:它去掉 watcher,也就去掉了热重载。适合现场止血。
  5. 别把 ignorePermissionErrors: true 当成无代价开关。它连根目录不可读也一起静音,complete 会从 false 变 true,上层不再降级缓存。
  6. 机制二先做离线等价挂载对照(同配置、同 @playwright/mcp、同 executablePath、同 cwd)。实测 483 ms 成功、注册 24 个工具,说明配置没问题,别在 YAML 上耗时间。
  7. session/create 500 是症状不是根因。500 里那句话没有 cause,需要去 Host 日志或自己打点拿底层错误。
  8. 升级前先确认版本线是否真含修复。机制二在 rc.2 明确未修(git log 仅两个提交、npm pack 逐文件哈希一致),别指望升级治好。
  9. 注意 npm latest 的陷阱:它当时仍停在 0.1.5-rc.3,比 0.1.7-rc.2 更旧;要用 npm i -g @deepseek-ai/dsh@next。
  10. 清理孤儿进程再排查:残留的 headless Edge(本次 12 个、约 1.5 GB)会持续占用内存与浏览器 profile,不清干净会把「已经修好的假象」重新打回原形。

总结:两条链,一个共同的设计缺陷

把两条链并排看,形状完全一样:

text
机制一:  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,而是把「可选组件不可用」这条常见路径显式建模出来。

来源:

文中涉及的提交、文件路径与行号均来自上述讨论贴中的实测与源码引用;关于 dsh-scope 双实例的推断来自 #4573 方向的核对,报告人明确未能稳定复现,仅作候选记录。


排查完这类「一个可选组件拖垮整棵树」的故障后,往往要顺手检查环境里到底装了哪些插件、哪些版本、哪些挂了却还在列表里。DSH Plugin Hub 提供插件市场、已安装列表、自定义安装、设置与系统日志五个界面,可以按来源筛选(目录插件 / 自定义安装)、查看版本与更新时间,并在系统日志页按级别与分类回溯安装、卸载、设置变更的执行轨迹——很适合用来确认「到底是哪个插件在拖后腿」,再决定卸载还是改配置。

DSH Plugin Hub 设置界面:更新设置、安全信任、系统诊断、系统日志与恢复默认五个分组

常见问题

为什么我的 dsh web 启动时先打印了 Web URL,然后又自己退出并报 `dsh: fatal load failure`?

因为打印 URL 与加载插件是并行推进的:Web 服务先起来了,随后 skill-filesystem 扫描 customSkillDirs 时,chokidar 对被监视目录调用 realpath() 抛了个 EPERM(Windows 上常见于 ACL 损坏的 .pytest_cache)。这个错误本身 provider 已经接住了,真正致命的是一个没人 catch 的 Promise 在 watcher close() 之后逃逸成了 unhandledRejection,被启动器的 installFailLoud 统一捕获后按致命错误退出。也就是说崩溃点是「未处理的 rejection」,不是「读不到目录」。

我把那个读不了的目录删了,为什么换个 Skill 目录又崩?

因为触发条件不是「某个特定目录」,而是「同一次扫描里出现 ≥2 个不可读条目」。实测把 1 个不可读条目放进监视树,10 次里 0 次复现;放 2 个,10 次里 8–10 次复现。单条目时那条 rejection 的时序恰好能在 removeAllListeners() 之前被 _handleError 消化掉,所以看起来「没事」;一旦有并发兄弟扫描,第二条就会落在 listener 已被摘除之后。换句话说,修目录只是碰运气,得从 watch 配置或上游代码上收口。

设置 `ignorePermissionErrors: true` 有什么代价?它看起来一行就修好了。

代价是「连根目录读不了也一起静音」。这个选项会让 chokidar 对 EPERM/EACCES 直接不发 error 事件,于是不可读的**根**目录也会变成「零候选、无报错」,provider 返回 complete: true,上层就不会降级到缓存,也不会记 skill provider "filesystem" skipped。如果你能接受「技能目录整体读不了时表现为没有技能」就够用;想保留可观测性,就选更窄的修法(close() 之后保留一个吞错 listener),让首个错误仍然走到 handleWatcherError、complete 仍然为 false。

新建会话报 `mcp-client(playwright-mcp): initial connection or tool synchronization failed`,但我把 `failOnStartupError` 改成 `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

来源