Bug 4: Goroutine Leak on Partial GGUF Header Parse Failure in Lazy Reader

当 Ollama 加载一个被截断或格式不完整的 GGUF 模型文件(blob)时, fs/gguf/gguf.go 的 Open() 会在解析 keyValues 前失败并提前返回,已由 newLazy 启动的 iter.Pull goroutine 不会被释放,同时底层文件句柄也未关闭。若反复对同

快速结论:当 Ollama 加载一个被截断或格式不完整的 GGUF 模型文件(blob)时,fs/gguf/gguf.goOpen() 会在解析 keyValues 前失败并提前返回,已由 newLazy 启动的 iter.Pull goroutine 不会被释放,同时底层文件句柄也未关闭。若反复对同一个坏模型发起 POST /api/show,每次请求都会泄漏 goroutine,长期运行会导致内存与句柄持续增长。优先排查模型 blob 是否被截断,并检查 Ollama 版本是否已包含 Open() 的清理修复。

适用环境:Issue 中确认涉及 Ollama 服务端(ollama serve)、POST /api/show 接口、GGUF 解析模块 fs/gguf/lazy.gofs/gguf/gguf.go。复现环境为 Linux(使用了 stat -c%ssha256sum、bash),模型以 blob + manifest 形式手动放置。Issue 未提供具体的 Ollama 版本号、Go 版本、CUDA、显卡或 Python 信息,这些项目无需在此补充。

最快修复方案:暂无确认的一步修复方案(没有可直接升级的已知修复版本号)。Issue 中经复现确认的修复是在 Open() 中,当第二次 newLazy(f, f.readKeyValue) 返回错误时,先调用 f.tensors.stop() 释放 iter.Pull 协程,再调用 f.file.Close() 关闭文件描述符,然后再 return nil, err。该改法由报告者在独立 Go 程序中验证,goroutine 计数差值匹配“每次失败的 Open() 泄漏 1 个 goroutine”。

注意事项:上述修复为 Issue 讨论中提出的补丁思路,评论表示愿意提 PR,但在该 Issue 讨论链内未确认已合并发布,普通用户无法仅靠配置规避。修复只针对 keyValues 解析失败这一条路径;评论还指出 os.Open 与第一次 newLazy 之间的早期 return 也会泄漏文件描述符(这些路径不泄漏 goroutine,但同样未调用 f.file.Close()),属于同一类清理缺失问题。临时规避方向是不要让截断/损坏的 GGUF blob 进入模型库并反复请求 /api/show,但该规避未在 Issue 中作为验证方案给出。

问题场景

用户在运行 ollama serve,本地模型库中某个模型的 blob 是一个被截断的 GGUF 文件(例如文件刚好在 tensor count 之后结束,缺少后续的 KV count 字节)。当客户端反复调用 POST /api/show 查询该模型信息,或服务器内部通过 server/images.goGetModel() 打开该模型 blob 时,触发问题。GetModel() 每次请求会至少打开同一 blob 两次(能力探测约第 148 行、直接打开约第 687 行),因此一次请求可能泄漏一个以上 goroutine。Issue 中的复现脚本通过手写 manifest 与 blob、启动服务后循环 30 次请求 /api/show 来触发,服务日志中会反复出现 couldn't open model file error=EOF

直接现象体现在 HTTP 层是请求返回 500 与 unexpected EOF;更深层的是后台 goroutine 与文件描述符泄漏。Issue 正文说明,纯 HTTP 复现只证明该路径可达,并不直接测量 goroutine 数;要直接测量泄漏,需要在独立 Go module 中用 replace 指向仓库路径,循环调用 gguf.Open("truncated.gguf"),对比显式 runtime.GC() 前后 runtime.NumGoroutine() 的变化。

报错原文

couldn't open model file error=EOF

=== [6/6] Firing repeated POST /api/show against the malicious model ===
request 1: HTTP_CODE=500 BODY={"error":"unexpected EOF"}

核心报错标识:Bug 4: Goroutine Leak on Partial GGUF Header Parse Failure in Lazy Reader。

原因分析

根本原因是 fs/gguf/gguf.goOpen() 的错误路径缺少清理逻辑,评论者已确认该泄漏并定位到修复位置。流程如下:

  • fs/gguf/lazy.gonewLazy[T](约第 20–51 行)先读取 8 字节 item count,再调用 iter.Pull(...)(约第 30 行),这会启动一个后台 goroutine。只有返回的 stop() 闭包能释放它。
  • Open() 先用 newLazy(f, f.readTensor)(约第 76 行)构建 f.tensors,成功并启动一个 goroutine。
  • 随后 Open()newLazy(f, f.readKeyValue)(约第 90 行)构建 f.keyValues。如果文件在 tensor count 之后正好结束、没有字节留给 KV count,这次 binary.Read 会失败,newLazy 返回 (nil, err)
  • Open() 在该分支直接 return nil, err(约第 92 行),没有调用 f.tensors.stop(),也没有关闭 f.file。唯一会同时调用这两者的地方是 File.Close()(约第 310 行),但调用方拿不到 *File,因此永远不会走到。
  • 结果:为 f.tensors 启动的 goroutine 永久滞留,处于不可达、不会被回收的状态。

评论者用独立 Go 程序复现,确认每次失败的 Open() 调用泄漏的 runtime.NumGoroutine() 差值恰好为 1。另有一处次要泄漏:os.Open 与第一次 newLazy 之间的早期返回路径(约第 60–74 行)同样没有关闭 f.file,这些路径不泄漏 goroutine,但会泄漏文件描述符,属于同一类清理缺失问题。

环境排查

  • 确认 Ollama 版本是否已包含 Open() 错误路径的清理修复;如无法确认,请对照当前版本源码中的 fs/gguf/gguf.go 检查第二次 newLazy 失败分支是否调用 f.tensors.stop()f.file.Close()
  • 确认模型 blob 是否为完整 GGUF 文件:Issue 复现所用的文件为 16 字节截断样本(magic + version 3 + tensor_count 0,随后 EOF,缺少 kv_count)。可用文件大小与 GGUF header 结构对照,或检查服务日志是否持续出现 couldn't open model file error=EOF
  • 确认模型 manifest 与 blob 校验和是否匹配,避免手工构造 manifest 时出现 digest/size 不一致导致的异常路径。
  • 确认测试是否通过真实的 POST /api/show 触发 GetModel()gguf.Open() 路径;Issue 中的复现使用 OLLAMA_HOST=127.0.0.1:28096 与独立 OLLAMA_MODELS 目录,不代表生产配置。
  • 若要直接测量 goroutine 泄漏,需要在独立 Go module 中通过 replace github.com/ollama/ollama => <repo path> 引入仓库代码,循环调用 gguf.Open("truncated.gguf"),对比 runtime.NumGoroutine() 变化。Issue 未给出具体 Go 版本要求。

解决步骤

  1. 先确认问题可复现:在独立 Go module 中循环调用 gguf.Open("truncated.gguf"),每次调用失败后执行 runtime.GC() 并记录 runtime.NumGoroutine()。若每次失败的 Open() 使 goroutine 计数净增 1,即与 Issue 报告的现象一致。
  2. fs/gguf/gguf.goOpen() 中定位第二次 newLazy(f, f.readKeyValue) 及其后的 if err != nil { return nil, err } 分支(约第 90–92 行)。
  3. 在该分支返回前补上清理(Issue 中经验证的改法,可优先尝试):调用 f.tensors.stop() 释放由 iter.Pull 启动的协程,再调用 f.file.Close() 关闭文件描述符,然后才 return nil, err
  4. 处理次要泄漏:检查 os.Open 到第一次 newLazy 之间的早期返回路径(约第 60–74 行),在每条 return nil, err 前补上 f.file.Close()
  5. 如果当前不在源码层修改,临时缓解做法是移除或隔离已损坏/截断的 GGUF blob,避免持续对其发起 POST /api/show 或让 GetModel() 反复打开它;此规避方式未在 Issue 中作为验证方案,仅作应急。
  6. 若你在维护 Ollama 分支或等待官方修复,请关注该 Issue 后续是否合入携带上述清理逻辑的 PR。

验证方法

在独立 Go 程序中,对同一个截断 GGUF(例如 Issue 的 16 字节样本)重复调用 gguf.Open,每次失败后执行 runtime.GC() 并读取 runtime.NumGoroutine()。修复前,goroutine 计数会随失败次数线性增长(每次净增约 1);打上清理补丁后,计数应在 GC 后回落、不再随调用次数持续上升,同时文件描述符数量也应稳定不增长。另一个可观察的侧面指标是对坏模型循环请求 /api/show 后,服务端进程的 goroutine 数与打开文件数不再随请求数单调增加。Issue 中的 HTTP 复现已确认日志会持续出现 couldn't open model file error=EOF,但它本身不测量 goroutine 数,因此不能单独作为泄漏修复的验证。

参考来源

ollama/ollama #17180

GamsGo AI

AI 工具推荐

想把多个 AI 模型放在一个入口?

GamsGo AI 集成 ChatGPT、DeepSeek、Gemini、Claude、Midjourney、Veo 等常用模型,适合写作、绘图、视频和日常 AI 工作流。

了解 GamsGo AI

推广链接:通过此链接购买,我可能获得佣金,不影响你的价格。

这个方案解决了吗?

celebrityanime
celebrityanime
文章: 23581

发表回复

您的邮箱地址不会被公开。 必填项已用 * 标注