Skip to content

[Bug] 输出读取器遇非 UTF-8 字节静默终止,实例随后撞 EPIPE 静默退出(exit code 1) #42

Description

@SDUTNB

OS Version - 操作系统

Windows

Launcher Version Details - 启动器版本信息

0.2.4-dev.103

DSH Instance & Profile - DSH 实例与 profile

0.1.5-rc.2

Existing behavior - 现有行为

本反馈由 Agent 生成:由 DeepSeek Harness 内的 AI 助手在用户环境中完成排查、复现与撰写。文中每条结论均附可复现步骤或实测证据(代码位置、字节级对照、最小复现脚本);如需补充日志或进一步取证,请在本 issue 中提出。

复核基线:撰写时已在 main 与最新开发版 v0.2.4-dev.109 上确认该写法仍存在(详见「相关代码」)。

TL;DR

src-tauri/src/process.rs 的 stdout / stderr 读取器使用 while let Ok(Some(line)) = lines.next_line().await

tokio 的 Lines::next_line() 读到非 UTF-8 字节时返回 Err(InvalidData);该 while let 不匹配 Err,循环静默结束BufReader 被 drop → 管道读端关闭

读取端关闭后,实例下一次向该流写日志即得到 EPIPE: broken pipe, write,且 Node 对 stdout/stderr 的 EPIPE 无兜底 handler → 进程以 exit code 1 静默退出。致命时机取决于写入方式:

  • 直接 process.stderr.write()当次即致命
  • console.* 写入:Node 全局 console 以 errorEmitted 闩锁吞掉第一次错误,第二次写入才致命。本次事故的致命写入经 console.error,即属此路径(见「实测证据」)。

放大效应:实例侧任意子进程吐出一个非 UTF-8 字节,就能让整个实例消失,且读取端不留任何记录——用户只看到一句「意外退出」,无从排查。

同一写法在本仓库共 5 处,本次致命的是实例输出读取器那两处;余下 3 处后果不同但同源,建议一并修复。


现有行为 (Existing behavior)

实例正常启动、可对话;开始干活后数秒到数十秒内进程毫无征兆消失,网页失联。现场特征:

  • 实例日志无任何 error 文本/无堆栈;系统事件日志、崩溃转储均无记录;
  • 启动器只留一句 实例 <id> 意外退出(exit code: Some(1))
  • 会话侧最后一条工具结果为「结果未落盘」,即崩溃发生在工具派发前后。

关键点:读取端死在它要读的那一行,所以那一行永远不会被写进日志。「日志干净」不能作为排除依据,这也是该问题排查成本极高的直接原因。


相关代码

src-tauri/src/process.rsmain @ 3a802ae713,blob 729565327de4e8e4bd73e3e27f544bbb0c707a28):

位置 行号 读取对象 读端静默关闭的后果
stdout watcher 479–480 实例 stdout 致命:实例下次写 stdout 即 EPIPE
stderr watcher 518–519 实例 stderr 致命:实例下次写 stderr 即 EPIPE(本次事故路径)
stream_pipe(tasks.rs) 1390–1391 任务子进程 stdout/stderr(安装、升级等) 该任务日志中途静默截断
ensure_web_profile_template(tasks.rs) 456–459 临时启动的 DSH stdout 就绪检测失灵,退化为超时失败
ensure_web_profile_template_wsl(tasks.rs) 659–662 WSL 内临时 DSH stdout 同上

实例输出两处写法一致:

let mut lines = BufReader::new(err).lines();
while let Ok(Some(line)) = lines.next_line().await {
    log_line(&reader_log, &line).await;
}

tasks.rs 三处同为 BufReader::new(..).lines();其中 456 / 659 以 tokio::select! 包装,Err 落入 _ => break 分支,效果相同——均不记录任何内容,也不区分「读错误」与「流结束」。

影响范围:该写法在可查的最早发行标签(v0.1.0-dev.16)至最新 main 上均存在,不是新版本引入的回归。撰写时已在 mainv0.2.4-dev.109v0.2.3 三个 ref 上逐字核对。


触发链

  1. 实例的某个子进程向 stderr 写入非 UTF-8 字节
    典型来源:Windows 中文环境下,未声明输出编码的 Python 子进程按系统 ANSI(GBK)编码非 ASCII 字符。例:省略号 → 字节 A1 AD,不是合法 UTF-8。
  2. 若该子进程以 stdio: ['pipe','pipe','inherit'] 启动(stdio 传输的 MCP 服务即如此),原始字节绕过 Node 流层,直灌实例的 stderr 管道
  3. 启动器 stderr 读取器读到该行 → next_line() 返回 Err(InvalidData) → 循环结束 → 读端关闭
  4. 实例其后第一次写 stderr → EPIPE。若该写入直接走 process.stderr.write() 即当场退出;若走 console.*,Node 吞掉第一次,第二次写入才退出(本次事故为后者)。最终 exit 1。

实测证据(已复现)

(1)注入实例的探针捕获到致命异常--requireuncaughtException):

Error: EPIPE: broken pipe, write
    at Socket._write (node:internal/net:75:18)
    at writeOrBuffer (node:internal/streams/writable:574:12)
    at _write (node:internal/streams/writable:503:10)
    at Writable.write (node:internal/streams/writable:512:10)
    at [kWriteToConsole] (node:internal/console/constructor:311:16)
    at console.error (node:internal/console/constructor:442:26)

(2)启动器同刻记录实例 <instance-id> 意外退出(exit code: Some(1))
(带探针时为 7——探针在 uncaughtException 里重抛所致;无探针时即 1,与历史多次崩溃一致。)

(3)旁证(观察日期 2026-09-11,修复前):只有 stderr 流死,stdout 全程正常——同实例的启动横幅、插件日志均照常落盘;唯独该子进程 stderr 里纯 ASCII 的行每次启动都出现,含非 ASCII 字符的行一次都没落盘,正好指认读取器死在那一行。

时间敏感性说明:把该子进程的输出编码声明为 UTF-8 之后,这些非 ASCII 行即开始正常落盘。该现象仅在读取端正常工作时可观察,因此这条旁证只在特定时间窗内成立;判断读取端是否健壮,请以(1)的原始栈与下面「预期行为」中「错误留痕」两条为准。


环境 (Environment)

OS Windows
DSH Launcher 复现环境 0.2.4-dev.103;该读取器写法已在 v0.2.4-dev.109main 逐字复核,仍存在——即升级到最新开发版并不能规避本问题
DSH 0.1.5-rc.2 / profile Default
触发子进程 一个 Python 实现的 MCP stdio 服务(未声明输出编码)

Checks

  **版本情况如实说明**:实际复现环境为 `0.2.4-dev.103`,撰写时最新为 `0.2.4-dev.109`。**但本问题不受版本影响**——涉事读取器在 `v0.2.4-dev.109` 与 `main` 上写法完全一致(blob `729565327de4e8e4bd73e3e27f544bbb0c707a28`),且该写法自最早发行标签 `v0.1.0-dev.16` 起即存在,升级无法规避。

Expected behavior - 预期行为

读取循环应区分「读错误」与「流结束」,不应静默丢弃读端。建议任一:

  1. 改显式 matchErr(e) 记一条 warning 后 continue(对 InvalidDataString::from_utf8_lossy 有损解码后继续读);
  2. 或不用 lines(),改 read_until(b'\n') + String::from_utf8_lossy,从根上不再产生 InvalidData
  3. 至少Err 分支写一条日志说明「读取已终止及原因」。

第 3 点是关键:读取端死亡不留痕迹,是这个故障排查成本极高的直接原因。 只要它留一行日志,用户就能立刻定位,而不是面对一个「无堆栈、无转储、无事件」的静默消失。

补充:方案 1 不丢数据——Lines::next_line() 在返回 InvalidData 后,下一次调用会继续读取后续行,因此 continue 不会跳过后续内容;被跳过的仅是那一行本身(用 from_utf8_lossy 可将其一并保留)。

To Reproduce - 复现问题

最小复现(无需安装任何插件):任意子进程向 stderr 写一个非法 UTF-8 字节。

// child.js
process.stderr.write("ascii line OK\n");
process.stderr.write(Buffer.from([0x61, 0xa1, 0xad, 0x62, 0x0a])); // 'a' + GBK「…」+ 'b'
setTimeout(() => { console.error("console 第一次写:被 Node 吞掉,进程存活"); }, 300);
setTimeout(() => { console.error("console 第二次写:致命,进程 exit 1"); }, 800);

Stdio::piped() 拉起它,用与 process.rs 相同的 while let Ok(Some(line)) 语义读取(读到非法 UTF-8 即结束循环并关闭读端),可观察到:

  • 第 1 行正常读出;
  • 第 2 行(含非法字节)触发读错误 → 循环结束、读端关闭
  • 之后 child 的第二次 console.error 撞 EPIPE → 退出码 1(第一次会被 Node 吞掉,这正是「实例能继续运行一段、随后在工具派发附近突然消失」的原因)。

实测输出(Node v26.7.0 / Windows):

读取端 OK      : "ascii line OK"
读取端 InvalidData -> 循环结束,读端关闭
>>> child 退出码 = 1

真实环境复现:以启动器启动一个 profile,其中含一个未声明输出编码的 Python MCP stdio 服务,在对话中触发任意工具调用即可(实例会在几十秒内静默消失)。

Checks - 检查项

  • I have searched the issue tracker and confirmed that there is no identical issue. 我已经搜索问题追踪器并且确认没有相同的 Issue。
  • I admit that I previously checked all options without thorough verification and directly submitted this Issue, and I agree that it can be closed directly. 我承认此前未充分核实便勾选了所有选项并直接提交此 Issue ,且同意该 Issue 可被直接关闭。
  • I confirm that I am currently using the latest release or dev version of DSH Launcher. If not, this issue can be closed directly. 我确认当前使用的是 DSH Launcher 的最新正式版或开发版,若非如此,此 Issue 可被直接关闭。
  • I promise to provide a detailed description of the encountered issue, along with relevant logs and screenshots, rather than offering incomplete or vague information. 我承诺将详细描述遇到的问题,并提供相关日志与截图,而非提供不完整或模糊的信息。

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

    🐛 BugSomething isn't working

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions