makecindy / makecindy/cindy

bug: 文件浏览器将 ripgrep 致命退出误报为成功的空结果

Open
#3,052 3 comments 0 reactions 0 assignees View on GitHub
bug
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

Open the contributing 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

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.