AGENT NETWORK · DOCS

研究:哪些缺陷只有联调能发现

十三个只有跨进程联调、双 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 适配层测试跑在一个 fake anetlinkd 上;
  • 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 的那一个:

{
  "provider": "did:anet:…",
  "capability": "light.onoff@sim/lamp-1",
  "args": { "on": true }
}

同一条路径上还有一个同源问题: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,同样不在。

结果是文档里多了一条前置:

apt-get update && apt-get install -y curl ca-certificates 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 已经有的东西,不是运行的前提。 操作上的结论随之变简单:

anet stop && anet up   # 重启即重新注册;显式 hub-register 仍用于改名字或改手写的 caps

这条修改把卡片序号那个问题重新暴露了一次:序号以秒为单位,同一秒内的快速重启会铸出与上次相同的号, 被 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 头触发的。 它间歇性发生,这才是它值得写下来的原因:它不干脆地失败,它产生看起来像别处缺陷的抖动。


测试分层

每一层覆盖上一层在原理上覆盖不到的东西。层与层之间不是"更严格",是不同的失败模式。

层工具覆盖什么谁能跑
单元 + mutationgo 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 + 另一台机器上的调用方真实新用户路径:白板系统缺什么、一行安装能不能跑、跨公网调用容器里的机器手工触发
拔插头 CIpluggable 与 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 条里,没有一条能靠往任何单个仓库里再加单元测试找到。