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.rs(main @ 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 上均存在,不是新版本引入的回归。撰写时已在 main、v0.2.4-dev.109、v0.2.3 三个 ref 上逐字核对。
触发链
- 实例的某个子进程向 stderr 写入非 UTF-8 字节。
典型来源:Windows 中文环境下,未声明输出编码的 Python 子进程按系统 ANSI(GBK)编码非 ASCII 字符。例:省略号 … → 字节 A1 AD,不是合法 UTF-8。
- 若该子进程以
stdio: ['pipe','pipe','inherit'] 启动(stdio 传输的 MCP 服务即如此),原始字节绕过 Node 流层,直灌实例的 stderr 管道。
- 启动器 stderr 读取器读到该行 →
next_line() 返回 Err(InvalidData) → 循环结束 → 读端关闭。
- 实例其后第一次写 stderr →
EPIPE。若该写入直接走 process.stderr.write() 即当场退出;若走 console.*,Node 吞掉第一次,第二次写入才退出(本次事故为后者)。最终 exit 1。
实测证据(已复现)
(1)注入实例的探针捕获到致命异常(--require 挂 uncaughtException):
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.109 与 main 逐字复核,仍存在——即升级到最新开发版并不能规避本问题 |
| 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 - 预期行为
读取循环应区分「读错误」与「流结束」,不应静默丢弃读端。建议任一:
- 改显式
match:Err(e) 记一条 warning 后 continue(对 InvalidData 用 String::from_utf8_lossy 有损解码后继续读);
- 或不用
lines(),改 read_until(b'\n') + String::from_utf8_lossy,从根上不再产生 InvalidData;
- 至少在
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 - 检查项
OS Version - 操作系统
Windows
Launcher Version Details - 启动器版本信息
0.2.4-dev.103
DSH Instance & Profile - DSH 实例与 profile
0.1.5-rc.2
Existing behavior - 现有行为
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)
实例正常启动、可对话;开始干活后数秒到数十秒内进程毫无征兆消失,网页失联。现场特征:
实例 <id> 意外退出(exit code: Some(1));关键点:读取端死在它要读的那一行,所以那一行永远不会被写进日志。「日志干净」不能作为排除依据,这也是该问题排查成本极高的直接原因。
相关代码
src-tauri/src/process.rs(main@3a802ae713,blob729565327de4e8e4bd73e3e27f544bbb0c707a28):stream_pipe(tasks.rs)ensure_web_profile_template(tasks.rs)ensure_web_profile_template_wsl(tasks.rs)实例输出两处写法一致:
tasks.rs三处同为BufReader::new(..).lines();其中 456 / 659 以tokio::select!包装,Err落入_ => break分支,效果相同——均不记录任何内容,也不区分「读错误」与「流结束」。影响范围:该写法在可查的最早发行标签(
v0.1.0-dev.16)至最新main上均存在,不是新版本引入的回归。撰写时已在main、v0.2.4-dev.109、v0.2.3三个 ref 上逐字核对。触发链
典型来源:Windows 中文环境下,未声明输出编码的 Python 子进程按系统 ANSI(GBK)编码非 ASCII 字符。例:省略号
…→ 字节A1 AD,不是合法 UTF-8。stdio: ['pipe','pipe','inherit']启动(stdio 传输的 MCP 服务即如此),原始字节绕过 Node 流层,直灌实例的 stderr 管道。next_line()返回Err(InvalidData)→ 循环结束 → 读端关闭。EPIPE。若该写入直接走process.stderr.write()即当场退出;若走console.*,Node 吞掉第一次,第二次写入才退出(本次事故为后者)。最终 exit 1。实测证据(已复现)
(1)注入实例的探针捕获到致命异常(
--require挂uncaughtException):(2)启动器同刻记录:
实例 <instance-id> 意外退出(exit code: Some(1))(带探针时为 7——探针在
uncaughtException里重抛所致;无探针时即 1,与历史多次崩溃一致。)(3)旁证(观察日期 2026-09-11,修复前):只有 stderr 流死,stdout 全程正常——同实例的启动横幅、插件日志均照常落盘;唯独该子进程 stderr 里纯 ASCII 的行每次启动都出现,含非 ASCII 字符的行一次都没落盘,正好指认读取器死在那一行。
环境 (Environment)
0.2.4-dev.103;该读取器写法已在v0.2.4-dev.109与main逐字复核,仍存在——即升级到最新开发版并不能规避本问题0.1.5-rc.2/ profileDefaultChecks
Expected behavior - 预期行为
读取循环应区分「读错误」与「流结束」,不应静默丢弃读端。建议任一:
match:Err(e)记一条 warning 后continue(对InvalidData用String::from_utf8_lossy有损解码后继续读);lines(),改read_until(b'\n')+String::from_utf8_lossy,从根上不再产生InvalidData;Err分支写一条日志说明「读取已终止及原因」。第 3 点是关键:读取端死亡不留痕迹,是这个故障排查成本极高的直接原因。 只要它留一行日志,用户就能立刻定位,而不是面对一个「无堆栈、无转储、无事件」的静默消失。
补充:方案 1 不丢数据——
Lines::next_line()在返回InvalidData后,下一次调用会继续读取后续行,因此continue不会跳过后续内容;被跳过的仅是那一行本身(用from_utf8_lossy可将其一并保留)。To Reproduce - 复现问题
最小复现(无需安装任何插件):任意子进程向 stderr 写一个非法 UTF-8 字节。
以
Stdio::piped()拉起它,用与process.rs相同的while let Ok(Some(line))语义读取(读到非法 UTF-8 即结束循环并关闭读端),可观察到:console.error撞 EPIPE → 退出码 1(第一次会被 Node 吞掉,这正是「实例能继续运行一段、随后在工具派发附近突然消失」的原因)。实测输出(Node v26.7.0 / Windows):
真实环境复现:以启动器启动一个 profile,其中含一个未声明输出编码的 Python MCP stdio 服务,在对话中触发任意工具调用即可(实例会在几十秒内静默消失)。
Checks - 检查项