老刘
发布于 2026-10-22 / 4 阅读

一个换行符——CRLF 跨块血案与修复

一个换行符——CRLF 跨块血案与修复

旦阅诞生记 · 08。上一篇讲了块边界上的六个陷阱,都是"防患于未然"的写法纪律。这一篇讲一个真实发生过的、已经流到 v1.0.1 用户手里的 bug——它大概是整个项目里最有戏剧性的一案:两个字节被一个缓冲区边界劈开,一本书就在另一台设备上"消失"了。

案发现场

用户报告的现象非常安静,没有任何报错:

  • 桌面端导入了一本 42 MB 的 txt,读到了 93%,进度同步显示正常;
  • 换到手机上打开同一个服务端账号,书架里根本没有这本书;
  • 反过来在手机上导入同一份文件,桌面端也看不到它。

像是同一本书在两个端各自领了一张身份证。去服务端查同步数据,bookKey 确实不一样:

桌面端导入: md5:4efde823d94c94a356e5ff499405ff37
手机端导入: md5:2dccd645e1af7a380a590720766aa107

同一份文件,两个哈希。BookKey 的设计我们前两篇刚讲过:归一化四步 + 流式 MD5,三端拿同一份内容必须算出同一个值。现在这个承诺被打破了,而且没有异常、没有日志,只有"书不见了"。

排查:从两端各自算起

既然 BookKey 不同,先回答一个问题:差异发生在哪个环节?

归一化的输入是"整本书的内容",输出是"归一化后的字符流",MD5 只是对后者做摘要。如果两端拿到的源文件字节一致,那差异只可能来自:

  1. 解码不同(编码探测出错);
  2. 归一化规则不同(某端多吞或多吐了字符);
  3. 归一化输入不同(落盘的正文本身就不一样)。

编码探测有 strict UTF-8 校验和黄金值测试,排除 1;归一化参考实现三端逐字符一致,还有 1 字节分块的等价性测试,排除 2。剩下 3——桌面端导入时转存落盘的那份正文,可能已经和源文件不一样了。

这一步用了一个很朴素的验证:在桌面端把导入后的落盘正文读出来,本地跑一遍 BookKeyUtil.Compute,得到的正是那个"错"的哈希。也就是说,BookKey 没算错,它忠实地摘要了一份被污染的正文。问题在导入,不在指纹。

凶器:char[1<<16] 的块边界

桌面端 BookImporter 转存正文时,为了不把几十 MB 的书整个读进内存,用了经典的分块读写:

var buf = new char[1 << 16];                    // 65536 个字符一块
int n;
while ((n = reader.Read(buf, 0, buf.Length)) > 0)
{
    // 旧版实现:在每一块内部做行尾归一化
    var text = new string(buf, 0, n);
    writer.Write(text.Replace("\r\n", "\n").Replace('\r', '\n'));
}

单看每一块,Replace("\r\n", "\n") 都是对的。但归一化是上下文相关的操作:\r 的含义取决于它后面跟着什么。而分块读取对上下文只保证一件事——不保证。StreamReader.Read 和 Stream.Read 一样,只承诺"读到了 1 到 n 个字符",切在哪里全看运行时心情。

于是存在一种切法:前一块恰好以 \r 收尾,后一块恰好以 \n 开头。这时两个块各自独立做替换:

  • 前一块尾部:孤立的 \r → 被替换成一个 \n;
  • 后一块开头:孤立的 \n → 原样保留,又是一个 \n。

原本的 CRLF(一个换行)变成了 LF LF(两个换行)。正文凭空多出字节,而且这个替换发生在落盘之前——从这一刻起,桌面端书库里的那份正文就永久地和源文件分道扬镳了。

这个 bug 最阴险的地方在于它的命中率结构。CRLF 落在块边界的概率不高——每 65536 个字符才有一次机会,而且还要恰好切在 \r 和 \n 之间。但一本 42 MB、约 1600 万字符的长篇小说,行数以十万计,只要有一行横跨了边界,整本书的哈希就变了。实测这份 42 MB 的文件在默认缓冲下正好命中 14 次,归一化字符数从 16 210 802 变成 16 210 816——多了整整 14 个 \n,一个块边界一个。

为什么手机端没事?安卓端的导入走的是另一条路(readLine 逐行读,天然吃掉完整的 CRLF),不经过这个 char 分块替换。所以只有桌面端导入的书会中招——这也解释了为什么"桌面导入的书在手机上看不到,手机导入的书在桌面上看得到"是不完全对称的:手机导入的书 BookKey 是对的,桌面端如果用旧版本导入同一份文件,两边一样对不上。谁先导入,谁的 BookKey 就先注册进服务端。

修复:块尾的 CR 不许当场表态

修复的思路和 BookKeyUtil 流式哈希里那个 pendingCr 状态机一模一样——事实上就是同一个状态机的第二次落地:

private sealed class NewlineNormalizer(TextWriter writer)
{
    /// <summary>上一个块以 CR 收尾、其归属尚未确定。</summary>
    private bool _pendingCr;

    public long Write(ReadOnlySpan<char> span)
    {
        long chars = 0;
        var i = 0;

        if (_pendingCr)
        {
            _pendingCr = false;
            writer.Write('\n');                              // 上块尾的 CR 落定为 LF
            chars++;
            if (span.Length > 0 && span[0] == '\n') i = 1;   // CRLF:吞掉这一块的 LF
        }

        while (i < span.Length)
        {
            var ch = span[i];
            if (ch == '\r')
            {
                if (i + 1 < span.Length)
                {
                    writer.Write('\n');                      // 块内就能看清:CRLF
                    i++;
                    if (span[i] == '\n') i++;
                    continue;
                }
                _pendingCr = true;                           // CR 落在块尾:挂起,等下一块
                i++;
                continue;
            }
            if (ch == '\n') { writer.Write('\n'); chars++; i++; continue; }

            // 批量写出连续的非换行字符,避免逐字符调用
            var j = i;
            while (j < span.Length && span[j] != '\r' && span[j] != '\n') j++;
            writer.Write(span[i..j]);
            chars += j - i;
            i = j;
        }
        return chars;
    }

    /// <summary>流结束:若最后留着一个孤立的 CR,补成一个换行。</summary>
    public long Finish()
    {
        if (!_pendingCr) return 0;
        _pendingCr = false;
        writer.Write('\n');
        return 1;
    }
}

三个要点:

一、状态活在块循环之外。_pendingCr 是写入器的字段,不是循环里的局部变量。块与块之间唯一合法携带的信息就是这一个 bit——"我手里捏着一个还没归属的 CR"。这和第 5 篇讲的流式哈希 pendingCr 是同一条纪律:上下文相关判断所需的状态,必须显式地活在分块循环的作用域之外。

**二、挂起的 CR 在下一块开头统一结算。**下一块来了先看第一个字符:是 \n,就是 CRLF,吞掉它,CR 已在挂起时或结算时写成 \n;不是,CR 就是孤立 CR,照样归一成 \n。两种情况对 CR 的输出都是 \n,区别只在要不要额外吞一个 LF。

**三、批量写非换行段。**归一化器逐字符判断,但输出走的是 span[i..j] 的区间写——真正逐字符 Write 的只有换行符。这个类在导入 42 MB 书时要跑 1600 万次判断,性能上不能给自己埋雷。

调用点上,导入转存和历史文件就地规范化两处统一换成:

var newlines = new NewlineNormalizer(writer);
while ((n = reader.Read(buf, 0, buf.Length)) > 0)
{
    var start = 0;
    if (first) { first = false; if (buf[0] == '\uFEFF') start = 1; }   // BOM 只在第一块判
    charCount += newlines.Write(buf.AsSpan(start, n - start));
}
charCount += newlines.Finish();

顺带一提 Finish() 的存在理由:如果文件的最后一个字符就是 \r,它会被一直挂起到流结束。没有 Finish,这个 CR 就静默丢失,又是一次"少一个字符"的不一致。挂起状态必须有终点结算,这是挂起式设计的完整闭环。

用测试把边界钉死

修完之后,问题变成了"怎么证明它以后不会再坏"。针对这类 bug 写随机测试是没用的——CRLF 恰好落在块边界是低概率事件,随机数据跑一万次可能一次都碰不到。所以要精确构造:

private static void TestImportCrlfAcrossChunkBoundary()
{
    const int bufChars = 1 << 16;   // CopyAsUtf8 的转存缓冲 = char[1<<16]
    // 让 \r 正好落在第 1、2、4 个块的最后一个字符上
    foreach (var crIndex in new[] { bufChars - 1, 2 * bufChars - 1, 4 * bufChars - 1 })
    {
        var text = new string('字', crIndex) + "\r\n" + "第二章 边界测试\r\n正文内容 abc\r\n";
        var expected = BookKeyUtil.Compute(text);        // 整串参照实现(= 服务端口径)
        var wantChars = BookKeyUtil.Normalize(text).Length;
        // ……写临时文件 → BookImporter.ImportFile → 断言 BookKey 与字符数
    }
}

测试的骨架是双实现对照:导入器(分块 + NewlineNormalizer)算出的 BookKey,必须等于整串参照实现(也就是服务端口径)对同一份文本直接算出的 BookKey。\r 被精确摆在第 1、2、4 个块的最后一个字符上——三个位置覆盖了"首次挂起""挂起后立刻再挂起""多次正常结算后再挂起"三种状态机路径。字符数也一并断言,防止"哈希碰巧对、字符悄悄错"。

这个测试进了 EggBook.Verify 自检工具,和 BookKey 等价性、42 MB 三方交叉那些测试住在一起。

两个收尾决定

老数据怎么办?已经用旧版本导入的书,落盘正文里那些多出来的 \n 已经写进了数据库文件——归一化是单向的,信息丢了就还原不回来。就地修复做不到,诚实的做法是在发行说明里写明白: affected 的书需要删除后重新导入源文件。发版说明里那条 ⚠️ 就是这么来的。升级工具能修 bug,修不了已经丢的数据,这个区别要对用户说清楚。

为什么 v1.0.1 才暴露?这个 bug 从第一个版本就存在,只是"同一本书在两端导入"这个场景在单机时代不存在。同步功能上线后,"同一份 txt"第一次变成了一个跨设备的一致性命题,块边界 bug 才从无害的瑕疵变成了一案血案。很多老 bug 的暴露时刻,不是代码变了,而是它所处的契约环境变了——新功能会替历史代码突然提高标准。

一案一段

把这一案压缩成三句话:

  1. 上下文相关的替换,不能按块独立执行。\r 的语义取决于下一个字符,而分块切断了"下一个"。要么把状态挂起到边界之外,要么干脆逐行处理。
  2. **低概率跨界 bug 要用精确构造去测,不要用随机。**把 \r 摆在第 65536 个字符上,比随机跑一万次更有说服力。
  3. **落盘前的污染是最贵的一类污染。**它不报错、有幂等假象、且随时间永久化。所有"转存/规范化再写回"的路径,都应该有和参照实现的对照测试看着。

下一篇,我们讲同步协议的第一块基石:客户端怎么向服务端证明"我就是我,这本书就是这本书"——拉取、推送与并发窗口的握手设计。