Repository navigation
Description
来源:2026-11-12 全矩阵深度审查(oekit/pkg 仓)。本条判定由报告方独立复核:契约原文、实现行号、复现与对照组均为本人实测;行号基于 main
4e32ee8。
位置
bufiox/bufiox.go:22-25—Reader.ReadBinary的接口契约(声明层)bufiox/doc.go:8-9— 包级文档,同一口径bufiox/defaultbuf.go:266-301—DefaultReader.ReadBinary(两条「部分读」出口)bufiox/defaultbuf.go:278-279— 实现里的注释「返回原始终态错误(io.EOF),与 acquire 路径及接口约定一致」bufiox/defaultbuf.go:110-117—readAtLeast(部分读遇 EOF 转为io.ErrUnexpectedEOF)- 对照实现
bufiox/bytesbuf.go:52-64—BytesReader.ReadBinary - 仓内既有测试
bufiox/robustness_test.go:43-45(两种错误都放行)
问题
接口契约白纸黑字:「读满返回 (len(p), nil);部分读返回 (n, io.ErrUnexpectedEOF);无数据可读返回 (0, io.EOF)」。
但 DefaultReader.ReadBinary 的两条「部分读」出口都把底层终态错误原样上抛,而底层在「本次没读到字节就撞 EOF」时给的是 io.EOF ⇒ 被截断的帧被报成 io.EOF(而非契约要求的 io.ErrUnexpectedEOF)。
更糟的是:同一个逻辑情形(读到了部分字节、随后流到头)给出的错误取决于「剩余字节此前是否已在内部缓冲里」——落在缓冲里的走 acquire 路径且缓冲已空 ⇒ io.EOF;直读路径 ⇒ io.EOF;只有「本次 readAtLeast 自己读到了字节」的那一种形态才是 io.ErrUnexpectedEOF。
实现里的注释还把这处行为明确说成与接口约定一致——而接口约定写的恰恰相反。
证据
一、契约原文(bufiox/bufiox.go:22-25)
// ReadBinary 读取至多 len(p) 字节填满 p,返回读到的字节数。
// 拷贝读:p 在 Release 之后仍然有效。
// 语义:读满返回 (len(p), nil);部分读返回 (n, io.ErrUnexpectedEOF);
// 无数据可读返回 (0, io.EOF)。
ReadBinary(p []byte) (n int, err error)bufiox/doc.go:8-9(包级文档)同口径:「ReadBinary 按经典 io 语义:读满 (len(bs), nil)、部分读 (n, io.ErrUnexpectedEOF)、无数据 (0, io.EOF)」。
二、实现(bufiox/defaultbuf.go:266-301)
if need := len(bs) - n; need > 0 {
if need >= directlyReadThreshold {
// 直读路径要碰底层 ⇒ 终态错误直接返回,不再读(粘滞语义)。
// 返回原始终态错误(io.EOF),与 acquire 路径及接口约定一致。 // ← 与接口约定相反
if r.rdErr != nil {
r.rn += n
return n, r.rdErr // ← :280-286 直读路径
}
nn, err := readAtLeast(r.rd, bs[n:], need)
r.rdErr = err
n += nn
r.rn += n
return n, err
}
if err := r.acquire(need); err != nil {
m := copy(bs[n:], r.buf[r.ri:])
...
return n, err // ← :288-293 acquire 路径
}
...
}而 readAtLeast(bufiox/defaultbuf.go:110-117)的注释与实现都表明:只有本次调用自己读到了字节才会把 EOF 升级成 io.ErrUnexpectedEOF;本次读到 0 字节就保持 io.EOF。
三、复现(报告方自写探针,副本 /tmp/aud3/probe-pkg-new/audprobe/probe_promises_test.go)
cd /tmp/aud3/probe-pkg-new
go test ./audprobe/ -run 'TestProbe_ReadBinary_PartialEOF2' -v=== RUN TestProbe_ReadBinary_PartialEOF2
[对照组1 BytesReader ] 部分读 => n=3 err=unexpected EOF isUnexpectedEOF=true isEOF=false
[对照组2 DefaultReader] 读满 => n=10 err=<nil>
[对照组3 DefaultReader] 空流 => n=0 err=EOF isEOF=true
[被测A 缓冲内已有3字节 ] 部分读 => n=3 err=EOF isUnexpectedEOF=false isEOF=true <-- 契约要求 ErrUnexpectedEOF
[被测B 缓冲为空 ] 部分读 => n=3 err=unexpected EOF isUnexpectedEOF=true isEOF=false
[被测C 直读路径 ] 部分读 => n=10 err=EOF isUnexpectedEOF=false isEOF=true <-- 契约要求 ErrUnexpectedEOF
--- PASS: TestProbe_ReadBinary_PartialEOF2 (0.00s)
四、对照组
- 兄弟实现对照——同包
BytesReader.ReadBinary(bufiox/bytesbuf.go:52-64)对同一情形返回io.ErrUnexpectedEOF:switch { case n == len(p): return n, nil case n == 0: return 0, io.EOF default: return n, io.ErrUnexpectedEOF // ← DefaultReader 缺这一档 }
- 同函数内形态对照——被测 B(缓冲为空、底层一次读出 3 字节后 EOF)与被测 A/C 是同一个逻辑情形,却分别给出
io.ErrUnexpectedEOF与io.EOF; - 正常路径对照——读满返回
(10, nil)、空流返回(0, io.EOF),两条契约都兑现,只有「部分读」这一档破约; - 仓内既有测试并未把
io.EOF钉死——bufiox/robustness_test.go:43-45的断言是:两种错误都放行 ⇒ 当前行为不是被有意固化的口径,而是当时用例写松了(用例标题却叫「直读路径部分读(遇 EOF)」)⇒ 不存在「实现与声明一致 ⇒ 设计取舍」的豁免。if !errors.Is(err, io.ErrUnexpectedEOF) && !errors.Is(err, io.EOF) { t.Fatalf("期望 EOF 类错误,实得 %v", err) }
影响
ReadBinary 是协议帧解析的读入口。调用方按 errors.Is(err, io.ErrUnexpectedEOF) 判定「这是半包 / 帧被截断,应当报错或重试」,按 errors.Is(err, io.EOF) 判定「流干净结束」。当前实现把被截断的帧报成 io.EOF ⇒ 下游会把它当作正常流尾 ⇒ 半包被静默当成完整消息消费(长度字段说 100 字节、实际只有 60 字节时,读侧得到 60 字节 + io.EOF,上层若只看错误分类就会放行)。
错误分类与实际字节数脱钩,是零拷贝缓冲底座上最难排查的一类故障;而同一个包里 BytesReader 又是对的,调用方换实现即换语义。
建议修复
在 DefaultReader.ReadBinary 的两条出口上补齐「部分读 ⇒ io.ErrUnexpectedEOF」这一档:当 n > 0 且终态错误是 io.EOF 时,返回 (n, io.ErrUnexpectedEOF);n == 0 时仍返回 (0, io.EOF)(与契约「无数据可读」一致)。
建议同时:
- 把
bufiox/defaultbuf.go:278-279那句「与 acquire 路径及接口约定一致」的注释改成如实描述(当前它把与接口相反的行为说成一致); - 把
bufiox/robustness_test.go:43-45的宽松断言收紧为errors.Is(err, io.ErrUnexpectedEOF)(该用例标题本就是「部分读」); - 补一条覆盖「剩余字节已在缓冲内」形态的回归——当前这条路径与直读路径、acquire 路径给出三种不同结果。
版本
main 4e32ee8。
Metadata
Metadata
Assignees
Labels
Type
Projects
- StatusShow more project fieldsDone
Activity
Merge pull request #421 from oekit/sweep/20261112-l1
zy84338719 commentedon Nov 12, 2026
已合入修复 ✅(无 SemVer 发布:本次改动不发版本)
变更内容
DefaultReader.ReadBinary 部分读经 partialReadErr 归一为 io.ErrUnexpectedEOF,与接口契约及 BytesReader 对齐
合入:PR #421(https://github.com/oekit/pkg/pull/421),merge commit
63917515,2026-11-12T17:10:03ZCI:
link✅ /Lint & Test / Test✅ /Lint & Test / Lint✅ /Zero Third-Party Deps✅