用免费MonkeyCode模型生成36行fuzz harness,在免费CI服务器上迭代到6417次时触发空buffer pop的off-by-one——14个精心设计的单元测试全部通过但未覆盖此路径。
先上结论:一个 36 行的模糊测试用例(fuzz harness),由一个免费模型编写,运行在一台免费服务器上,发现了一个 14 个人工编写的单元测试都没有发现的差一错误(off-by-one)。运行成本 0 美元。失败出现在第 6417 次迭代。
这就是这次运行的完整故事。同时,这也是一个关于为什么这个 harness 比我在开始 prompt 之前写的那份不变量(invariant)列表更不重要的故事。
我维护一个小的 C++ 有界队列。它是一个环状缓冲区(ring buffer),供遥测聚合器使用:固定容量、启动后不分配内存、一个写线程和一个读线程。实现代码 200 行。单元测试是 14 个用例,覆盖 push、pop、环绕(wrap-around)和缓冲区满的行为。
14 个测试全部通过。但缓冲区仍然存在 bug。
Bug 藏在 pop 路径里。当缓冲区为空时,head_ 仍然会被递减。head_ 是 std::size_t 类型。递减后回绕到一个巨大的数字。下一次 push 就写到了错误的槽位,污染了整整一批数据。
没有任何一个单元测试在空缓冲区上调用 pop。这就是这个 bug 的全部故事。
我不想花钱,想要一个第二意见。两个约束条件:
计划很简单:让一个免费模型写一个模糊测试 harness,在免费服务器上运行,看看哪里会出问题。我并不期待这个 harness 是正确的。我期待它成为一个起点。
工作分四步完成。
在打开编辑器之前,我把缓冲区必须始终满足的条件写了下来:
最后一行正是单元测试漏掉的那一条。也是抓住这个 bug 的那一条。
我给免费模型提供了头文件和不变量列表。prompt 要求的是模糊测试 harness,而不是单元测试:
Write a C++ fuzz harness for ring_buffer.h.
Loop 10,000 times. Each iteration: choose a random op
(push, pop, or observe). After every op, check:
- size() <= capacity
- head() < capacity
- tail() < capacity
On violation, print the iteration number and exit non-zero.
模型返回了 36 行代码。它是一个合理的骨架。但也有一个缺陷。
harness 在每个 pop 之前都加了 if (size() > 0) 的保护。这很礼貌。但对这项工作来说,这也是错的。被测代码必须自己强制执行其前置条件。一个遵守前置条件的 harness 会掩盖前置条件 bug。
我移除了保护。在模糊序列中,对空缓冲区执行 pop 变成了合法操作。不变量检查器现在可以捕获下溢了。
以下是实际运行的 harness:
#include "ring_buffer.h"
#include <cstdint>
#include <cstdio>
#include <cstdlib>
static void check(bool cond, const char* msg, uint64_t iter) {
if (!cond) {
std::printf("FAIL at iteration %llu: %s\n",
(unsigned long long)iter, msg);
std::exit(1);
}
}
int main() {
ring_buffer buf(64);
uint64_t rng = 0x12345678;
for (uint64_t i = 0; i < 10'000; ++i) {
rng = rng * 6364136223846793005ULL + 1442695040888963407ULL;
int op = (int)(rng >> 33) & 3;
if (op == 0) {
buf.push((int)(rng >> 10));
} else if (op == 1) {
int value = 0;
buf.pop(&value); // no guard: pop on empty is allowed
} else {
// op == 2: just observe
}
check(buf.size() <= 64, "size exceeds capacity", i);
check(buf.head() < 64, "head out of range", i);
check(buf.tail() < 64, "tail out of range", i);
}
std::printf("PASS: 10000 iterations\n");
return 0;
}
head() 和 tail() 访问器是仅调试用的。它们的存在正是为了让不变量检查器能够窥探缓冲区内部。
免费服务器运行了一个小任务:用 sanitizers 编译并运行 harness。任务脚本平淡无奇:
g++ -std=c++20 -fsanitize=address,undefined -g -O1 \
fuzz_harness.cpp ring_buffer.cpp -o fuzz_harness
./fuzz_harness
第一次运行在第 6417 次迭代时失败。
FAIL at iteration 6417: head out of range
head_ = 18446744073709551615
这是 std::size_t 下溢。对空缓冲区执行 pop 使 head_ 递减到零以下。下一次 push 写到了 64 槽缓冲区的第 63 个槽位,而缓冲区认为自己为空。整批数据被污染了。
数据如下,仅供参考:
| 单元测试数量 | 14 |
| 模糊测试发现的问题 | 1(off-by-one) |
| 运行成本 | $0 |
有趣的是中间那一行。模型原始的 harness 没有错。它太小心了。它按照缓冲区期望的方式测试了缓冲区,而不是按照契约允许的方式使用它。
这是一次有界模糊运行,不是真正的 fuzzer。固定 seed 的 10000 次迭代是一个带锋利边缘的冒烟测试。它不是 libFuzzer。没有覆盖率指导,没有语料库,没有变异引擎。
我也在运行前审查了模型生成的 harness 的每一行。那次审查花了五分钟。如果你不能审查这个 harness,那这次运行给你的就是虚假信心,而不是证据。
Oracle 比生成器重要。模型的 harness 没问题。不变量列表才是让这次运行有意义的部分。
免费基础设施转移了瓶颈。成本从 token 和 CI 分钟转移到了我的审查时间上。这是一笔好交易。
礼貌的 harness 会掩盖契约 bug。遵守前置条件的 harness 永远不会发现前置条件违反。
最便宜的运行是失败的那一次。绿色通过的 10000 次迭代比红色失败在第 6417 次迭代的那一次告诉你的更少。
缓冲区现在会在 pop-when-empty 时 assert。单元测试套件有了第 15 个用例。模糊测试 harness 保存在代码库中,每次 push 时都会运行。
如果你有一个带有锋利边缘的小型 C++ 模块,最便宜的第二意见就是一个模糊测试 harness 和一台免费服务器。MonkeyCode 的免费模型访问和免费服务器选项是获得这种能力的一种方式。不变量列表是你必须自己写的部分。