Skip to content

[bufiox] DefaultReader.ReadBinary 部分读返回 io.EOF 而非契约声明的 io.ErrUnexpectedEOF —— 同一逻辑情形因缓冲落点不同给出两种错误,截断帧会被下游当成正常流尾 #406

Closed

Description

@zy84338719

来源: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)

四、对照组

  1. 兄弟实现对照——同包 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 缺这一档
    }
  2. 同函数内形态对照——被测 B(缓冲为空、底层一次读出 3 字节后 EOF)与被测 A/C 是同一个逻辑情形,却分别给出 io.ErrUnexpectedEOF 与 io.EOF;
  3. 正常路径对照——读满返回 (10, nil)、空流返回 (0, io.EOF),两条契约都兑现,只有「部分读」这一档破约;
  4. 仓内既有测试并未把 io.EOF 钉死——bufiox/robustness_test.go:43-45 的断言是:
    if !errors.Is(err, io.ErrUnexpectedEOF) && !errors.Is(err, io.EOF) {
        t.Fatalf("期望 EOF 类错误,实得 %v", err)
    }
    两种错误都放行 ⇒ 当前行为不是被有意固化的口径,而是当时用例写松了(用例标题却叫「直读路径部分读(遇 EOF)」)⇒ 不存在「实现与声明一致 ⇒ 设计取舍」的豁免。

影响

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)(与契约「无数据可读」一致)。

建议同时:

  1. 把 bufiox/defaultbuf.go:278-279 那句「与 acquire 路径及接口约定一致」的注释改成如实描述(当前它把与接口相反的行为说成一致);
  2. 把 bufiox/robustness_test.go:43-45 的宽松断言收紧为 errors.Is(err, io.ErrUnexpectedEOF)(该用例标题本就是「部分读」);
  3. 补一条覆盖「剩余字节已在缓冲内」形态的回归——当前这条路径与直读路径、acquire 路径给出三种不同结果。

版本

main 4e32ee8。

Activity

  1. moved this from Todo to In Progress in oekit Issue 大盘on Nov 12, 2026
  2. added a commit that references this issue on Nov 12, 2026
  3. moved this from In Progress to Done in oekit Issue 大盘on Nov 12, 2026
  4. zy84338719 commented on Nov 12, 2026

    @zy84338719
    ContributorAuthor

    已合入修复 ✅(无 SemVer 发布:本次改动不发版本)

    变更内容

    DefaultReader.ReadBinary 部分读经 partialReadErr 归一为 io.ErrUnexpectedEOF,与接口契约及 BytesReader 对齐

    合入:PR #421(https://github.com/oekit/pkg/pull/421),merge commit 63917515,2026-11-12T17:10:03Z

    CI:link ✅ / Lint & Test / Test ✅ / Lint & Test / Lint ✅ / Zero Third-Party Deps ✅

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions