Skip to content

feat(bridge): 业务桥契约下沉 api 模块并重构线路治理 - #63

Merged
wcpe merged 7 commits into
masterfrom
feat/bridge-capabilities
Oct 30, 2026
Merged

wcpe merged 7 commits into
masterfrom
feat/bridge-capabilities

Conversation

@wcpe

@wcpe wcpe commented Oct 29, 2026 •

Copy link
Copy Markdown
Owner

概述

把 ServerProbe 的业务对接从「仓内硬编码的两个集成模块」重构为「对外契约 + 第三方自注册」,同时重构上行线路传输层,并补齐测试基础设施。

核心变化:探针从"内置业务域实现的监控插件"退回到"只做业务面板 ↔ 业务插件的路由中介"。第三方插件经 api 模块的 ServerProbeAPI 门面自行注册命令与事件,不再需要反射内部 IoC 容器。

主要改动

契约下沉到 api(纯 Java 8,可独立发布)

  • BusinessProvider / BusinessHost / BusinessEventSink / DomainManifest / ActionSpec / ProviderHealth / ConfigReloadRegistry 迁入 api 模块
  • 新增静态门面 ServerProbeAPI:返回稳定委派代理(永不 null、可提前缓存为字段),绝不抛异常,失效原因用 InactiveReason 枚举
  • 模型按用途拆入 api.model.{metrics,player,startup,diagnostics} 四个子包
  • 只读接口 ProbeReadApi 拆成 MetricsReadApi / StartupReadApi / NetworkForensicsReadApi 三个领域子接口

业务契约强类型化

  • manifest() 返回 DomainManifest,ActionSpec 声明 risk(READ/WRITE/DESTRUCTIVE)、idempotent、inputSchema/outputSchema、errorCodes
  • risk 未声明时按 WRITE 生效并告警(安全优先)
  • 动作名重复才拒绝,空名不算重复
  • dispatch(ActionRequest) 携带 requestId + Principal 审计上下文,返回含 errorCode 的 ActionResult

上行链路治理

  • 从「一帧一队列」改为读线程只解析投池、三池有界隔离、发送器优先级轮询
  • ping/hello 走 CONTROL 旁路直写
  • 入站帧超限分级处置:小幅超限跳过该帧保持连接,超大声明长度直接重连
  • 写侧 watchdog:writingSinceMs 在 writeFrameLocked 中置位并包住 flush(),两条写路径(阻塞控制帧 / 非阻塞事件帧)都受保护

事件传输可靠性

  • emitReliable 增加 per-domain seq + seqEpoch
  • 可丢事件一个号都不分配
  • 落盘格式扩展为 8 列,旧 6 列行仍可读出

主题订阅与帧分片

  • event 帧新增 topic,滤镜位于容量判定之前
  • 整帧超阈值自动切 chunk 分片,载荷走 base64,同组原子入队

Provider 健康 / 配置重载

  • health() 经缓存 + 独立刷新线程采集,绝不在命令线程调第三方代码
  • 重载采用通知-认领模型,编排池与 worker 池分离(共池会池内自锁);只有 TIMEOUT 触发熔断

移除内置业务集成

  • 删除 integration-multicurrencyeconomy / integration-allininventorysync 及对应 E2E 场景
  • 门禁从 verifyBusinessIntegrationJar(正向)改为 verifyNoBuiltInBusinessIntegration(反向断言)

依赖升级

依赖 变更 原因
taboolib 6.3.0-wcpe.1 → wcpe.3 修 isKotlinEnvironment() 只查未重定位 kotlin.Lazy,导致 Kotlin 写的 TabooLib 模块组件不被实例化
taboolib gradle 插件 2.0.37-fix → 2.0.38-wcpe.3 修运行期模块被整包重定位,@Surgeon/@Lead/@Trail 被改写到不存在的命名空间
mc-testkit 0.8.0 → 0.16.0 paperclip 前缀匹配修复(1.8.8 矩阵必需),并适配其每后端独立运行目录

测试基础设施

  • 平台与核心测试去反射化:生产侧开最小接缝(WorldMetricsReader、isBukkitPlatform、isVelocityPlatform、isIncisionEnabled 等)
  • 新增 E2E 场景 incision-startup
  • platform-bukkit 测试改用 JDK 21 launcher(JDK 8 的 Proxy.newProxyInstance 会强制初始化被代理接口,触发 Bukkit.Registry 静态块)
  • build-logic 引入 Gradle TestKit 替代"读源码文本 + contains()"的脆弱断言

验证

构建与单测

  • ./gradlew build(Java 21):BUILD SUCCESSFUL
  • ./gradlew test --rerun-tasks:771 测试,0 失败

E2E 真机验收:34/34 通过

组 场景数 结果
核心桥能力 8 ✅ 全部通过
版本矩阵 12 ✅ 全部通过(Paper 1.8.8→26.2、Spigot 1.8.8/1.16.5)
MCP 诊断 8 ✅ 全部通过(含 Bungee/Velocity 的 Via 变体、Java 8/21/25)
网络取证 5 ✅ 全部通过(含 WithBot / Via 变体)
Folia 观测 region 1 ✅ 通过(预热修复后)

明细:read-api / storage-spi / bridge-fixture / chunk-fixture / topic-fixture / health-fixture / reload-fixture / incision-startup / matrix-paper-{1-8-8,1-12-2,1-16-5,1-17-1,1-18-2,1-19-4,1-20-4,1-21-1,26-2} / matrix-spigot-{1-8-8,1-16-5} / mcp-diagnostics-{paper,paper-java21,spigot,java8,folia,bungee,velocity,velocity-java25} / network-forensics-{paper,bungee,velocity,folia}

folia-observed-regions:已定位并修复

该场景最初失败,症状是 Folia 启动即崩(Cannot asynchronously load chunks)。经基线对照(独立 worktree 检出 origin/master 跑同一场景,以完全相同方式失败)确认不是本 PR 引入,属既有缺陷。

根因链(日志实证):

首个 bot 登录 → PlayerList.canPlayerLogin → ServerPlayer.getBukkitEntity
  → CraftEntityTypes.<clinit>          ← 静态初始化要注册全部实体类型,实测 5.26s
  → 跑在 Folia 的 global region 线程上
  → 触发 watchdog「Global region has not responded in 5.263905234s」
  → 服务端开始 shutdown → level 初始化在 region 线程上抢跑 → 崩溃

修复:在 ServerLoadEvent(服务端刚就绪、尚无玩家)同步预热 CraftEntityTypes,把首次初始化从"bot 登录时卡住 tick"移到"启动末尾一次性预热"——watchdog 判的是单 tick 响应性,启动预热不触发它。

实测:预热耗时 528ms,场景 PASS(1m47s / 复跑 2m20s,此前跑满 600s 超时)。日志中 has not responded 与 Cannot asynchronously load chunks 均已消失。

顺带修掉一个连带缺陷:FOLIA_OBSERVED_REGIONS 原本不在 needsStartupEvent 名单里,收不到 ServerLoadEvent——不补这条,预热代码永远不会执行。

第二轮:启动不再崩溃,但断言仍随机制失败

启动修复后该场景仍时过时不过。我一度判定为「Folia 自身竞争、与我们无关」,理由只是崩溃栈里没有 ServerProbe 的帧——这个推理是错的:预热改的正是 Folia 的初始化时机,栈里没有我们的帧说明不了因果。

深入排查后找到三个叠加的真缺陷,全部已修:

1. region 身份判定与生产侧不一致

生产 ObservedRegionTracker.RegionKey 用 (internalId, worldName, centerChunkX, centerChunkZ) 四元组做身份;测试侧 ReadApiConsumer.observedRegionPair 却只用 foliaRegionId 取 firstOrNull。一个 Folia region 可覆盖多个 chunk,日志实证 targetRegionId=0 而 region 0 对应 chunk(-13,0)/(-12,0)/(-10,0)/(-11,0)——没有一条是任务实际所在的 chunk(0,0)。负载加在 chunk(0,0),p95 却取自未被加载的 chunk。

2. 协议 bot 会窒息死亡

落脚点只垫了落脚层一格,传送点上方若有世界生成方块,bot 被卡在方块里,几十秒后 suffocated in a wall。玩家一死 playerCount 归零,ObservedRegionTracker.onTickStart 走 pause() 分支,该 region 从此不再产生样本,配对与隔离断言随之失败。

3. 判据本身不成立(最关键)

原判据是纵向的「加压前 vs 加压后」。但目标 region 的 MSPT 在开局几十秒内本身就在陡降:

进服 7s  p95 = 102.84ms
静置后   p95 =  45.45ms
加压 32s p95 =  20.65ms

下降幅度(−80ms)远大于一次 15ms 负载能造成的抬升,任何静置时长都赌不赢。targetΔ 实测横跳 +2.99 / +4.46 / -12.31 / -24.81 / +2.64——跨两个数量级,正是判据失效的典型表现。

改为横向判据:加压后 targetP95 - controlP95 >= 6.0。两个 region 同机器、同启动阶段,唯一系统性差异就是其中一个在跑我们注入的自旋,时间趋势被抵消。纵向差分保留作辅助,用于排除「两边一起被抬高」的外部噪声。

结果

连续三次全 PASS,耗时 1m47s / 1m46s / 1m47s。最后一个样本:lateralGap=9.71ms(阈值 6.0,余量 62%)、targetP95=19.11、controlP95=9.40、controlP95Delta=-0.85(对照组零抬升)。

另加失败诊断,输出 loadRuns 与各 p95 实际值,下次失败能直接区分「负载没跑」「取样错位」「对照被污染」。

中间走过一段弯路,已回退:曾把加压时长从 120 ticks 拉到 640 想「填满 600 样本窗口」。方向错了——那只让 bot 暴露更久、放大了缺陷 2,测试从 1m54s 拖到 2m32s 且仍三中一过。改用横向判据后不再需要填满窗口,已调回 160 ticks。

CI 失败:BusinessDomainStatusTest 的并发缺陷(已修复)

首次推送后 Linux「构建 · 测试 · detekt」失败,Windows 与其余检查均通过:

BusinessDomainStatusTest > 100个域响应小于100毫秒() FAILED
    org.opentest4j.AssertionFailedError at BusinessDomainStatusTest.kt:282
796 tests completed, 1 failed

失败在第 282 行的数量断言(assertEquals(100, domains.size)),不是第 283 行的耗时断言——这个区别是定位的关键:耗时断言红才是抖动,数量断言红说明域真的少了。

根因

ProviderHealthRegistry.attach 原本写成:

entries.computeIfAbsent(domain) { Entry() }.reset(provider, digest)

computeIfAbsent 会先把尚未初始化的 Entry 发布进 map(此刻 registered=false、unregisteredAtMs=0),reset() 在其后才执行。而 sweep 线程的过期判定是:

if (!entry.isRegistered() && now - entry.unregisteredAtMs() > retainMs)
    entries.remove(domain, entry)

now - 0 > retainAfterUnregisterMs(300000) 对 0 恒成立,于是刚注册的域被当成「早已注销」直接删除,reset() 只改到已脱离 map 的孤儿实例。更糟的是 attach 末尾就调用 requestRefresh(domain) 唤醒 sweep 线程——注册越密集越容易命中(100 个域 = 100 次唤醒)。

定位方法(靠排除法而非复现)

该缺陷本地永远不出现,只有 CI 复现。判断依据:snapshot() 不过滤条目(未注册域也会出现在 domains 里,只是排序靠后),所以 domains.size < 100 只可能是条目被物理删除;而两条删除路径(sweepExpired / evictOverflow)都以 !isRegistered() 为前提。已注册域被删,只剩那个半成品窗口。

并用反射构造「已进 map 但未 reset」的 Entry 走真实 sweepExpired,确认它确实会被删除——缺陷机制就此闭环。

修复

entries.compute(domain) { _, existing -> (existing ?: Entry()).also { it.reset(provider, digest) } }

创建 + 初始化在 map 锁内原子完成,sweep 永远看不到半成品。重注册复用旧实例的语义不变。

测试的诚实边界

新增的 ProviderHealthRegistryTest#并发注册不会因过期清理丢失已注册域 不是该竞态的复现证据:中间态窗口只有纳秒级,实测该用例在修复前的代码上也能通过。它守的是「已注册域不会被清理误删」这个结果不变式,用于拦住「把过期判定写得更宽」这类后果更严重的回归。KDoc 里已如实标注这一点。

本次改动中发现并修复的两个真实缺陷

这两个都只有真机 E2E 才能暴露,编译与单测阶段完全不可见:

  1. 写侧 watchdog 失效:writeFrameWithTimeout 走非阻塞路径,writingSinceMs 从未被置位,checkWriteDeadline() 恒返回 false——写阻塞时连接永远不会被强制回收。修复:把置位/清零下沉到 writeFrameLocked(两条写路径的公共底层),且置位包住 flush()(真正的阻塞点)。

  2. ServerProbeAPI 代理漏覆写 historySnapshots:read() 返回的稳定代理未实现该新方法,于是落到接口的 default 实现——每次查询返回空列表,不抛异常、不打日志。e2eReadApi 场景因此必然超时。修复:补齐转发,并在 KDoc 写明"必须显式覆写,否则落到默认实现后每查必空且不报错"。已系统比对接口的 10 个方法与代理覆写,现全覆盖。

评审提示

⚠️ 本 PR 是一个合并提交。仓库 .claude/rules/git-commit.md §5.2 要求"包含多个独立意图的 PR 必须保留为多个逻辑提交",本次按项目负责人的明确指示合并为单提交。

拆分难点的实测结论(供日后需要回溯时参考):

  • api 模块发生破坏性重排,api.model.* 拆分影响全仓 100 个文件,任何"只含 api 改动"的提交都会让 core/platform/plugin 编译失败
  • 按 diff 内容实测:49 个纯 import 变化、49 个纯功能变化、94 个混合
  • 最重的几个:BridgeClient.kt(+729/−259)、ServerProbeE2eHarnessPlugin.kt(+650/−120)、ProbeConfig.kt(+513/−0)、e2e-verification.gradle.kts(+113/−320)

另:本 PR 已 rebase 到 origin/master(ad6881f v0.7.2),解决 23 处冲突。其中把桥功能整体重编号为 FR-30~41——远端的 FR-25~29 已被其他功能占用(启动画像指标 / 历史回读 / Grafana / 代理日志 / 告警扩面)。

测试计划

  • ./gradlew build 通过
  • ./gradlew test --rerun-tasks 全绿(本地 796 测试)
  • 34 个 E2E 场景在真机通过(含 folia 连跑两次)
  • 发行 jar 不含 top/wcpe/mc/plugin/serverprobe/integration/ 包路径
  • folia-observed-regions(三个真缺陷已修,连跑三次全 PASS)
  • BusinessDomainStatusTest 在 CI 的失败已定位为 ProviderHealthRegistry.attach 并发缺陷并修复

wcpe added 2 commits October 30, 2026 03:22
- 新增 no-fr-numbers.md:需求编号只允许出现在文档(docs/、CHANGELOG、README、.claude/rules)
- 禁止范围覆盖注释、标识符、字符串字面量、测试方法名与文件名
- 理由:编号随需求表重排而失效,代码应描述行为而非计划;字符串里的编号还会经日志与验收结果泄漏给服主
- 需要在代码中指向出处时,改为引用文档路径或 ADR 文件名
- 源码、测试、配置、脚本中的需求编号一律移除,编号只保留在 docs/ 与 CHANGELOG
- 注释按语义重写:只删编号与其带来的悬空括号、分隔符,不改动代码逻辑
- 两个含编号的文件按语义重命名(Fr8Consumer → ReadApiConsumer、VelocityFr13MatrixContractTest → VelocityMatrixContractTest)
- 顺带修正两条失效的 E2E 接线:prepareE2eFr11 / prepareE2eFr13 前缀匹配的任务名早已不存在(实际为 prepareE2eNetworkForensics* 与 e2eVelocityMatrixV*),这两个 harness 依赖此前从未生效
- 全仓等量替换(+744/−744),./gradlew build 通过
@wcpe
wcpe force-pushed the feat/bridge-capabilities branch 2 times, most recently from f3a1ab6 to ca6a40a Compare October 29, 2026 22:50
探针原先只做指标采集,业务对接靠仓内两个内置集成模块(MultiCurrencyEconomy /
AllinInventorySync)硬编码实现,第三方要接入只能反射内部 IoC 容器。本次把业务桥
做成对外契约:契约落在纯 Java 8 的 api 模块,探针只做「业务面板 ↔ 业务插件」的
路由中介,不含任何业务域实现。

- **契约下沉到 api**:BusinessProvider / BusinessHost / BusinessEventSink /
  DomainManifest / ActionSpec / ProviderHealth / ConfigReloadRegistry 等迁入
  api,第三方经 ServerProbeAPI 静态门面自注册并持句柄注销,不再需要反射。
  门面返回稳定委派代理(永不 null、可提前缓存),绝不抛异常,失效原因用
  InactiveReason 枚举而非自由文本。
- **manifest 强类型化**:manifest() 返回 DomainManifest,ActionSpec 声明
  risk(READ/WRITE/DESTRUCTIVE)、idempotent、inputSchema/outputSchema、
  errorCodes,下游据此渲染表单与决定二次确认。未声明 risk 按 WRITE 生效并告警
  (安全优先);动作名重复才拒绝,空名不算重复。
- **强类型分发**:dispatch(ActionRequest) 携带 requestId 与 Principal 审计上下文,
  返回 ActionResult 含 errorCode。requestId 恒非空(缺省由探针补 UUID,跨重启不撞),
  声明 idempotent 的动作必须据此本地去重。
- **移除内置业务集成**:删除 integration-* 两个模块及其 E2E 场景,
  verifyBusinessIntegrationJar 改为 verifyNoBuiltInBusinessIntegration
  (反向断言:发行 jar 里不得再出现该包路径)。
- **线路治理**:上行链路从「一帧一队列」重构为读线程只解析投池、三池有界隔离
  (回执/不可丢事件/可丢事件)、发送器优先级轮询。ping/hello 走 CONTROL 旁路
  直写,避免大数据帧把心跳挤到队尾让对端误判失联。入站帧超限分级处置:小幅超限
  跳过该帧保持连接,超大声明长度直接重连。
- **事件传输**:emitReliable 增加 per-domain seq 与 seqEpoch,使下游能凭单一
  序号判断缺口;可丢事件一个号都不分配(否则一被丢弃就在可靠序列里留下补不上的
  空洞)。落盘文件扩展为 8 列,旧 6 列行仍可读出。
- **主题订阅与分片**:event 帧新增 topic,滤镜位于容量判定之前(写反会导致订阅
  越多、无关事件越先占满可靠队列)。整帧超阈值自动切 chunk 分片,载荷走 base64
  而非直接切文本(避免 JSON 二次转义),同组原子入队、连片写出。
- **Provider 健康**:health() 经缓存 + 独立刷新线程采集,绝不在命令线程调第三方
  代码(business_status 本身已占一个命令槽,两个慢域就能让命令通道饱和)。
  存储时即脱敏限长,防失控 Provider 撑爆回执与内存。
- **配置重载**:通知-认领模型,探针只转发意图并收集插件自报结果,不推断生效。
  编排池与监听器子任务池分离(共池会池内自锁,让全部请求误判 TIMEOUT);
  只有 TIMEOUT 触发熔断,确定性错误码重试有意义。
- **配套**:taboolib 升 6.3.0-wcpe.3(修 isKotlinEnvironment 只查未重定位
  kotlin.Lazy 导致 Kotlin 写的模块组件不被扫描)、taboolib gradle 插件升
  2.0.38-wcpe.3、mc-testkit 升 0.16.0(paperclip 前缀匹配修复,1.8.8 矩阵
  必需)并适配其每后端独立运行目录。api 模型按用途拆入 model.{metrics,player,
  startup,diagnostics} 子包,只读接口拆成三个领域子接口。
@wcpe
wcpe force-pushed the feat/bridge-capabilities branch from ca6a40a to d948bd7 Compare October 30, 2026 05:23
wcpe added 4 commits October 30, 2026 16:59
code review 发现一批只出现在异常路径上的缺陷——E2E 全绿是因为 33 个场景跑的都是
happy path,而这些缺陷全部在 Worker 断连、磁盘写一半、正常停机这几条路上。

可靠事件落盘:设计意图与实现自相矛盾:

- `unacked` 无界:`MAX_PENDING` 只约束交接队列,落盘后的记录只进不出——
  唯一的出队是「连续已确认前缀」,而 Worker 不可达时它恒为 0。于是内存按
  「自断连起产生的全部可靠事件」线性增长,最终 OOM。而 OOM 的后果不是
  「丢一部分」,是全部丢失 + 服务端崩溃。现加硬上限 16384,超出丢最旧并计入
  `droppedBeyondDisk`——与既有的「宁丢旧不崩」降级语义一致。

- 轮转在无已提交前缀时永久停摆:原实现在 `committed <= 0` 时直接 `return` 并
  WARN「暂停轮转以免丢未提交事件」。但文件会因此无上限增长直到打满磁盘,
  届时连同服务端一起崩——设计说的「绝不丢」变成了「全丢」。现改为按体积硬裁切
  (保留最近 `spillMaxBytes` 字节,对齐到行边界),丢弃量记账。

- 轮转后不重映射偏移:原注释写「未确认列表保持不变」,但文件已被截断到从 0 开始,
  而 `Record.offset` 仍是重写前的绝对偏移 —— `bindReplayInstance` 按新偏移找不到记录、
  `onSent` 按引用也找不到,`committedOffset` 永远停在 0。后果是每次重连重复上行
  同一批、且回放永远无法推进。现由 `rebaseUnacked` / `rebaseUnackedButDrop` 重建偏移。

- `replayOffset` 补 `@Volatile`:它被落盘线程(轮转归零)与连接线程(装载/推进/重置)
  并发读写,缺可见性会让 `next()` 拿到轮转前的旧偏移、在刚重写过的文件里 seek 出半行。

停机:按注释写明的顺序执行:

原实现是 `sender.shutdown()` → `spill.shutdown()` → `seqAllocator.shutdown()`
→ `pools.shutdown()`,与 KDoc 写的「停读 → 打断重载池 → 优雅停命令/事件池 →
停发送 → 停落盘 → 关连接」完全相反,造成三处丢失:

- 在途命令的回执无处可发(`sendReceipt` 阻塞等容量必超时后丢弃)——
  每次正常关闭,凡有在途命令 JM 都拿不到任何回执;
- 落盘待写队列不 drain、不 fsync,停机前一刻被受理的可靠事件静默消失且不计数;
- 序号状态早于业务静默落盘,之后分配的号不再持久化 → 重启后重号。

现按注释顺序执行,并配套新增 `FrameSender.shutdownAfterDrain`(有界排空)、
`BridgePools.awaitTermination`、`EventSpillStore.flush`(生产路径用,此前只有测试专用的
`flushForTest`)。

序号状态:原子写 + 完整性判定:

`EventSeqStore.persist` 用 `writeText` 截断重写,而 `load` 的损坏判定是「一行都没解析
出来」——截断的文件完全能解析出 epoch 和部分域,于是被判 `Present` 且 max 偏小 →
重启后与已发出的号重号,且 epoch 不变,下游连「该对账」的信号都收不到。
这恰好绕开了 `EventSeqAllocator` 类注释明确要防的「一次磁盘故障就让序号静默回退」。

- 改为临时文件 + `fd.sync()` + `ATOMIC_MOVE`(不支持时回退普通 move);
- 文件尾写 `# EOF` 标记,`load` 缺标记即判 `Corrupt`——宁可跳号不可静默重号;
- `persistNow` 的「快照生成 + 提交」整体串行化:原来只对写文件加锁,
  两个线程并发时后写的旧快照会覆盖先写的新快照。

WebSocket 客户端:

- 写超时机制无效:`SO_TIMEOUT` 在 JDK 里只作用于 `read`,对 `write/flush` 毫无作用。
  注释宣称的「flush 至多等 1 秒」并不成立,`catch (SocketTimeoutException)` 分支永远不会
  触发;更糟的是它临时改 `soTimeout` 会缩短读侧超时,而 `readFrame` 不跟踪帧边界,
  中途超时会导致协议失步。现删除该机制,如实写明写侧唯一上界是 watchdog 的
  `writeDeadlineMs`;`WRITE_TIMEOUT_MS` 更名为 `WRITE_LOCK_WAIT_MS`(它只是取锁超时)。

- 握手失败泄漏 fd:`readHttpLine` 抛异常时 `socket` 字段尚未赋值,
  上层 `client?.close()` 拿不到实例。重连循环下,一个持续返回坏握手的 Worker
  会让 fd 单调增长到 `Too many open files`。现整个握手体包进 try/catch 单点关闭。
  同时抽出 `verifyHandshakeResponse`,让 `connect` 只负责建连与写请求。

api 门面:

- `BusinessHostProxy` 8 个方法裸委派,违反类注释「所有静态方法均不抛异常」。
  可触发路径:探针 `@PreDestroy shutdown()` 已 `shutdownNow` 但门面尚未 uninstall 的
  窗口里,`dispatchAsync` 会抛 `RejectedExecutionException` 穿透给调用方。现全部补齐兜底,
  `dispatchAsync` 返回已完成的失败 Future(不返回 null,也不异常完成)。

- 17 处 `catch (Throwable ignored)` 全静默:探针实现一旦开始抛异常,
  所有查询永久变成空列表/null 且日志零痕迹——正是类注释自己担心的静默失败。
  现改为按方法名去重告警(`Error` → SEVERE,`Exception` → WARNING),
  并补 `delegate()` 的 null 兜底(`ProbeReadApi` 被十个方法读,null 会让整个只读门面 NPE)。

其他:

- `BridgeClient.doStart` 中途失败时 `running` 卡在 true → 桥永久无法自愈,只能重启服务器。
  现失败时回滚 `running` 并清理已创建资源;不走 `stop()` 是因为那是带秒级预算的运维停机
  协议,会把一次启动失败拖成十几秒主线程停顿。
- 中断标志残留:`ReloadTaskHandle.run` 与 `HealthProbeRunner` 的任务体在 `finally` 里
  清中断位。原先 `cancel()` 打的 interrupt 会留在 worker/probe 线程上,
  同线程的下一个任务一进门就「被中断」,被广捕记成 `PLUGIN_THREW` 或送进隔离窗口
  ——把上次的超时错误归因到无辜插件。
- `awaitAvailable` 的 javadoc 标注「禁止在服务器主线程调用」:它会 `Thread.sleep` 轮询,
  而第三方最自然的落点是 `onEnable`(主线程),探针 install 也需主线程 → 永远等不到,
  只白等超时。推荐用法改为「缓存稳定代理即可,无需等待」(这正是本设计的初衷)。

文档一致性:

- ADR-0029 与 spec 声称 `install` 有 `SELF_INSTALLER_CLASSES` 调用栈白名单 ——
  代码里完全不存在(实际只有 package-private)。已如实改正,并把验收表里
  那条「门面不可劫持」标为未实现。
- `docs/API.md` 5 处仍在教旧门面 `ServerProbeApi`,其中「统一返回 null」会让使用者写出
  永不成立的 `?: return`;同步修正数据模型包路径。
- CHANGELOG 未发布段缺 FR-37~41 五项功能登记,补齐。

测试:

- `ReadFacadeProxyTest` 补 `historySnapshots` 的委派透传与违约降级断言。
  这条尤其重要:该方法的代理覆写曾漏过一次(落到接口 default 后永远返回空、
  不抛异常不打日志,只有真机 E2E 才暴露),而测试此前完全没有覆盖它。
  已验证防护有效:注释掉代理里的覆写后该用例确实变红。
- 新增 `FrameWriterTest`(18 条):钉死「未连接时可靠事件转落盘、可丢事件直接丢弃」
  的分级语义,以及「落盘结果必须被检查」(防无条件记 spilled 把真实丢失伪装成已落盘)。
- 新增 `ReloadWorkerPoolTest`(7 条):钉死「队列满时 submit 返回 null 而不是抛」
  ——「拒绝必须被看见、但绝不把异常抛给读线程」这个架构决策的落点。
ProviderHealthRegistry.attach 用 computeIfAbsent 建条目,新 Entry 会先以
registered=false、unregisteredAtMs=0 的形态发布进 map,reset 在其后才执行。
而 sweepExpired 的过期判定是 now - unregisteredAtMs() > retainMs,
对 0 恒成立——刚注册的域被当成早已注销直接删除,reset 之后改的只是一个
已脱离 map 的孤儿实例。attach 末尾又正好调 requestRefresh 唤醒 sweep 线程,
注册越密集越容易命中。

该缺陷只在 CI 复现:BusinessDomainStatusTest 的 100 个域用例在数量断言失败,
本地则因窗口仅纳秒级而始终通过。

改为 entries.compute{...},创建与初始化在 map 锁内一次完成。
该场景长期被当作「Folia 自身不稳定」,实际是三个叠加的缺陷:

1. region 身份判定与生产侧不一致
   生产 ObservedRegionTracker.RegionKey 用 (internalId, worldName,
   centerChunkX, centerChunkZ) 四元组;测试侧只用 foliaRegionId 取
   firstOrNull。一个 Folia region 可覆盖多 chunk,任务在 chunk(0,0)
   而取到的是别的 chunk 的样本,负载抬升被摊薄。
   任务调度时记录实际 chunk 坐标,配对时 id 与坐标一起匹配。

2. 协议 bot 会窒息死亡,导致 region 停止采样
   落脚点只垫了落脚层一格,传送点上方若有世界生成方块,bot 卡在方块里
   死亡;玩家一死 playerCount 归零,onTickStart 走 pause 分支,该 region
   从此不再产生样本,配对随之失败。
   先清空玩家身体占据的两格,再垫落脚层,并给出抗性兜底。

3. 判据本身不成立
   原判据是纵向的「加压前 vs 加压后」。但目标 region 的 MSPT 在开局
   几十秒内本身就在陡降(实测 102.84ms -> 45.45ms -> 20.65ms),
   下降幅度远大于一次负载能造成的抬升,任何静置时长都无法抵消。
   targetΔ 实测横跳 +2.99 / +4.46 / -12.31 / -24.81 / +2.64。
   改为横向判据:加压后目标 region 的 p95 必须比对照 region 高出阈值,
   两个 region 同机器同启动阶段,唯一系统性差异是我们注入的自旋。
   纵向差分保留作辅助,用于排除两边一起被抬高的情形。

另加失败诊断:失败时输出 loadRuns 与各 p95 实际值,避免再次只看到
一句笼统结论而无法区分「负载没跑」「取样错位」「对照被污染」。
主干历史以「一个 PR = 一条提交」为单位组织,不再要求把分支上的
多个开发期提交原样带进 master。

- 压合标题采用 PR 标题,正文汇总各分支提交要点
- 开发期细分提交仍保留「写时能独立理解与回滚」的价值,只是不进主干
- 合并前仍须清理 WIP / fixup! / squash! 与拼写问题
- docs/CONTRIBUTING.md 的摘要同步修改,避免两处规范打架
@wcpe
wcpe merged commit 13816e2 into master Oct 30, 2026
8 checks passed
@wcpe
wcpe deleted the feat/bridge-capabilities branch October 30, 2026 11:45
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant