研究:哪些缺陷只有联调能发现
十三个只有跨进程联调、双 hub 实网或白板容器才能发现的缺陷
摘要
一个由五个仓库、四类进程、两种线协组成的系统,它的单元测试可以全部通过而系统是坏的。 这不是覆盖率不够,是测试形状的问题:每个仓库为了能独立测试,都要伪造它的对端; 两个 fake 各自只实现自己那侧代码已经在做的事,于是两边同时变绿,而中间那条线上什么都没被检查。
本文列出 ANet 项目实际发生过的 13 个这类缺陷,逐条给出位置、行为、 为什么单元测试看不见、以及最终是被哪一层发现的。其中:
- 6 条靠跨进程联调发现(或在导出面清点时发现);
- 5 条只有双 hub 实网能发现 —— 单 hub 的联调在原理上造不出来,因为缺陷藏在两个账本之和里;
- 2 条只有白板容器(什么都没装的
debian:12)能发现,因为单测与联调都跑在已经装好东西的机器上。
结论有四条,都可以直接抄走:fake 必须实现被替代方的完整契约; 线协字段名要像 golden 向量一样钉死;新写的断言要用 mutation 验证; 联调脚本是仓库的一部分,不是脚手架。
这一节是回顾性的。它列的是已经被发现并修掉的缺陷,以及为每条补上的守卫。 它不能证明现在没有同类的缺陷 —— 恰恰相反,它给出的是一份"这类缺陷长什么样"的样本, 用途是让下一条更早被找到。
问题:两边各自伪造对方,可以同时全绿
ANet 拆成五个仓库:ANetCore(协议内核)、ANet(daemon)、ANetHub(中继与目录)、
ANetLink(设备运行时)、ANetMock(用真实线协模拟被控硬件)。
依赖方向单向:三个应用 → ANetCore,应用之间永不互相 import。
这个结构的好处是每个仓能独立编译、独立测试。代价是:每个仓都必须伪造它的对端。
ANet的 daemon 测试跑在一个进程内的 fake hub 上(internal/daemon/hubfake_test.go);ANetHub的测试用httptest起自己的 handler,对端 daemon 是测试代码里手写的请求;ANet的 ANetLink 适配层测试跑在一个 fakeanetlinkd上;ANetLink的北向测试跑在自己的 runtime 上,北向消费者是测试代码。
一个 fake 的默认行为,是同意被测代码正在做的任何事。写 fake 的人手边只有本仓的代码, 他实现的是"让本仓的用例跑通"所需的最小行为。于是:
daemon 写了一个字段而 fake hub 不读它 → daemon 侧绿; hub 读一个字段而 daemon 从不写它 → hub 侧绿; 两侧的 CI 都是绿的,线上失败。
这不是假设。下面每一条都发生过。
证据一:跨进程联调(与导出面清点)发现的六条
1 · 能力委派路径无人可达
| 项 | 说明 |
|---|---|
| 位置 | daemon 控制面 POST /delegate(ANet/internal/daemon/control_api.go)与 CLI anet delegate |
| 行为 | 控制面只接受 provider + goal 两个字段。anet delegate <aid> --capability <id> 把能力 id 放进了 goal 文本,而没有任何解析器看那里 |
| 影响 | 委派确实到达了对端,provider 本可以服务它,摄像头不动 |
| 发现方式 | 一次 joint 运行:真 hub + 真 ANetLink + 真 mock 硬件 |
单测为什么看不见:两个仓各自伪造对方,而能力往返的那个单测直接在进程内调用
DelegateCapability,根本没经过控制面。方法存在、能工作,产品里没有任何东西能调到它。
守卫现在是 TestCapabilityCallIsReachableThroughTheControlAPI,
它构造的 body 是 CLI 实际 POST 的那一个:
同一条路径上还有一个同源问题:splitFlags 原先只认 --capability VALUE,
而 CLI 在别处已经接受 --attach=PATH。于是 anet delegate <aid> --capability=cas.put
被解析成一个名叫 capability=cas.put、值为 true 的 flag,能力丢失,
命令报的错是"goal 为空"。用户用的是这个工具在上一个 flag 里教给他的写法。现在两种写法都收。
这一族问题后来被一次性堵住:ANet/cmd/anet/main.go 里的 knownFlags 表列出每个命令接受哪些 flag,
不在表里的直接报错并列出接受哪些。此前拼错或臆造的 flag 会被收下、丢掉,命令照跑 ——
anet find --capability text.digest 返回未过滤的整个目录、退出码 0,与一次真的匹配了所有人的查询无法区分。
(find 接受的是 --cap;anet find --capability x 现在报 unknown flag --capability (it accepts: --cap)。)
同一个 CLI 一直会拒绝未知的命令,两半此前对"拼错算不算错误"的看法不一致。
2 · 读能力跨线只写不读
| 项 | 说明 |
|---|---|
| 位置 | ANet/internal/daemon/capability.go 的 capabilityResult |
| 行为 | 交付物只带 status / verifiable / metrics。能力把读到的东西放在 Evidence.ObservedState,而那个字段不过线;唯一的另一条通道 Metrics 是 map[string]float64,装不下 CID、blob 或列表 |
| 影响 | 系统里每一个"读"跨线之后都是只写的。cas.put 告诉调用方存了多少字节,不告诉它存在哪儿;cas.get 因此根本无法被调用 |
| 发现方式 | 两个 daemon 加一个真实 store |
单测为什么看不见:请求方那侧的用例断言的是一盏灯 —— 灯的答案恰好塞得进一个 metric;
模块那侧的用例在进程内调 Invoke,Effect 就在手边,不需要过线。
两个断言都成立,而它们之间那条线从没被检查。
守卫是 TestAReadReturnsWhatItRead。同一次修改还把证据带上了交付物,
于是 provider 发出的 provenance 成了收据 ResultCID 覆盖的一部分 ——
声称 V3(独立确认)而实际取的是设备的一面之词,现在是一条签过名的声明,不再是私下的注记。
3 · quirk 标签在北向边界就停了
| 项 | 说明 |
|---|---|
| 位置 | ANetLink/northbound/c1serv/c1serv.go 的 evidenceWire,以及 ANet/provider/anetlink/anetlink.go 的解码端 |
| 行为 | 北向映射函数漏掉了 Quirk;线的另一端,daemon 的解码器整个忽略了 evidence 块 |
| 影响 | 修正过的读数以裸值到达 daemon。消费这个结果的对端 —— 唯一没办法自己核对的一方 —— 是唯一没被告知发生过修正的一方。同时,一次经独立读回确认的效果(V3)与一次"我发出去了"(V1)在下游无法区分 |
| 发现方式 | 跨进程 |
单测为什么看不见:两侧各自都自洽,而且 fake 那侧也不发 evidence。
ANet 侧的注释把这句话写得很直白 —— "Every test passed, because the fake did not send evidence either."
守卫有两道,方向不同:
TestEveryEvidenceFieldCrossesTheWire用反射逐字段比对effect.Evidence与evidenceWire。 往effect.Evidence加字段而忘了加到线上,这个测试会指名报出来。TestQuirkSurvivesBothEncodings走 JSON 与 CBOR 两条编码各一遍。 一个 CBOR key 和一个 JSON name 是两种丢字段的方式。
ANet 侧对应的是 TestProvenanceSurvivesTheShim,它断言穿过 C1 之后 effect.Evidence 逐字段相等。
4 · 证据链用无法表达自身 id 的编码落盘
| 项 | 说明 |
|---|---|
| 位置 | ANet/internal/daemon/ledger.go |
| 行为 | 链存成 JSON,而事件 id 是从记录的 CBOR 预映像推出来的。CBOR 区分字节串与文本,JSON 不区分 —— 一个带收据(二进制)的 payload 读回来变成 base64 文本,预映像变了,id 重算不出来 |
| 影响 | ledger 是"用前先验"的,于是 daemon 在首次接受结果之后的第一次重启拒绝加载自己的历史,报 tamper/fork? |
| 发现方式 | 重启一个已经跑过一次完整委派的节点 |
单测为什么看不见:每个其他测试都建一条新链。链只在"重启且有历史"这一种情况下被读回, 而那种情况在单测里不存在。这条缺陷在写下去之后一直潜伏,直到第一个真实节点重启。
守卫是 TestChainWithBinaryPayloadReloads,它写的正是当初炸掉的那个形状:
一条带签名收据的 anet.result.accepted。配套的 TestLegacyJSONChainStillLoads
覆盖反方向 —— 老 daemon 写的链只要能重算 id 就仍须加载,升级时拒绝启动会是比原缺陷更糟的失败。
5 · p2p 送不回一个回复
| 项 | 说明 |
|---|---|
| 位置 | ANet/module/p2p/transport.go 的会话读循环 |
| 行为 | 入站投递内联跑在读循环里。处理一条入站委派意味着要应答,应答意味着 Send,而 Send 等的那个回复只有这条循环能送。循环等 daemon,daemon 等循环 |
| 影响 | 死锁在 Send 超时的时候解开,传输报失败,每一次能力调用都回落到 hub。唯一为绕开 hub 而建的模块,一条委派都送不完 |
| 发现方式 | 两个 daemon 加一个真 peer 进程;症状是 peer 日志里一片 "receiving daemon did not accept" |
单测为什么看不见:套件里每个用例只驱动一个方向。而每一次能力调用都恰好是双向的: 收一条委派,回一个结果。
修法是把投递挪出循环(go t.deliverInbound(...))。同一处还有第二个缺陷:
这条线协原本没有请求 id,回复按先进先出匹配,理由是"peer 会按被问的顺序回答"。
那是 daemon 无法检查、而 peer 作者不会去想的约束 ——
第一个照着这条线协写的 peer 进程在一小时内就破了它,方式恰恰是"把投递挪出读循环以免自己死锁"。
在负载下的失败形态是:一条委派因为另一条成功了而被报告为已投递。这是最糟的一种,它看起来像成功。
现在 frame 带 ID,peer 必须原样回送。守卫:TestAnsweringAnInboundMessageDoesNotDeadlock,
加上一个断言"回复落在提出它的那个请求上"的用例。
6 · 收据从来没人验过
这一条不是联调发现的,是清点导出面时发现的,列在这里因为它属于同一类:一条编译得过、没人走过的接缝。
| 项 | 说明 |
|---|---|
| 位置 | ANetCore/payment 与 ANetCore/evidence 的 Receipt.Verify |
| 行为 | 在 daemon 里调用点为零,且不可能不为零 —— 回程根本不带 provider 的 KEL,请求方手里有一个签名对象和没有钥匙 |
| 影响 | 请求方把到达的字节原样存下,并在自己的证据链上写"结果已接受" |
| 发现方式 | 逐仓跑导出面扫描,数调用点 |
一个签名证明的东西比它看起来的少。它说"某个 provider 签了某张收据", 它不说这张收据属于这次交互、点名我们是请求方、或覆盖了实际到达的那些字节。
修法是回程带上 provider 的 KEL,并由 ANetCore/delegation 的 VerifyResult
把收据绑到四样东西上:这次交互、作为请求方的我们、我们实际委派的那个 provider、
以及眼前这些字节的哈希。验不过就丢掉结果、交互保持打开 ——
这是诚实的结果:我们没有拿到一份能背书的完成件,而另一个选项是把它记进自己的证据链。
同一处还落了一条状态区分:老 provider 不发 KEL 时返回 ErrUnverifiable,
工作照收,但链上记的是 UNVERIFIED 而不是 OK。"不知道"和"知道没问题"是两种状态,
而一次完成件正是合并这两者代价最大的地方。
守卫是 TestAnAcceptedResultWasActuallyVerified,它断言链上记着 receipt_verified == true ——
从链上读,而不是"没报错",因为旧路径也不报错。
TestAResultMustBeTheAnswerToTheRequestItClaims 则逐条列出:
一个 provider 握着一个完全有效的签名,还能说哪些谎。
证据二:只有双 hub 实网能发现的五条
下面五条,单 hub 的联调在原理上造不出来。它们不藏在某一个进程里, 藏在两个账本之和、两台主机之间、或者一条只有两个运营者才存在的清算路径上。
生产拓扑是两个 hub:emax(https://hub.agentnetwork.org.cn,nginx/TLS)与
fmax(按 IP 直连),三个 daemon 分属其下。scripts/prodtest.sh 每小时定时跑一次只读半程
(--no-write);会花 credit、写证据链、留评价的那半程手工跑,因为每小时跑它会让监控成为
它所监控的网络上最大的那个用户。
7 · 跨 hub 付款在两个账本上各造一份 credit
| 项 | 说明 |
|---|---|
| 位置 | ANetHub/internal/aghub/facilitator.go 的 SettlePayment 与 ClearFromPeer |
| 行为 | 付款方 hub 不分本地与否,一律给 payee 记贷;收款方 hub 凭对端签名的收据又记一次 |
| 影响 | 一次付款,两份 credit。联邦总供给按付款额增长 |
| 为什么单 hub 看不见 | 两个 hub 各自内部一致:outstanding == balances 在两边都成立,任何一侧的供给检查都通过。能看到差异的只有两侧之和 |
修法是:payee 不在本 hub 时不记贷,改记 hub_due,同时把 credit 收回 hub 自己那一行 ——
这与一次兑付是同一个动作,因为价值确实离开了这个账本。
对端 hub 那边由 ClearFromPeer 凭签名收据创造这一份,并把"对端欠我多少"记进 hub_owed,
不与任何人的余额相抵:那是对另一个 hub 的债权,不是这里的 credit,两者不能长得一样。
守卫两道:单测 TestACrossHubPaymentCreatesCreditOnce;
实网 prodtest.sh §9j,它断言的是两个 hub 的 outstanding 之和不变,
因为那是双重记账唯一会显形的地方。
8 · 跨 hub 的 credit 变动两侧都不入发放链
GET /x402/supply 会报出 chain_agrees,即"从签名发放链算出来的 chain_outstanding"
是否等于"从余额表算出来的 outstanding"。
跨 hub 付款那一刻,这个等式在两侧同时失效。失效原因不是账本有问题,是漏记:
付款方销毁了 credit 而没写 EvCreditRetired,收款方创造了 credit 而没写发放记录。
一条只记录一部分供给变动的链,会一边低估一边高估,而两个 hub 各自都报告自己一致。
现在两侧都在提交之后追加链记录,并且是"记日志而不是失败" ——
credit 已经动了,报一个失败会让付款方以为自己还持有那笔余额。
prodtest.sh §9j 在付款前后各查一次两个 hub 的 chain_agrees。
9 · 卖方 402 只报自己 hub 的账本
| 项 | 说明 |
|---|---|
| 位置 | ANetHub/internal/aghub/facilitator.go 的 hX402Supported(GET /x402/supported) |
| 行为 | 只列本 hub 自己的 network。卖方据此在 402 里只提供一个选项 |
| 影响 | credits 在别处的买方只能收到 insufficient funds。跨 hub 清算路径存在而无法到达 |
后果是一族"写好之后长期零触发"的代码:ClearFromPeer、SettleOwed、hub_owed。
修法是把"愿意清算的对端账本"一并列出,买方在自己 hub 的账本上付款。
prodtest.sh §9j 因此有两条断言:kinds 至少两条,以及"买方付在自己 hub 的账本上" ——
取列表里的第一条会取到卖方的账本,而那正是跨 hub 买方唯一没有余额的那个。
同一族里还有两条零调用点,发现方式是清点而不是联调,但影响只有实网能看见:
GrantCredit无调用点。 运营商无法为账户充值,余额只能来自注册赠额 (RegistrationGrant = 100)。补成anet-hub -grant <aid> -amount <n>。 做成二进制上的 flag 而不是 HTTP 端点是有意的:谁可以创造 credit 是一个本轮没有回答的策略问题, 一个端点会顺手把它答错。要求 shell 访问持有账本的那台机器,与数据库文件是同一条信任边界。IssueOwedSettlement无调用点。 一个欠了对端的 hub 没有办法说自己付过了,hub_owed只升不降。补成anet-hub -clear。 随后债务人一侧长出了同样的形状:due_to_peers只累加,而-clear -payee写好之后 在生产上零次运行 —— 看起来是一个能工作的清算闭环,实际只有一半。prodtest.sh§9j 现在真的跑一次清偿,并要求两侧同时归零。
10 · ledger 默认 100 条且不标记
| 项 | 说明 |
|---|---|
| 位置 | ANetHub 的 GET /agents/{aid}/ledger |
| 行为 | 页大小默认 100,响应里没有任何东西说明被截断了 |
| 影响 | anet reconcile 拿一页流水之和去对全额余额,凡历史超过一页的账户都报出一个由上限而非账本产生的差异 |
| 发现方式 | 拿 anet reconcile 对着实网 hub 跑。dmax 上报出的是:余额 866,流水和 −109 |
修法是让响应带 total(全账户条数)、sum(全账户流水和)、returned(本页条数),
并在 total > returned 时加上 truncated 与一句说明:对账要对 sum,不要对这一页。
这条缺陷的形状值得单独记一下:被破坏的是一个诊断工具, 而诊断工具报出的假差异,会把注意力引向一个不存在的账本问题。
11 · p2p 会合点是共享文件系统目录
| 项 | 说明 |
|---|---|
| 位置 | p2p 模块的对端发现 |
| 行为 | 发现靠一个共享文件系统目录。两台主机没有共享目录 |
| 影响 | 唯一为绕开 hub 而建的传输,只能在同一台机器上的节点之间使用 —— 恰好是不需要它的那种情况 |
| 发现方式 | 把两个节点放在两台机器上 |
修法是让 hub 兼作签名地址目录。hub 本来就知道谁存在、也持有他们的 KEL, 所以它是能回答"我该往哪里拨这个 AID"的最小的那个东西。设计上的几条取舍写在明处:
- 发布是 opt-in 且单独签名。 想要 hub 投递、但不想被当成目录的节点什么都不发布,也就不被列出。 hub 不会从一条连接推断出地址 —— 它记不下一个 agent 没有签过的地址。
- 空地址即撤回。 停止直连与注销 hub 是两个决定,不能只有一个开关。
- 查不到返回 404,不是空地址。 前者的意思是"回落到 hub",后者会被真的拿去拨号。
- 代价是公开的。 hub 现在持有一份地址表,所以
GET /p2p/peers把这份目录本身也公开 —— 付这个代价的人应该看得见它。
这不让 p2p 变成"无 hub",而是变成引荐之后无 hub: payload、证据、结果都不经过 hub,hub 只知道两个 peer 互相查过一次,不知道他们之后做了什么。
相关命令:anet p2p-advertise [<addr>](空参数即撤回),
读取端点 GET /agents/{aid}/p2p 与 GET /p2p/peers。
证据三:只有白板容器能发现的两条
这里的"白板"指什么都没装的系统(dmax 上的 debian:12 容器),
不是 blackboard 模块。这一层验的是真实新用户会走的那条路:
从一个干净容器开始,用线上的 install.sh 装,经生产 hub 从另一台机器调用它。
单测与联调都看不见这两条,原因是同一个:它们都跑在已经装好东西的机器上。
A · 白板系统缺什么
debian:12 这类最小系统既没有 curl,也没有 ca-certificates ——
缺后者时 HTTPS 直接失败,而失败信息指向的是证书,不是缺包。
uptime 这种顺手就会写进 shell 命令白名单的东西属于 procps,同样不在。
结果是文档里多了一条前置:
B · 改了配置没重新注册,能力就不上目录
| 项 | 说明 |
|---|---|
| 位置 | daemon 启动路径(ANet/internal/daemon/daemon.go) |
| 行为 | 改了模块配置并重启之后,daemon 不会重新向 hub 注册。能力清单是 hub-register 那一刻折进注册里的 |
| 影响 | hub 上的 caps 保持旧值,该节点在 anet find --cap … 里查不到。按 AID 直接委派仍然可用(能力由本节点解析),所以漏做这一步不会报错 |
| 发现方式 | dmax 上白板 Debian 容器的实测 |
删除方向更糟:hub 继续广告一个节点已经不再提供的能力,anet find --cap 把它当成有效答案返回,
据此发出的委派落进没人服务的那条路径 —— 请求方拿不到错误,provider 什么都没记,两条链上都没有这次调用。
修法在 daemon 侧。启动时 refreshRegistration() 用当前模块实际提供的能力清单重新注册一次,
后台执行且失败不致命:hub 不可达时节点仍然要能起来,而这是刷新一件 hub 已经有的东西,不是运行的前提。
操作上的结论随之变简单:
这条修改把卡片序号那个问题重新暴露了一次:序号以秒为单位,同一秒内的快速重启会铸出与上次相同的号, 被 hub 的高水位规则拒收。所以序号计数器现在落盘,重启后从记下的位置继续,而不是从时钟重新起算 —— 详见下一节。
这一条的形态值得注意:不报错的缺陷。系统的每个部件都在正常工作, 只是有一条信息没有传播出去,而后果(查不到)与另一种正常状态(没人在找)长得一样。 这类缺陷不会出现在任何日志里。
方法论结论
一 · fake 必须实现被替代方的完整契约
一个只实现快乐路径的 fake,是一个会同意被测代码做任何事的 fake。 它让缺陷在两侧同时变绿,而且让为这条缺陷写的回归测试变成空过。
本项目的真实例子:fake hub 曾经连 card 字段都不读。
daemon 铸一个卡片序号发过去,fake hub 存下这个 agent 就算成功,两个仓的套件都绿,
真 hub 在生产上拒收。真实缺陷是:卡片序号原本是秒级时钟,
同一秒内两次注册携带同一个号,hub 的高水位规则拒收第二次,报 STALE_SEQ ——
而"连着跑两次 anet hub-register"、"节点因为能力清单变了而在启动时重新注册"正是这件事的日常形态。
关键在于:在 fake 补上卡片准入之前,为这条缺陷写的回归测试是空过的。 测试通过了,通过的原因是 fake 不检查。
现在 fake hub 用与真 hub 相同的 adp.AdmitCard 跑准入,并维护 per-subject 高水位。
序号本身改为"以时钟为种子、保证超过本进程已铸出的每一个号",并且落盘。
单位仍然是秒,而不是毫秒 —— 毫秒也能打破平局,但会把序号抬高三个数量级,
之后一个降级回旧版本的节点将永远无法再更新自己的卡片。
落盘是第一版修法漏掉的一半:当时的理由是"一次重启比一秒长",而这不成立 —— 同一秒内重启会从同一个时钟值重新起算一个内存计数器,再次撞上 hub 的高水位。 在注册只发生于操作者主动执行时,这个漏洞还能活;daemon 开始在启动时重新注册之后, 每一次快速重启都会碰到它。落盘同时挡住反方向:跨重启的时钟回拨也不再落到高水位之下。
同类的第二个例子在 C1 那条线上:daemon 的 ANetLink 适配层忽略了整个 evidence 块,
测试全过,原因是 fake anetlinkd 也不发 evidence。两侧对"不传 provenance"达成了一致。
可操作的判据:写 fake 时,问的不是"本仓的用例需要什么", 而是"被替代的那一方会拒绝什么"。拒绝路径是 fake 最容易省掉、也最值钱的那一半。
二 · 线协字段名要像 golden 向量一样钉死
跨仓、跨语言的字段漂移不报错。请求成功,JSON 解析成功,一个字段静默地以零值到达。
- daemon 曾经从一个发送
credits的 hub 读取balance, 于是每个有钱的账户都报 0,而套件全绿。 现在ANet/internal/hubapi/hubapi_test.go把字段名像ANetCore钉 CBOR 向量一样钉住: 改名必须是一个更新这份清单的有意动作。 - hub 的 webui 用 TypeScript 接口镜像 Go 的 JSON,而它们曾经漂过:
Go 的
AgentView长出了home_hub、last_seen、quiet,TypeScript 的没有。 页面因此分不出本地 agent 与联邦 agent,也看不出谁停止收信了。什么都没失败 —— TypeScript 乐于描述一个比实际到达更窄的形状,多出来的字段在边界静默消失。 两侧现在都补齐了,守卫是ANetHub/internal/aghub/wirecontract_test.go: 它在 Go 侧读webui/src/lib/api.ts的接口字段名,与 Go 结构体逐条比对 —— 写在 Go 侧,因为 Go 侧拥有这条线。
三 · 新写的断言要用 mutation 验证
一个断言在正确实现上通过,不说明它在错误实现上会失败。
做法是:写完断言之后,把实现改坏,确认测试变红,再改回来。
上面 fake hub 不读 card 那个例子就是反例 —— 断言在那儿,实现坏了,测试仍然绿。
这条要求写进了项目的提交前检查里,与 gofmt -l 空、go vet 干净、
go test ./... 全绿、race 干净、tag 矩阵成立并列。
四 · 联调脚本是仓库的一部分,不是脚手架
scripts/joint.sh 与 scripts/prodtest.sh 与源码一起维护、一起提交、一起被要求通过。
把它们当成"调试时用一下的临时脚本",等价于把上面第 1–11 条缺陷所在的那整层删掉。
prodtest.sh 里还沉淀着一类不属于本软件、但决定测试能否成立的知识。
例如:明文 HTTP 访问一个解析到阿里云地址的域名,会被 ICP 备案检查拦截并替换成合规页面 ——
实测约十次里八次;而同样的请求打到 IP 上全部成功,因为拦截是按 Host 头触发的。
它间歇性发生,这才是它值得写下来的原因:它不干脆地失败,它产生看起来像别处缺陷的抖动。
测试分层
每一层覆盖上一层在原理上覆盖不到的东西。层与层之间不是"更严格",是不同的失败模式。
| 层 | 工具 | 覆盖什么 | 谁能跑 |
|---|---|---|---|
| 单元 + mutation | go test ./...,改坏实现确认断言会红 | 每一条新断言本身是否有效 | 每次提交 |
| 跨进程联调 | scripts/joint.sh(四仓六进程)、joint-shell.sh(hub + 两 daemon)、joint-invite.sh(准入) | 两边各自伪造对方时全绿的那一类 | 本机,需要四个仓的二进制(ANet / ANetLink / ANetMock / ANetHub) |
| 单机多节点 | scripts/scenario.sh(一个谁也不认识的 hub + 三个节点,第 7 节再起第二个 hub 与第四个节点;--live 接真模型) | 新用户加入路径、付费闭环、第二个 hub 的目录联邦与信誉联邦 | CI(默认档密闭,仅回环) |
| 双 hub 实网 | scripts/prodtest.sh(两 hub 两地、三 daemon、真 TLS 真 DNS 真云防火墙) | 只有两个账本之和才能显形的缺陷 | 需要两地两台公网 hub 主机,加上分属其下的 daemon 主机;每小时定时跑只读半程(--no-write),写入半程手工跑 |
| 真实服务 | scripts/realworld.sh(Docker 起 mosquitto / Modbus / Home Assistant / OPC UA / Matter) | 七个适配器对着不是自己写的线协 | 远端机器,需要 Docker Hub |
| 白板容器 | dmax 上 debian:12 容器 + 生产 hub + 另一台机器上的调用方 | 真实新用户路径:白板系统缺什么、一行安装能不能跑、跨公网调用容器里的机器 | 手工触发 |
| 拔插头 CI | pluggable 与 optin 两个 job,判据是 go tool nm 的符号数 | 减法 tag:完整构建符号数 > 0,-tags no_<name> 构建符号数 == 0;加法 tag shell 方向相反 | 每次提交 |
拔插头那一层的形状与本文其余部分是同一个道理: "运行时把它关掉"是一句读者无法在自己手上那个文件上验证的承诺, 而符号数是可以的。这条单独展开在另一篇。
局限
这份报告的证据标准是"本仓库在真实部署上的实测",它有几处明确的边界:
- 它是回顾性的。 列出的是已经被发现并修掉的缺陷。补上的守卫针对的是已知的失败模式, 不是穷举的。同类缺陷仍然可能存在于没有人放过断言的接缝上。
- 层与层之间的成本不同。
prodtest.sh需要两地两台公网 hub 主机、分属其下的 daemon 主机、 真域名与 TLS,跑一轮以分钟计,不能进 PR 门禁。白板容器一层目前是手工触发的。 这意味着最贵的那两层保护的是"合并之后",不是"合并之前"。 - daemon 与 ANetLink 之间那条线没有端到端契约测试。 现有的是两侧各自的反射测试
(
TestEveryEvidenceFieldCrossesTheWire在 ANetLink 侧,TestProvenanceSurvivesTheShim在 ANet 侧), 它们各自有效,但没有一个测试同时读两个仓的类型。三个仓当前的ANetCore依赖都在 v0.13.1, 但"版本一致"不等于"线协一致"。 - fake hub 跳过挑战签名的验证。 这是有意的取舍(签名由协议包自己的用例覆盖),
代价是:daemon 侧的用例证明不了"到达 hub 的挑战确实验得过"。
一个例外是付款结算:fake 的
hSettle真的验付款授权的签名,因为在那条路径上验签本身就是被测属性 —— 不验的话,把签名去掉,付费用例照样通过。准入与挑战签名那部分靠joint-invite.sh与prodtest.sh补。 - 本文不报告缺陷密度或修复时间。 样本是一个项目的一段时间, 这类数字在这个样本量上不承载信息。
一句话结论
两边各自伪造对方,可以同时全绿。 唯一能证伪这件事的, 是让一个真实的请求真的穿过所有那些边界 —— 跨进程、跨仓库、跨机器、跨账本、跨一台什么都没装的机器。
上面 13 条里,没有一条能靠往任何单个仓库里再加单元测试找到。