命令输出凭空消失:进程打开 1024 个文件后,Hermes 的终端开始“空手而归”


周五下午,agent 说它跑完了测试。你让它把输出贴出来,它回了两个空引号:{"output": "", "exit_code": 0, "error": null}。再让它 read_file 看一个 5KB 的配置文件,返回 file_size: 0。可你自己在终端里 cat 一下,文件好好的。更诡异的是,换 execute_code 跑同一段 Python,又能读到内容——好像只有某些通道瞎了。

这不是模型在敷衍你,也不是磁盘出问题。这是一个持续了将近两个月、直到 2026 年 10 月 21 日才关闭的 bug(issue #94928、PR #142299),根因只有一行:当进程持有的文件描述符(fd)超过 1024 个,等待输出的那行 select() 会抛异常,而异常被当成了“管道已关闭”。

症状:空输出 + 成功的退出码

问题报告里的现场很具体:Linux 上的 Hermes Desktop 运行一段时间后,terminal 对每条命令都返回空 stdout,退出码却是 0。ls、stat 都空,read_file 对一个 5092 字节的文件返回 file_size: 0,而 execute_code 里用 Python 读同一个路径完全正常。

为什么是“空输出”而不是报错?因为在读到数据之前,负责搬运输出的读线程就先认输了:它认为管道已经结束,于是把还没读的内容直接丢掉,命令本身照常执行完、照常返回 0。对调用方来说,这就是一次“成功但什么都没说”的调用。

同一个 bug 现场还记录了几个数字:Electron 主进程 1438 个 fd,hermes serve 1429 和 1410 个,一个 git-remote-https 子进程 1384 个。全都越过了那条线。

根因:select() 的 1024 上限

POSIX 的 select() 用固定长度的位图表示 fd 集合,这个长度的上限就是 FD_SETSIZE,通常编译成 1024。传进去一个大于等于 1024 的 fd,Python 会直接抛:

ValueError: filedescriptor out of range in select()

Hermes 里读输出的地方有三处——前台命令的输出排空(tools/environments/base_output.py 的 _drain_fd_select)、后台进程读取线程(tools/process_registry.py 的 _reader_loop)、以及给 Desktop 终端面板用的 PTY 桥(PtyBridge)。三处都在 select.select() 上等待可读,也都把这个 ValueError 当成“fd 已经关了”来收尾。于是同样的掉输出,会同时出现在前台命令、后台任务和桌面终端面板里。

复现它根本不需要 Hermes:开 1100 个 /dev/null,再建一个管道,然后 select.select() 一下那个新 fd,异常立刻就来。

修复:换掉 select(),但不要换 poll()

补丁新增了 tools/environments/fd_wait.py,里面是一个小的 FdWaiter,底层用 selectors.DefaultSelector——Linux 上是 epoll、macOS 上是 kqueue,两者都没有 FD_SETSIZE 上限。

class FdWaiter:
    """Readability wait on one fd. Owns a selector (epoll/kqueue are fds themselves):
    create it once per reader lifetime and close() it when the reader is done."""

    def __init__(self, fd: int, selector_cls):
        self._selector = selector_cls()
        try:
            self._selector.register(fd, selectors.EVENT_READ)
        except BaseException:
            self._selector.close()
            raise

    def wait(self, timeout_s: float) -> bool:
        return bool(self._selector.select(max(timeout_s, 0)))

有个反直觉的取舍:poll() 明明也没有 1024 上限,为什么不用它?因为 macOS 的 poll() 不支持设备 fd,会把 PTY master 报成 POLLNVAL——也就是“立刻就绪”。如果 PTY 读取循环建立在 poll() 上,它会在空闲时疯狂空转,白白烧掉一个核。所以实现里把 poll() 保留为“没有别的选择时”的兜底:它不像 epoll/kqueue 那样自己要占一个 fd,因此在 EMFILE(进程 fd 用尽)时仍能工作;而在 macOS 上遇到字符设备(PTY)时宁可抛错,也不接受一个会空转的等待。

补丁同时补了两个边界:selector 注册失败时不能谎报 EOF;每个读取器为自己的生命周期持有一个 waiter,用完关掉,避免修掉旧泄漏又引入新泄漏。

修好之前,怎么判断自己撞上了

症状对照表比日志更好用:命令确实执行了(有副作用),但 output 是空字符串、exit_code 是 0、error 是 null;read_file 对非空文件返回 0 字节;而 execute_code 读同一个文件正常。三条同时成立,基本可以确定。

数一下 fd 就够确认了。Linux:

for p in $(pgrep -f "hermes_cli.main serve"); do
  echo "$p: $(ls /proc/$p/fd | wc -l) fds"
done

macOS:

lsof -p $(pgrep -f "hermes_cli.main serve" | head -1) | wc -l

再看软限制 ulimit -n(macOS 常见 256 起步、Linux 常见 1024),只是参考——问题的线是 FD_SETSIZE,不是 ulimit。

老版本上的临时办法很朴素:重启 gateway(Desktop 也重启),把 fd 计数打回低位,输出立刻恢复。另外别把“空输出 + exit 0”当成“命令没输出”,看起来成功的空结果最容易被写进结论里。

长期运行的服务本来就会攒 fd,这跟你用不用容器、跑不跑 Docker 共享容器都没关系,只跟进程活了多久、开了多少连接有关。稳定性和资源这两块可以顺带看看孤儿进程回收和终端临时目录那两篇;gateway 本身的命令和排障入口在 hermes gateway 命令参考。

现在能用吗

补丁于 2026-10-21 合入 main,尚未进入正式发版(线上最新 v0.22.2 不含它),会在下一个补丁版随包发布。想马上验证的话,可以跑一个高 fd 的实测:让进程先占住 1100 个 fd,再执行 echo,对比有没有输出。

小结

记住三件事就够了:第一,空输出 + exit 0 是“丢输出”,不是“没输出”;第二,根因是 select() 的 1024 fd 上限,跟内存、磁盘、权限都无关;第三,修复用的是无上限的 selectors,并且刻意避开 macOS 上会空转的 poll()。看到这类“只有长时间运行的实例才犯的怪病”,先从 fd 数量开始查,通常比翻日志快得多。