bug: 文件浏览器将 ripgrep 致命退出误报为成功的空结果
- Dominant language
- TypeScript
- Stars
- 2.7k
- Forks
- 395
- Avg merge
- 21h 48m
- Merged PRs (30d)
- 776
Description
## 场景与问题
Cindy 的文件浏览器通过 ripgrep 实现两项能力:
1. `listAllFiles` 使用 `rg --files` 生成工作目录的文件索引;
2. `RipgrepSearcher` 使用 `rg --json` 执行项目级内容搜索。
当 ripgrep 成功启动、随后因配置错误、文件系统错误等原因以致命错误退出时,当前实现没有将错误传递给调用方,而是把已经产生的不完整结果当成一次成功结果返回。
这会把两种含义完全不同的状态混在一起:
- ripgrep 执行失败,结果不完整;
- 工作目录确实没有文件,或内容搜索确实没有匹配项。
上层因此只能把失败显示为“空项目”或“没有匹配结果”,用户无法获知文件索引或内容搜索实际已经失败。
## 环境
- Cindy commit:`c104ed851cc1d70ce9d99213fb44a029ee079c71`
- 相关 package:`@cindy/file-browser-core`
- 平台:macOS 14.5,Apple Silicon(arm64)
- 安装方式:源码仓库
- 内置 ripgrep:15.1.0(`apps/ripgrep-bin/darwin-arm64/rg`)
## 期望行为
1. `listAllFiles` 遇到非预期的自然非零退出时,应通过错误路径 reject,不能把已经收集到的空结果或部分结果作为成功结果返回。
2. `RipgrepSearcher` 遇到退出码 2 或其他非预期的自然非零退出时,应发出 `type: "error"`,不能发出表示正常完成的 `type: "end"`。
3. 内容搜索的退出码 1 应继续表示“没有匹配项”,并正常结束。
4. 用户主动取消搜索,以及 Cindy 达到结果上限后主动终止 ripgrep,应继续作为有意的正常终止处理。
5. 上层应能明确区分“搜索失败”和“没有匹配结果”。
## 实际行为
以下输出来自本地最小复现,已脱敏,不包含凭证、个人数据或本地工作目录路径。
使用包含无效参数的 `RIPGREP_CONFIG_PATH` 时,内置 ripgrep 明确以退出码 2 失败:
```text
退出码:2
stderr:rg: unrecognized flag --definitely-not-a-ripgrep-flag
```
但相同环境下,`listAllFiles` 仍成功 resolve,没有 reject:
```text
{ files: [], truncated: false }
```
`RipgrepSearcher` 只发出正常的空结果结束事件:
```text
[
{
type: "end",
truncated: false,
totalMatches: 0,
totalFiles: 0
}
]
```
没有发出 `type: "error"` 事件。为便于阅读,以上事件省略了每次随机生成的 `searchId`。
## 复现步骤
1. 在 Cindy 仓库根目录创建临时工作目录和测试文件:
```bash
rg="$(pwd)/apps/ripgrep-bin/darwin-arm64/rg"
tmp_root="$(mktemp -d)"
mkdir "$tmp_root/workdir"
printf '%s\n' needle > "$tmp_root/workdir/sample.txt"
```
2. 创建一个包含无效 ripgrep 参数的配置文件,并通过环境变量启用:
```bash
printf '%s\n' --definitely-not-a-ripgrep-flag > "$tmp_root/ripgreprc"
export RIPGREP_CONFIG_PATH="$tmp_root/ripgreprc"
```
3. 直接执行内置 ripgrep:
```bash
(cd "$tmp_root/workdir" && "$rg" --files --hidden --no-messages -- .)
echo $?
```
4. 确认 ripgrep 输出参数错误,退出码为 `2`。
5. 在相同环境下调用 `@cindy/file-browser-core` 的文件列表接口:
```ts
await listAllFiles({ workdir, rgPath });
```
6. 使用相同的 ripgrep 二进制创建 `RipgrepSearcher`,监听其 `event` 事件并启动内容搜索:
```ts
searcher.start({
workdir,
query: 'needle',
caseSensitive: true,
maxMatches: 200,
});
```
7. 观察结果:
- `listAllFiles` resolve 空列表,没有 reject;
- `RipgrepSearcher` 发出空的 `end`,没有发出 `error`。
## 复现频率
使用包含无效参数的 `RIPGREP_CONFIG_PATH` 时可以稳定复现。
同一行为还使用 ripgrep 15.2.0 连续复现了三次。内容搜索也可通过真实的不可读目录条件触发退出码 2,并得到相同的正常空 `end`。
不可读目录场景只确认了 `RipgrepSearcher` 路径;由于 `listAllFiles` 使用了 `--no-messages`,该场景下的 `rg --files` 在当前环境返回退出码 0,因此不将其作为 `listAllFiles` 的复现条件。
## 诊断摘要
问题发生在 ripgrep 已成功启动、随后通过 `close` 事件报告致命退出的路径。
Node.js 子进程的 `error` 事件主要覆盖进程无法启动等错误。ripgrep 成功启动后再以退出码 2 结束,不会进入现有的 `error` 处理器,只会进入 `close` 处理器。
### `listAllFiles`
[`listAllFiles.ts`](https://github.com/makecindy/cindy/blob/c104ed851cc1d70ce9d99213fb44a029ee079c71/packages/file-browser-core/src/listAllFiles.ts#L129-L139) 的 `close` 处理器已经判断出非截断状态下的非零退出属于异常,但当前只记录警告:
```ts
if (!truncated && code !== 0 && code !== null) {
log.warn('rg exited non-zero', { code, signal, elapsedMs, files: files.length });
}
```
随后仍无条件执行:
```ts
resolve({ files, truncated, elapsedMs });
```
因此致命退出会被转换成成功结果。
### `RipgrepSearcher`
[`RipgrepSearcher.ts`](https://github.com/makecindy/cindy/blob/c104ed851cc1d70ce9d99213fb44a029ee079c71/packages/file-browser-core/src/search/RipgrepSearcher.ts#L230-L236) 已在注释中明确区分:
- 退出码 0:有匹配;
- 退出码 1:无匹配;
- 退出码 2:致命错误。
但当前对退出码 2 只记录警告,随后仍调用:
```ts
this.finalize(searchId, false);
```
`finalize` 会发出正常的 `type: "end"`,导致致命错误丢失。
主动取消和结果上限不会与本问题冲突:这些路径会在子进程关闭前设置 `ended` 或 `truncated`,可以与未经请求的自然致命退出区分。
## 建议修改方案
1. 修改 `listAllFiles` 的 `close` 处理:
- 命中结果上限并由 Cindy 主动终止时,继续正常返回 `truncated: true`;
- ripgrep 自然以退出码 0 结束时,正常返回结果;
- 其他自然非零退出应构造包含退出码、signal 和有限长度 stderr 摘要的错误并 reject;
- reject 后立即结束处理,不能继续执行成功的 resolve。
2. 修改 `RipgrepSearcher` 的 `close` 处理:
- 退出码 0 和 1 继续进入正常 `end`;
- 退出码 2 或其他非预期自然非零退出应发出 `type: "error"`;
- 错误路径只负责清理当前搜索状态,不能再发出代表成功完成的普通 `end`;
- 保留现有主动取消和达到结果上限的行为。
3. 为两个接口补充子进程退出语义测试,至少覆盖:
- 正常成功;
- 内容搜索退出码 1;
- 致命退出码 2;
- 用户主动取消;
- 达到结果上限后主动终止;
- `spawn` 本身失败。
4. 如果错误消息包含 stderr,应限制长度并避免把未受控的大段子进程输出传给 renderer 或日志。
本问题的核心是保留 ripgrep 的致命退出语义,不要求必须通过添加 `--no-config` 规避配置文件。即使增加该参数,其他真实文件系统错误仍可能使 ripgrep 以退出码 2 结束。
## 关联
- `listAllFiles` 异常退出处理:
https://github.com/makecindy/cindy/blob/c104ed851cc1d70ce9d99213fb44a029ee079c71/packages/file-browser-core/src/listAllFiles.ts#L129-L139
- `RipgrepSearcher` 异常退出处理:
https://github.com/makecindy/cindy/blob/c104ed851cc1d70ce9d99213fb44a029ee079c71/packages/file-browser-core/src/search/RipgrepSearcher.ts#L230-L236
- 相关但不重复的 PR:
https://github.com/makecindy/cindy/pull/423
- ripgrep 退出码说明:
https://github.com/BurntSushi/ripgrep/discussions/2976
- ripgrep 更新日志:
https://github.com/BurntSushi/ripgrep/blob/master/CHANGELOG.md#1100-2019-04-15
Contributor guide
Research direction
Start with packages/file-browser-core/src/listAllFiles.ts and packages/file-browser-core/src/search/RipgrepSearcher.ts, focusing on their close handlers and existing process-state handling. Add coverage for successful exits, search exit code 1, fatal exit code 2, cancellation, truncation, and spawn failure; done means fatal natural exits reach the error path without also producing successful results or end events.
Written by the indexing model from the issue text.
Assessment
- Tech stack
- node.js, typescript
- Domain
- backend, testing, tooling
- Issue type
- Bug
- Difficulty
- 4/5
- Estimated time
- 3-5 days
- Activity status
- Active
- Clarity
- Clearly specified
- Newbie friendliness
- 72/100