Skip to content

feat(core): 新增外部控制面托管模式与桥指标上报 - #64

Merged
wcpe merged 48 commits into
masterfrom
feat/managed-mode
Nov 3, 2026
Merged

wcpe merged 48 commits into
masterfrom
feat/managed-mode

Conversation

@wcpe

@wcpe wcpe commented Nov 1, 2026 •

Copy link
Copy Markdown
Owner

变更概述

本 PR 含两块内容:

  1. 外部控制面托管模式(FR-42~47):探针可由外部控制面托管,凭身份文件接入、配置热下发、指标经反向 WS 上行、MCP 工具面经插件桥暴露;托管模式下本地 metrics/web/mcp 三个监听面强制关断。
  2. 诊断工具与桥指标链路的一组修复(后 13 个提交):桥自身中转指标上报、Arthas 运行时补齐、MCP 工具的若干真机缺陷修复。

一、托管模式(FR-42~47)

主要能力

  • 身份文件即开关:agent-identity.json 原子写、半写防御、启动三态判定(托管 / 存量 bridge 段自动迁移 / 独立模式),迁移幂等并保留备份。
  • 配置源抽象与三档分级:托管缓存 > config.yml > 默认值;键分 SAFE / GUARDED / FORBIDDEN,禁止档出现在下发载荷即整包拒绝;托管值热切换、版本单调、自检失败自动回退上一版。
  • 状态上报与指标上行:周期 config_state(脱敏)与 metric_snapshot,metrics_now 按需推送;指标上行改为只在托管模式启用,独立模式不再声明 metric_push 能力位。
  • 零监听保证:按运行模式关断本地监听面;判定超时/失败且存在身份文件时 fail-close(不 bind),模式事后落定为托管则回撤已开端口。
  • MCP 经插件桥:core.mcp 动作承载 JSON-RPC,工具面/工件/审计与本地端点共用装配;审计记录操作者。

真机验证

场景 平台 结果
managed-config Paper 1.20.1 PASS 7/7
managed-config-paper188 Paper 1.8.8 PASS 7/7
managed-config-paper1211 Paper 1.21.1 PASS 7/7
managed-config-bungee BungeeCord PASS 7/7
managed-config-velocity Velocity 3.1.1 PASS 7/7
managed-auth-reject(401/403) Paper 1.20.1 PASS 9/9
bridge-fixture / read-api(独立模式回归) Paper 1.20.1 PASS

代理端场景的「模拟控制面」是跑在代理进程内的 Kotlin 夹具(harness-managed-bungee / -velocity + 共享的 e2e/fixture-core),仓库内已无任何 Python 依赖。

代码审查与修复

本分支经过两轮独立代码审查(托管核心 / 桥与 MCP / 平台端 / 构建与 E2E 四块,外加针对修复 diff 的复审),修复提交链:

  • 门禁与夹具可信度:结果门禁空清单硬失败;后端夹具不再「未加载也绿」;零断言即 FAIL;hello 能力位测试从「验死代码」改为验真实帧;build-logic 测试接入 check。
  • 并发与生命周期:零监听 fail-close + 回撤;config_push 串行化与版本单调;三处 stop 先到竞态;桥启动线程化、目标可自愈;停机后不再残留线程。
  • 安全:config_applied.effective 与 config_state 共用同一份脱敏;迁移备份与身份文件同权限;迁移不再静默丢弃运维新增的 bridge 段。
  • 健壮性:推送循环异常兜底;能力位并发安全;重连退避按断开原因取值并在连接稳定后才复位;托管缓存装载逐键校验。

二、诊断工具与桥指标链路修复(后 13 个提交)

桥指标链路

  • f96b110 metric_snapshot 帧新增桥自身中转指标(队列深度 / 拒绝速率 / 写失败)。控制面侧仅在 BridgeMetricsPresent 时落点,未知一律 NULL 断点——「不知道」不会被当成「健康」。
  • 5df8c9c 落盘兜底默认关闭 + 新增桥链路压测夹具(e2e/harness-bridge-load,含延迟直方图单测)。

MCP 工具真机缺陷

提交 问题
1f9c10d jfr_stop 缺 --recording,recording 永远停不下来
c3559dd JFR recording id 靠本工具自增计数推断,多 recording 时错位;改为以 JVM 实际状态为准
b5dfb53 ArthasControlRegistry 未转发 executeSync,上一个修复形同虚设
986a57b plugin_list 把插件名当类名反查,插件数量多时同步调用超时;改为正向查表
86f0675 审计归档按毫秒命名,同一毫秒内轮转互相覆盖,静默丢数据;改为原子占位命名

Arthas 运行时

  • 0d6e743 内置 vmtool 的 JNI 库并按官方 lib/ 布局落盘(缺库时 vmtool 恒报 no ArthasJniLibrary in java.library.path,而运行期不联网补下载);CPU 归因默认开启;备份失败改为逐步告警可诊断。
  • 9b63a0f JNI 库纳入升级校验(否则旧目录跳过解压、库永不落盘);备份改用 ClassLoader 读字节码。

IoC 与测试

  • 9ffe9b2 移除项目内自建扫描器,改用 taboolib-ioc 1.3.1 + 编译期索引。
  • 4511c3b 补 configReadyGate 测试接缝,修复长期失败的异步契约用例。

CI

  • 04f5be6 detekt:MetricPusher 桥状态读取补 SwallowedException 抑制 + 说明;McpAuditTrail 的 catch (Throwable) 收窄为 IOException。
  • 0da2996 verifyE2eResults 的 resultsDirectory 由 @InputDirectory 改为 @InputFiles 文件集合。原因是 @Optional 只允许「值没设」,值一由 convention 设上就要求目录真实存在;而「所有场景启动即挂」正是目录不产出的那条路径,于是判定逻辑压根没执行、构建死在输入校验阶段——这段「缺结果即失败」的守卫在最该生效的时刻是失效的。

三、已知未覆盖(请 reviewer 知悉)

  1. GitHub Actions 实际行为:本地无法触发,本 PR 的 CI 结果即为首次真实验证。
  2. 小批灰度:需生产环境,未做。
  3. Linux 真机:目前仅 Windows 真机验证(代理端插件数据目录大小写差异已在模板层处理,但未在 Linux 上实跑)。
  4. 凭据轮换热生效:规格中仍为「待定,按重启生效」。
  5. config_state.connection=unauthorized:现有协议形态下该状态在断开时不可观测,本 PR 以「归类为 AUTH_FAILED + 不降级 + 恢复后重连」作为等价验收(managed-auth-reject 场景)。
  6. metric_snapshot 秒级 dedupKey:需与控制面侧对齐,未擅自改协议。

四、平台差异(已实测确认,非遗留缺陷)

代理端(Bungee / Velocity)MCP 控制面完整可用——工具面与 Bukkit 端同源装配,server_status / server_command / thread_top / thread_dump / gc_stats / log_tail / Arthas 全家桶等均实测可用。

平台差异只体现在依赖 Bukkit 专有平台 API 的少数工具上,这类工具在代理端返回结构化降级而非报错:

  • plugin_list / plugin_classes / plugin_cpu / plugin_threads — 代理端无 Bukkit 插件模型,返回 platformUnsupported: true
  • player_lookup — 代理端不持有玩家背包/世界等子服数据,返回 {available: false, reason: "平台不支持在线玩家明细"}

桥治理执行器(kick / list)按 ADR-016 只在 Bukkit 子服端与 Bungee 代理端实现,Velocity 侧不提供(收到此类指令返回明确失败回执,不静默成功)——见 docs/API.md 平台语义一节。

五、影响面

  • 平台模块(Bukkit/Bungee/Velocity)、api、Java agent 改动集中在新增的 MCP Provider 实现与治理执行器;核心逻辑在 project/core。
  • 独立模式行为:除「不再推送指标 / 不再声明 metric_push」与「判定超时且存在身份文件时不 bind」外,其余保持一致(bridge-fixture、read-api 真机回归通过)。
  • 版本:0.8.0-SNAPSHOT(未发布)。

wcpe added 30 commits October 31, 2026 19:52
- 身份文件 agent-identity.json 作为托管开关:tmp→fsync→rename 原子写、截断视为不存在并降级独立模式;存量 bridge 段自动迁移并备份 config.yml
- 探针配置全部键改为经 ManagedConfigResolver 取值(托管缓存优先于 config.yml),配置键按安全直接应用、危险校验后应用、禁止远程三档分级
- 下行 config_push 做版本与档位校验后后台原子应用,三项自检通过才落缓存,失败自动回退上一版并回执原因
- 托管状态按周期上行 config_state,区分网络不可达与凭据被拒两种失败语义,凭据被拒不降级到文件配置
- 指标经反向 WS 推送 metric_snapshot,另支持 metrics_now 按需推送;托管模式下 metrics/web/mcp 三个本地监听面一律不 bind,独立模式行为不变
- 上行接线落在既有线路治理架构上:指标与状态走 FrameSender 的可丢队列,治理动作经 GovernanceRoute 的 core 内置动作分发,连接目标由身份文件替代 bridge 段配置
- 治理指令的载荷同时接受嵌套 payload 对象与 payloadJson 字符串,避免结构化下发被当成空载荷
- 新增厂商隔离门禁:api 与 project:core 源码不得出现控制面厂商标识,挂入 check
- 新场景 managed-config:由身份文件驱动的回环 fixture 扮演控制面,在真机 Paper 上逐项验收 hello 托管位与能力位、config_push 热应用回执、周期 metric_snapshot 推送、config_state 上报、metrics_now 按需回执,以及托管下 metrics/web 零监听
- 模板配置故意把 metrics/web 写成 enabled=true,用来验证托管模式的强制关断(端口连不上才算通过)
- prepare 阶段在配置注入之后写身份文件(注入会清理残留身份),端口与凭据一次性随机生成
- 场景纳入启动后验收:探针需先完成模式判定与首份指标快照,否则会撞上自检窗口
- 夹具侧对 config_push 做有限重试,避免把“刚启动时首份快照未产出”误判成配置链路故障
- 删除 serverprobe.vendor-isolation 约定插件及其在 api / project:core / 根工程的接线:它读源码文本按关键词判定合规,属于典型的假门禁
- 新增规则(static-analysis.md §4):禁止以「读源码/配置/文档字符内容 + 关键词匹配」作为构建或 CI 的通过条件,约束应落在编译器可检查的结构或断言运行行为的测试上
- 厂商中立仍作为设计约束保留,改为评审保证(见 managed-mode 规格)
- 与上游 FR 表撞号:FR-30~FR-41 已由业务桥下沉与线路治理各批需求占用,托管模式原占 FR-30~34 会让同一号指两个需求,重编为 FR-42~FR-46
- PRD 五行的编号、内部依赖引用与状态同步:状态由「已交付@v0.8.0·真机验收」归真为「开发中(Paper 1.20.1 真机 E2E 通过;待代理端真机与发版)」
- 规格的关联需求、任务拆分、验收标准同步;厂商标识一栏改为评审保证(不再引用源码扫描门禁)
- 代码中指向托管模式的注释编号同步重编,避免指到别的需求
- 托管模式为兑现零监听端口会关断 metrics/web/mcp 三个本地监听面;前两者有替代(指标推送、控制面渲染),事故诊断没有替代,故把 MCP 挂到已有桥连接上
- 新增 core.mcp 动作:payload 是完整 JSON-RPC 2.0 请求,回执 output 是 JSON-RPC 响应;协议层错误(版本不符/方法未实现/工具不存在)仍以成功回执返回并保留 error.code,只有传输与格式错误才是桥失败
- 工具面、工件工作区与审计与本地 HTTP 端点共用同一装配:McpControlPlane 拆出 ensureRuntime() 与 bridgeDispatcher(),让工件工作区不再依赖“起监听”
- 开关语义不变:mcp.enabled 决定是否启用 MCP 控制面,传输方式按模式切换;该键仍是禁止远程档,两个方向都不新增绕过口
- 挂载成功后才声明 mcp_bridge 能力位
- E2E 场景新增第 7 项断言 mcpBridge(下发 initialize 断言回执含 protocolVersion),并把零监听断言扩到 metrics/web/mcp 三个端口
- 首份指标快照要等一个采集周期才产出,控制面在 hello 后立刻下发配置时,“取不到快照”会把装配时序误报成能力缺失(真机 E2E 实测:启动约 1s 内下发必定被拒并回退)
- 改为采集器在跑时最多等一个采集周期(ticks×50ms,夹在 500ms~5s)轮询快照,等到即通过、超时仍无快照才判不可产:语义仍是“真能产出快照”,不再把窗口期算失败
- 等待发生在桥的命令池线程且有硬上限,不占 MC 主线程,也不会拖住其它下行指令
- 两处用“可靠队列深度归零”当等待条件,而队列在写出尝试之前就已出队,此时 reliableRejected 尚未递增,断言时而拿到 0(偶发红,与代码无关)
- 改为以被断言的计数本身作等待条件(有界轮询),语义不变且不再抢跑
- hello 是连接瞬时的快照,而托管组件(如 MCP 挂桥)在模式判定之后才装配完成,两者存在毫秒级先后差:控制面只看首个 hello 可能看不到 mcp_bridge
- 在 core.bridge_status 的回执里补 capabilities(查询时刻的活值),控制面据此刷新能力视图,不必重连或猜时机
- 规格同步说明该口径
- 下行 command 帧的 principal.operator 映射为审计来源 bridge:<operator>,缺失时退化为 bridge(老控制面不注入也不影响审计可用性)
- 探针只把 principal 当字符串标签,不解释控制面的账号体系(厂商中立)
- 修一处真实缺陷:handle(payload, source) 未把 source 继续传给下游,会导致带操作者的审计来源在字符串入口路径上丢失(detekt 的 UnusedParameter 抓到的)
- 单测覆盖两条路径:带操作者时审计文件出现 bridge:alice;空载荷被拒时不产生审计
- 面向控制面/上游:帧格式、错误分级(桥 fail 与 JSON-RPC error.code 的界线)、能力发现时机(用 bridge_status.capabilities 而非首个 hello)、必处理的三个坑(分片重组/限流 4 并发/长任务用 taskId 轮询与大产物分页)、断线语义
- 附 ~40 行 Node 转发壳子参考实现
- 版本号由 0.7.2 升为 0.8.0-SNAPSHOT,供托管模式联调;0.7.2 仍是最后一个正式发布版本
- CHANGELOG 新增未发布段:托管模式(FR-42~FR-47)、零监听端口、MCP 经桥、bridge_status 能力位,以及自检窗口与审计来源两处修复;顶部显式标注“未发布、功能尚未完成全量真机验收”
- 两个新场景复用同一套托管断言,分别跑在 1.8.8(锁 Java 8)与 1.21.1 上;prepare 任务抽出 installManagedScenario 复用于三个后端
- 修复旧版服务端无 ServerLoadEvent 时场景永不启动:改为按固定延时兜底进入验收(400 ticks)
- 夹具重试放宽到 12 次、零监听判定放宽到 15 秒,适配 1.8 较慢的启动
- 真机结果:managed-config / -paper188 / -paper1211 三者均 7/7 断言 PASS
- 新增第 14 章:两模式判定表(托管/独立)、组件与线程模型、失败语义(回退、断线、凭据被拒不降级)
- e2e/tools/probe_control_plane_sim.py:纯标准库实现的 RFC6455 服务端,扮演外部控制面,等托管探针反向连入后逐项验收
- 覆盖 hello 托管位与能力位、bridge_status 能力活值、config_push 热应用(版本按探针当前版本 +1 下发,避免被正确判为 stale)、周期 metric_snapshot、metrics_now、config_state(online)、MCP initialize/tools/list/tools/call(含 chunk 帧重组)、托管下三个监听端口零绑定
- 循环 accept 跳过端口探活连接,避免把“握不了手就走”的探测当成探针
- 用途:代理端与手工起服等 mc-testkit 覆盖不到的场景,以及对接真实控制面前先自验协议
身份文件与托管 config.yml 改为随代理模板生成:mc-testkit 在场景任务内部用模板铺开代理运行
目录,而场景钩子跑在那之前,钩子写进 plugins/<数据目录> 的文件会被模板覆盖,探针因此退回
独立模式(实测现象为身份文件消失、控制面握手超时)。故新增 bungee-managed / velocity-managed
两个代理,模板目录由 prepareManagedProxyTemplates 生成,端口与凭据在配置期一次确定,供模板与
模拟控制面钩子共用;场景通过 e2e<Key>Via<Proxy> 任务运行。
独立模式的桥只承载业务转发,不再周期推送 metric_snapshot,hello 与 config_state 也不再声明
metric_push 能力位:能力位必须与真实能力一致,否则旧 Worker 会一直等推送帧,而正确行为是按协议
回退到 HTTP 拉取 /metrics(该端点语义不变)。MetricPushService 装配改为先等托管模式判定再注册
metrics_now 与启动推送线程,并补上 stop 先到时的时序保护。
仅注释措辞:端口让位的理由与数值不变,不再点名具体测试服。
把「最小 WS 服务端 + 托管断言 + 结果文件」抽成平台无关的 e2e/fixture-core,
Bukkit / Bungee / Velocity 三个 harness 共用;新增 harness-managed-bungee 与
harness-managed-velocity 两个夹具插件,在代理进程内扮模拟控制面,端口与凭据
直接读身份文件,不再需要外部进程或解释器。编排脚本移除 ExecHook/StopPidHook,
代理声明补上夹具 jar 与 prepare 依赖。

受影响场景 managed-config-bungee / managed-config-velocity 尚待真机验证:
本机当前无可用外网出口,mc-testkit 的 jar 解析阶段需访问远端 API,两条 via
场景都在解析阶段因 TLS 失败中止(同环境下后端场景同样受影响)。
字节码门禁、依赖树转 OSV 清单、覆盖率汇总、E2E 结果断言,原先分别由 .github/scripts 下的
两个脚本与两条 workflow 的内联脚本承担。现统一收口到 build-logic 的 serverprobe.ci-verification:
verifyBytecodeGate / generateOsvScanInput / coverageSummary / verifyE2eResults,判定阈值与失败
口径逐条保持等价,workflow 只保留 ./gradlew 调用,仓库不再需要额外脚本运行时。
四个 CI 校验任务从 doLast 脚本改为 DefaultTask 子类:输入输出全部通过 property 与 file
collection 声明,Gradle 自行推导任务依赖与增量,因而每个任务都能单独运行且可命中
UP-TO-DATE;任务内不再出现 println 与手拼路径,输出统一走 logger。

字节码门禁的受检目录改从各模块 main 源集输出接线(只含 main,且自动带入编译依赖),
发行 jar 由打包任务提供依赖,故 workflow 里那次额外的 build 已删除;覆盖率任务挂上
jacocoTestReport 依赖后可单独运行。判定语义未变:按模块判产物缺失、上限仍为 major 52。
依赖用任务提供者而非字符串路径(重命名/挪模块时会在配置期报错,而不是运行期静默不依赖);
发行 jar 路径改用 :plugin 的 layout.buildDirectory,不再硬编码构建目录名。
原用例把 ArthasDiagnosticsLifecycle 的源码读成文本、用 contains 比对某一行:改个缩进即假失败,
而「启动不阻塞启用线程」「卸载后拒绝加载」这两条语义一点没验。现改为注入捕获型调度器与计数型
加载器,直接断言调用序列与返回值(并用变异验证:把调度改回同步执行时两个用例确实变红)。
为此把调度入口、自动加载开关与加载动作收口成可替换字段,生产默认行为不变(仍走 submitAsync)。
代理与后端共用 run-proxy,下一条场景铺模板会覆盖上一条的日志,导致 mcpBridge 偶发失败时无证可查。
夹具现在在启动前与交卷时各留一份探针 config.yml 快照,并把代理日志复制到结果目录;同时把
probeConfig.exists / mcpEnabled / mcpEnabledAtStart / proxyLogExists 写进结果文件的 diagnostics.*,
失败时不必再挖日志就能区分「文件不对」与「探针读错」。
config.yml 由 TabooLib 在加载期反射注入,而 IOC 组件的装配线程可能跑在注入之前,mcp.enabled 等
开关会偶发读到默认 false:实测托管模式下 MCP 工具面因此偶发不挂上桥,控制面下发 core.mcp 得到
「未注册执行器」的失败回执。新增 ProbeConfig.awaitLoaded()(有界等注入 + 就绪后强制重载一次,超时
降级并 WARN),并让 MCP 桥、本地 MCP 端点、Prometheus 端点与 Web 面板把「读开关 + 判定」移到后台
线程、过门后再判,同时消除在启用线程上读配置的风险。
新增第 3 批两个 step:BungeeCord 与 Velocity 各一次 gradle 调用——两条场景共用
build/mc-testkit/run-proxy,合并到一次调用里后一条铺代理模板会冲掉前一条的代理日志、
结果文件也会在下一轮开始时被清空,失败时无法排障。两个场景同时加入 EXPECTED_SCENARIOS。
代理端托管场景已把 mcpBridge 断言纳入,状态与 FR-42~46 口径一致。
夹具在 WS 握手头两次分别回 401 与 403:断言两种拒绝都真实发生、被拒后不降级为文件配置
(模板里故意开启的三个监听端口在拒绝窗口内仍不可连)、控制面恢复后自动重连并跑完整托管链路。
反向验证:只回 401 + 把一个确实开启的端口混入零监听检查,能精确报出 authRejected /
noStandaloneFallback 红,证明两项判据不是摆设。CI 第 3 批新增该场景并加入 EXPECTED_SCENARIOS。
- verifyE2eResults:期望场景清单为空时硬失败。原先 `.orElse("")` 把缺清单兜成空列表,
  结果目录为空也判 PASS,CI 最后一道断言形同虚设(旧内联脚本此处是 KeyError)。属性改标
  @optional,让任务动作抛出带修复指引的错误,而不是 Gradle 的 `Value not set`。
- 后端 harness:补 managed-config-bungee / -velocity 两个场景条目并显式 NOOP(夹具与断言
  都在代理进程内)。此前后端插件加载即抛“未知 E2E 场景”,场景仍绿,导致“夹具没加载”与
  “夹具跑通”无法区分,日志里持续有插件加载失败。
零监听:模式判定未完成/异常时,原先只 WARN 就照常 bind——慢盘启动会让托管探针把
metrics/web/mcp 端口开起来,且事后没有任何回撤机制。现在按「有无托管意图」分流:
无身份文件仍按独立模式 bind(行为不变),有身份文件则 fail-close 不开端口;并把已
bind 的面登记到闸门,模式落定为托管时幂等回撤。

配置下发:config_push 跑在命令池(多线程)上,而 apply 是一段 check-then-act,
并发到达会双双通过 baseVersion 校验并互相覆盖生效值与缓存,版本号还可能倒退。
现改为串行应用,版本状态取单调 max;同时修正两处「由桥读线程调用」的错误注释。
- config_applied 的 effective 与 config_state 共用同一份脱敏;脱敏补上列表递归与超长串首尾扫描,
  不再整体跳过(列表里的 webhook 地址、超长 URL 的查询参数过去都会漏遮)
- 指标推送循环单次抛异常只跳过该轮并告警,中断退出记日志并恢复中断位(此前一次抛出即静默停推)
- BridgeCapabilities 改用并发集合并新增 removeCapability(装配线程写、桥线程取快照可能抛 CME)
- config_applied 的 dedupKey 改为 cfg:<version>:<result>,避免“先失败后成功”的回执被对端幂等丢弃;
  metric 的去重键格式未动(协议原文不在本仓),已在文档标注待与 JM 侧确认
wcpe added 18 commits November 2, 2026 00:27
- config_push / metrics_now / core.mcp 在「stop 先到、装配后到」时会残留注册:停服后指令
  仍会落到已停的执行体。三处改为同一把锁下的状态转移,停止时注销动作并撤掉 mcp_bridge
  能力位(BridgeCommandRegistry 补 unregisterCoreAction)。
- 桥客户端的模式判定与装配原先在 @PostEnable(启用线程)上同步等待,最坏叠到 9s;现整体
  下移到引导线程,启用线程只起线程。
- 连接目标改为每轮现取:旧写法只算一次,判定超时拿到空值即定论下去,托管部署(bridge.*
  已被迁移清空)会整场会话不连控制面且无重试入口;现在判定晚到即自愈。
- awaitLoaded 的重载收敛为「同一配置实例只成功装载一次」,失败不再被当作已就绪、也不缓存
  失败;被中断时恢复中断位。

验证:core 1011 用例全绿(含新增 ServiceStopRaceTest 6 例、BridgeClientStartupTest 2 例、
awaitLoaded 并发/失败 2 例);e2eManagedConfig 7/7、e2eBridgeFixture 独立模式 PASS;
gradlew build 绿。代理端 e2e 本轮未能运行——mc-testkit 下载 BungeeCord 时 TLS 握手被断,
与本改动无关。
- 零断言不再写 PASS:ScenarioResultWriter 要求至少一条断言,事件驱动场景需显式 allowZeroAssertions;
- hello 帧结构收口到 BridgeFrames.hello(生产 helloJson 委托它,测的就是生产路径),删除已漂移的
  event/playerEvent/businessEvent/commandResult 与 PROBE_BRIDGE_VERSION 死代码;
- build-logic 的 TestKit 守卫接进根 check(否则那批“证明任务真的注册”的测试永远不会在 CI 执行);
- 身份文件残留清理提到与场景无关的准备步骤,避免非托管场景以托管模式启动;
- 托管缓存装载逐键过档位与边界(坏键丢弃 + WARN,不整份丢弃);
- 迁移备份与身份文件同口径收权限;身份已存在且与 bridge 段不一致时保留现状并告警,不再静默删段。
重连退避:连上过就回到初始值(稳定运行数小时后再掉线不再直接吃满上限),并给初始值与休眠加下界。覆盖率汇总:报告与依赖改按「应用了 jacoco 的子项目」与 JacocoReport 类型接线,不再靠任务名猜;无报告改为 WARN;删掉口径不一致且无人调用的 reportFiles。零监听断言:夹具改为从探针 config.yml 读三个监听面端口,读不到直接失败,不再与模板各持一份常量。托管代理模板的一次性端口与凭据移到任务动作里生成。OSV 清单:解析为空改为硬失败。e2e 预构建补齐新增夹具 jar;UplinkOutcome.Sent 语义改为已入队,MetricPusher 断连时清理历史失败原因。
停机与回撤:
- 桥客户端新增停机标志与引导线程有界 join:stop 先到时装配自我放弃,避免停机后仍有
  四条线程在跑、序号落盘与落盘 flush 被跳过;装配在建资源前后各复查一次。
- 零监听回撤:回撤先置位、登记后复查,回撤与登记交错时当场关闭该面(否则该端口永久开着);
  三个监听面按返回值决定是否宣告“已启动”。
- 其余:resolveInstance 热路径不再每帧探取、判定异常路径先写意图再 reset、迁移备份建文件
  即 0600、推送循环区分中断并恢复中断位、harness 通过默认要求至少一条断言。

重连退避:
- waitMs 只取下界,降频值不再被 maxBackoffMs 夹回(认证失败 60s 降频此前实际只睡 30s)。
- 复位增加“连接存活 ≥ max(2×初始值, 10s)”门槛,避免对端接受即关闭时退化成 1s 热重连。

顺带按 detekt 提示抽出 BridgeEndpointUris(URI/authority 纯函数)与 declareCapabilities,
避免以 @Suppress 掩盖体量与复杂度问题。
指标按需推送的回执由"已推送"改为"已入队"(Sent 只代表帧被接纳,被滤镜丢弃也算 Sent);
E2E 结果门禁的空清单失败、零监听 fail-close、config_push 并发、effective 脱敏、推送线程兜底、
三处假通过与迁移备份凭据保护等修复补进 CHANGELOG;managed-mode 新增「零监听的失败方向」一节
(有身份文件却判定超时则不 bind、独立模式行为不变、模式晚到则回撤),并把自动迁移触发条件的取舍
挪进待定表;夹具的 YAML 读取器合并为 probeConfigValue(content, key, section) 单一实现;
托管场景的端口/凭据/身份 JSON 复用同一组 helper(并统一 token 风格);
覆盖率汇总不按任务名猜报告、应到/实到不等即告警;e2e 接线在配置期校验任务名存在。
taboolib-ioc 1.3.1 已根治「单 jar 多平台引用它平台类 → NoClassDefFoundError 从
LOAD 阶段冒泡 → 整条注入链中断 → 插件 onEnable 自我禁用」,并把 @PlatformSide 判定
前移到 scan 之前。本项目的 ioc/scan/ComponentVisitor 及其两个契约测试是为绕过这个缺陷
而写的补丁,现在可以删掉,改用上游实现 + 编译期索引(运行期零成员枚举)。

同时修复 Arthas 在索引路径下的两处时序问题:

1. autoLoadScheduler 由 submitAsync 改为裸守护线程。
   @PostEnable 跑在 Taboolib ENABLE 阶段(优先级 -80),早于平台执行器可用;
   那里调 submitAsync 不报错、但任务体永远不执行 —— 真机现象是「已提交异步自动加载任务」
   之后没有任何后续日志,解包目录也从未创建,与「从来没调过加载」完全一样。
   裸守护线程不依赖平台服务,与 BridgeClient 在同一阶段起网络线程的做法一致。

2. 不在此处读 mcp.enabled,推迟到异步任务执行时再读。
   该配置依赖 @config 注入;走索引路径时本 Bean 的 @PostEnable 可能排在插件启用主流程
   之前,彼时 conf 尚未注入,mcpEnabled() 会静默取到默认 false —— 不报错、但 Arthas
   永不自动加载。推迟判定同时消除了对框架执行顺序的隐式依赖。

另:gradle.properties 的 version 此前被误改为 0.2.1(把 0.8.0-SNAPSHOT 降低),已还原。
## 一、落盘兜底默认改为关闭

本通道的语义约定是「不可靠 / 幂等 / 不堵塞」,而落盘与这三条都相反:

- 与幂等重复:业务事件带 dedupKey,重复到达不会重复生效;断线窗口丢的事件
  由重连后的全量同步补齐(重放安全)。落盘只是把「重连后重新拉一遍」换成
  「从磁盘回放」,语义上没多出任何保证。
- 与不堵塞冲突:spill.append() 跑在发送线程的 write() 里,是同步磁盘写。
  常见部署是机械盘(ROTA=1),断线时每条可靠事件都要等一次寻道,
  发送线程被拖住 → 队列迅速回满 → 回执也会跟着挤不上。
- 收益极小、风险集中:落盘只在「已断线」时发生,而那段窗口本来就会丢;
  保留它等于用「不堵」换「可能会丢的少量事件」,与需求相反。

关闭后的降级行为(纯内存,不碰磁盘):
  · 可靠事件队列满 → reliableRejected++ 并返回 false,调用方自决是否重试;
  · 可丢事件队列满 → bestEffortDropped++ 直接丢弃;
  · 命令回执队列满 → commandResultRejected++(读线程走非阻塞 offerReceipt,绝不阻塞)。

需要的宿主仍可在 config.yml 显式 bridge.line.spill-enabled: true 打开。
测试无回归:现有用例都显式传 spillEnabled,不依赖默认值。

## 二、新增 e2e/harness-bridge-load 压测桩(外置插件)

### 为什么外置而非给 ServerProbe 加命令
1. 不动被测物:给产品代码加压测入口会改变它自身的类装载/初始化顺序,
   压在「被改过的探针」上得出的结论不可靠。
2. 证据链闭合:只依赖 :api(compileOnly),以第三方插件身份调公开 API,
   它能把事件发出去就证明「公开 API → 桥」这条链路外部真实可用。
3. 发布包干净:压测代码不会因「忘关开关」漏进发行 jar。

### 部件
- LatencyHistogram:指数分桶分位数(p50/p95/p99/p999),无锁 AtomicLongArray。
  不存原始值再排序——压测动辄几十万条,那样压测器会先被自己的度量拖死。
  取桶上界作估值:宁可略高,不可报低,后者会掩盖尖刺。
- BridgeLoadRunner:多线程注流量,每线程局部计数后合并,避免压测器自己的
  CAS 竞争变成瓶颈、污染测量结果。
- BridgeLoadHarnessPlugin:/bridgeload <count> [threads] [maxInFlight],
  异步执行(压测留在主线程会把服务器卡死,测出的数字毫无意义)。
  压测域 probe.loadtest 与真实业务隔离,JM 侧可按域过滤。

### 测试里记下的两个坑
1. 首版桶索引写成 (subIndex - subBucketCount),对 micros ∈ [1024,2048)
   会算出负索引,长尾落错桶 → p99 报 50µs 而真值 20ms。这是分位数最危险的
   失败模式:不报错,只是让「链路已经堵了」显示成健康。
2. 修好实现后仍剩 2 个失败,查下来是**测试期望写错**:10000 样本里 9900 个
   是 50µs,rank(p99)=ceil(0.99*10000)=9900 恰好仍落在快样本上,
   那 1% 慢请求属于 top 1%,应在 p99.9 体现。分位数的 rank 边界极易写错,
   期望值写错会让「实现正确」被误判为 bug,反之也可能掩盖真 bug。

### 实测(lobby-1)
| 流量 | 受理 | 拒绝 | 接受率 | 吞吐 | p50 | p95 | p99 |
|---|---|---|---|---|---|---|---|
| 1000/4线程 | 1000 | 0 | 100% | 540376/s | 0.003ms | 0.007ms | 0.016ms |
| 20000/8线程 | 16120 | 3880 | 80.6% | 61096/s | 0.061ms | 0.114ms | 4.431ms |

p99 从 0.016ms 跳到 4.431ms(277 倍),拐点在万级事件量。
桥(业务通道)此前只有 bridge_status 的瞬时值可看,回答不了「下午那次高峰
到底堵没堵」。把通道自己的队列深度与拒绝计数随指标帧一起上行,JM 侧才有
历史曲线。

- MetricSnapshotFrame.appendBridge:8 个字段(可靠/可丢队列深度、可靠拒绝、
  可丢丢弃、回执被拒、落盘挤掉、写失败、写超时)
- MetricPusher 新增 senderStatus 参数,默认 { null } 保持旧行为;
  读取广捕降级——桥状态读不到不该阻断整帧上报(TPS/堆仍值得上报)
- MetricPushService 注入 bridge.senderStatus()
- BridgeClient.senderStatus():刻意不拿锁,心跳线程每周期都调

计数传**累计值**而非速率:推送间隔可变,探针侧算速率会把「间隔变化」
误读成「流量变化」,差分交给 CP 的固定心跳节奏。
真机验收发现:jfr_start 成功(`Started recording 1`)、`jfr status` 也能看到
`recording=1 name=Recording-1 (running)`,但 jfr_stop 恒报
`Failed to stop. please input recording id`——start 能起、停不了,产物恒空。

根因:Arthas 的 JFRCommand 有两个**独立**参数,`recording`(Long,目标 id)
与 `filename`(String,落盘路径)。旧实现只传 `--filename`,Arthas 无从判断
该停哪一个。从 arthas-core 4.3.2 的 JFRCommand.class 字段声明确认了这点。

同时修掉引号问题:命令串不是 shell,`'path'` 里的引号会被当作文件名的一部分
传下去。

recording id 由 JVM 分配(从 1 递增,不可预测),而 ArthasControl 只有异步接口
(submit 返回 taskId,输出要另轮询),stop 时无法可靠地同步读 start 的输出。
因此在 start 时自行记账,stop 时引用;调用方也可用 recordingId 显式覆盖
(多 recording 并存场景)。

测试从「包含 jfr stop」升级为断言必须含 `--recording`、路径不得带引号,
并新增 recordingId 覆盖用例——旧断言正是没能拦住这个问题。
## vmtool 缺 native 库 → 内置

真机报 `no ArthasJniLibrary in java.library.path`。查 arthas-core 4.3.2 的
VmToolCommand 静态初始化:它从 **arthas-core.jar 所在目录下的 lib/<平台库名>**
定位(File(codeSourceLocation).resolve("lib").resolve(name)),官方发行包
arthas-packaging 的 lib/ 里本就有这个库。我们的运行闭包只展开了 4 个 jar 和
async-profiler,lib/ 整个漏掉了。

选择内置而非安装:探针承诺「事故现场不发生运行期下载」,且部署环境未必有
网络。库仅 44KB,进 jar 代价可忽略。按官方布局落到 lib/ 子目录,构建期与
运行期两处都补上:打包 extractArthasRuntime、运行期 ArthasRuntimeExtractor,
并校验 sha256、把发行校验升为必需项(缺库直接构建失败,不再静默降级)。

## CPU 归因默认开启

原先默认关,等于「事故现场永远没有归因数据」——事故不会提前打招呼。
采样在异步线程做 dumpAllThreads + 栈帧归并,开销可控。需要省开销的部署
可显式设 cpu.enabled: false。

## dumpClassBytes 失败可诊断

补丁前自动备份依赖 Instrumentation.getBytecodes,原先整段 runCatching 吞异常,
返回 null 时上层只看到「控制器未提供备份能力」,分不清是没 Instrumentation、
类没加载、还是 JDK 8 没这个方法。现在逐步告警。

注:ArthasStartupAsyncContractTest 的失败为既有问题(改动前后均复现),
与本提交无关。
## JNI 库升级路径失效

上一版把 libArthasJniLibrary-x64.so 打进了 jar,但真机升级后 lib/ 仍不出现。
根因:isVerified 只校验四个 jar,requiredLibraries() 没把 JNI 库算进去,
于是「已有解压目录的 jar 哈希全中 → 跳过解压」——新增的库永远落不了盘。

修:requiredLibraries 并入 vmToolLibraries,让缺库的旧目录必然校验失败、
触发重解;同时补了针对这条路径的防回归测试。

附带修一个静默失效:清单键是相对路径(lib/xxx.so),而 copyResources /
isVerified 取值时用的是裸文件名 expected[name],恒为 null,等于没校验。

## dumpClassBytes:getBytecodes 根本不存在

真机实证 Instrumentation.getMethod("getBytecodes") 恒抛 NoSuchMethodException。
java.lang.instrument.Instrumentation 只有写入接口(addTransformer /
retransformClasses / redefineClasses ...),**没有任何读取字节码的入口**。

旧实现建在这个错误假设上,所以「补丁前自动备份」从来没生效过——日志一直报
「当前 Arthas 控制器未提供字节码备份能力」,而 redefine 照常执行,等于裸奔。

改走 ClassLoader.getResourceAsStream("a/b/C.class"):经 allLoadedClasses
定位目标 Class(优先非 bootstrap 加载器),用它自己的加载器读资源。
拿到的是该加载器眼里的源字节码——对「回滚到官版」这个目的正合适。
根因不是性能,是语义用反了:loaderSummary() 把**插件名**传给 ownerOf(),
而 ownerOf 的入参是**类名**。于是对每个插件都执行
loadClass("AllinCore") → 必然失败 → 遍历全部 ClassLoader 逐个试加载
→ 把「无归属」写进缓存。60+ 插件时每次枚举就是 60+ 轮全量 CL 遍历。

改成 PluginClassLoaderRegistry.loaderOf(name):插件名 → 加载器是正向 map
查表,O(1)。注册表本来就有这个 map,只是没对外暴露。

没有改成异步——异步化只是给错误的实现加复杂度;让它真的在 5 秒内跑完
才是对的。加了回归测试断言两个方向的语义不可互换。
JFR recording 是**进程级共享**的,id 由 JVM 单调递增分配。早先用
「本工具 start 过几次」推断 id——一旦有人或其他工具也 start 过,
计数就错位,jfr_stop 会停到别人的 recording 上。

现在 start 前先读 jfr status,解析 recording=(\d+) 取最大值 +1。
读不到(控制台不支持同步执行、超时、输出格式变化)才退回自增计数——
宁可 id 可能不准也不阻断采集,且调用方仍可用 recordingId 显式覆盖。

为此给 ArthasControl 加了 executeSync(command, timeout):异步 submit 只能
拿 taskId、还要轮询 output,对「先读当前状态再决定下一步」这种短查询太绕。
实现复用同一个 runner(它本身就是同步的),包 FutureTask + 超时保护。
ArthasStartupAsyncContractTest 的「启动期只把加载任务交给异步调度器」一直失败
(expected: <1> but was: <0>)。查下来不是实现没异步化,是测试缺隔离:

start() → autoLoadScheduler{ autoLoadAfterStartup() }
       → awaitConfigReady() → ProbeConfig.awaitLoaded()   ← 这里
无平台环境下 conf 永远注入不了,awaitLoaded 会空转满 5 秒再返回 false,
于是 loadAfterStartup() 根本不执行,loadCount 恒为 0。

测试原本只打桩了 autoLoadGate,没打桩配置就绪判定。加 configReadyGate 接缝
(默认仍走 ProbeConfig.awaitLoaded(),生产行为不变),测试里打桩为 true。

没有改断言让它变绿——补的是缺失的隔离。
上一提交给 ArthasControl 加了 executeSync、也在 ArthasTaskManager 里实现了,
但漏了在 ArthasControlRegistry 里重写。core 注入给 provider 的正是 Registry,
于是它落到接口默认实现 `= null`——nextRecordingIdAfterStart() 每次拿到 null,
静默退回自增计数,等于那个「以 JVM 真实状态取 id」的修复在生产路径上从未执行。

这类漏转发的隐蔽性在于:接口有默认实现兜底,编译不报错、运行不抛异常,
调用方只是安静地拿 null 降级,看起来像「能力未提供」。

补了测试断言 Registry 会转发 executeSync,并顺带核对了接口 7 个方法
(submit/status/output/cancel/retryAttach/dumpClassBytes/executeSync)全部有转发。
dumpClassBytes 上轮也踩过同一个坑,现在两条都有回归防线。
全量跑测试时 McpAuditTrailTest 的「过期审计归档自动删除」偶发失败,
单跑却通过。查下来不是测试不稳,是产品缺陷:

归档名原本只用 System.currentTimeMillis(),而 Files.newOutputStream 默认
CREATE + TRUNCATE_EXISTING。同一毫秒内轮转两次 → 同一路径 → 后一次直接
覆盖前一次,**审计数据静默丢失且不报错**。全量跑时机器负载高,30 条记录
更容易密集撞同一毫秒,所以测试快照里的归档路径指向了被覆盖后的新文件
(mtime 未回拨),过期删除断言就失败了。

修法:uniqueArchivePath() 用 Files.createFile 做原子占位(冲突抛
FileAlreadyExistsException),冲突就递增后缀 -1/-2… 重试,上限 64 次防死循环。
写内容用 WRITE + TRUNCATE_EXISTING 覆盖占位文件(不能用 CREATE_NEW,
文件已存在会抛异常——这一点我第一版写错了,测试立刻抓出来);
写失败则删掉占位,不留空归档。

回归测试「同一毫秒内连续轮转不会覆盖已有归档」:maxFileBytes = 1 让每条
记录都触发轮转,断言归档数 ≥2、无空文件、文件名不重复。

教训记在这:单测能过不等于不是 bug。全量与单跑的差异(并发、负载、
时序密集度)恰恰是暴露这类覆盖/竞态缺陷的窗口。
- MetricPusher.readSenderStatus:捕获异常后返回 null 是设计决定(桥字段缺失
  只让 CP 侧曲线断点,不能因此丢掉整帧 TPS/堆/线程),补 SwallowedException
  抑制与注释说明「为什么可以吞」,而不是放宽规则或改窄 catch 类型掩盖意图。
- McpAuditTrail:catch (Throwable) 改为 catch (IOException)。这里是文件轮转
  写归档,失败类型只可能是 IO;Throwable 会把 OOM、ThreadDeath 一并当成
  「写失败删占位」,语义过宽。
## 问题

`CiVerificationTasksTest > 期望场景缺结果文件时构建失败` 长期失败:任务确实
FAILED 了,但输出里没有场景名,断言 `output.contains("no-such-scenario")`
落空。本地复现拿到真实原因:

```
Input file does not exist
  property 'resultsDirectory' specifies directory '.../build/mc-testkit/results'
  which doesn't exist
```

`resultsDirectory` 声明成 `@InputDirectory`。它标了 `@Optional` 只是允许「值没
设」,一旦值被设上(这里由 convention 给出),Gradle 就要求目录真实存在。而
「所有场景都在服务端启动阶段挂掉」恰恰是**目录根本不会产出**的那条路径——
于是判定逻辑一行都没跑,构建在输入校验阶段就死了,既报不出缺哪个场景,也让
这段「缺结果即失败」的守卫在最该生效的时候失效。任务自己的 KDoc 写着「结果
目录允许不存在」,但声明方式与这份意图矛盾。

## 改法

输入快照改用 `@InputFiles` 文件集合(`resultsFiles.from(resultsDirectory)`):
目录不存在时解析为空集合,任务照常执行,由动作报出「[缺失] 未找到结果文件: X」
后失败。`resultsDirectory` 降为 `@Internal`(只用于动作里定位文件),增量与
缓存判定仍由文件集合驱动,UP-TO-DATE 行为不变(对应用例已 PASSED)。

验证:
- `./gradlew verifyE2eResults -PexpectedScenarios=no-such-scenario` →
  `[缺失] 未找到结果文件: no-such-scenario` + BUILD FAILED(修前是输入校验报错)
- `CiVerificationTasksTest` 该用例本地 PASSED
@wcpe wcpe changed the title feat: 外部控制面托管模式(FR-42~47) feat(core): 新增外部控制面托管模式与桥指标上报 Nov 3, 2026
@wcpe
wcpe merged commit 11c379a into master Nov 3, 2026
15 checks passed
@wcpe
wcpe deleted the feat/managed-mode branch November 3, 2026 22:19
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