fix(platform): 修复生命周期终态与任务清理#715
Conversation
Code Review — #715 fix(platform): 修复生命周期终态与任务清理这个 PR 挖出了几个存在很久、影响很实际的 bug,其中两个我实测复现了。质量上和 #713 一个水平。 最有价值的三处发现:
有几处破坏性变更需要进兼容性说明,另有一处 Velocity 的 审阅方式:读 patch + 对照源码逐条验证 + 实测(Velocity API 3.1.1 jar 反编译、reflex 双构造器行为、CME 复现)。未实跑 gradle 测试。 🟢 先说三个值得单独点出的真 buga. fun unregister(name: String) {
commands.find { it.command.aliases.contains(name) } ?: return
unregister(name) // ← 调自己,不是 unregister(Command)
}第一行的 顺带一提,这个 bug 还掩盖了另一个问题: b. override fun unregisterCommands() {
commands.forEach { unregister(it) } // unregister → commands.remove(it)
}
注意单元素时不抛( c. public static void lifeCycle(LifeCycle lifeCycle) {
if (isStopped) {
return; // ← DISABLE 也被拦住
}而 改成 🟡 问题 1 — Velocity 的
|
| 项 | 结论 |
|---|---|
EventTask.resumeWhenComplete 可用性 |
本机反编译 velocity-api-3.1.1.jar 确认存在该静态方法,签名 (CompletableFuture<?>) -> EventTask,与用法匹配 |
EventManager.fire 返回 future |
确认签名 <E> CompletableFuture<E> fire(E),旧代码丢弃返回值属实,"call() 读不到异步结果"是既有问题而非本 PR 引入 |
| reflex 双构造器选择 | 实测两种形态(public+public、private+public)下 newInstance() 均正确选中无参构造器,AppExecutor / VelocityExecutor 的构造器重构安全 |
unregisterCommands CME |
实测 LinkedHashSet 边遍历边删确实抛 ConcurrentModificationException;单元素时不抛,解释了为何长期未被发现 |
| Application 缺失 ACTIVE | App.java:49-52 确认只有四个阶段;ClassVisitorSchedule.getLifeCycle() 返回 ACTIVE,所以 @Schedule 在该平台一直失效。修复属实 |
isStopped 拦住 DISABLE |
TabooLib.java:66-68 确认;6 个平台(bukkit/bungee/velocity/afybroker/application/hytale)的 onDisable 全部调 lifeCycle(DISABLE),全部受影响 |
setStopped 已存在 |
TabooLib.java:170,测试用它切换状态不需要新增 API |
| 生命周期任务优先级顺序 | registerLifeCycleTask 用 Comparator.comparingInt(LifeCycleTask::priority) 升序排序,优先级 2 的 stop() 会在默认优先级 0 的清理任务之后执行,顺序正确 |
AppLifeCycle 状态机单向性 |
NEW → INITIALIZING → ACTIVE,shutdown 在 INITIALIZING 时设 STOP_REQUESTED,由 finishTransition 在当前阶段结束后转入 DISABLING。不存在从 DISABLING/DISABLED 回退到 ACTIVE 的路径 |
AppLifeCycle 的 transitionRunning 语义 |
beginTransition 在状态非 INITIALIZING 时返回 false 中断循环;finishTransition 检测 STOP_REQUESTED 并转 DISABLING。关闭请求落在阶段执行中间时,会等当前阶段结束再 disable,不会打断半个阶段 |
AfyBrokerActiveGate / VelocityActivationGate |
OPEN → ACTIVATING → CLOSED 单向 CAS;close() 在 OPEN 时直接完成 future,在 ACTIVATING 时返回未完成的 future 让 disable 等待 ACTIVE 跑完。避免了 DISABLE 先于 ACTIVE 完成导致的倒退 |
activate 的 finally |
state.set(CLOSED) + activationClosed.complete(null) 在 finally 中,即使 action 抛异常也会释放等待方,不会让 disable 永久挂起 |
ACTIVE 内的 isStopped 二次检查 |
activate 的 lambda 开头 if (TabooLib.isStopped()) return,防止 gate 打开但插件已停止时仍触发 ACTIVE。与 gate 形成双重保护 |
| DISABLE 幂等 | AfyBroker 用 AtomicBoolean disabled CAS;Velocity 用 AtomicReference<CompletableFuture> disableFuture CAS;Application 用 AppLifeCycle 状态机。三者都保证 disable 只执行一次 |
| 用户回调异常不阻断 DISABLE | 三个平台都是 try { pluginInstance.onDisable() } catch { failure = ex } 后继续执行 TabooLib.lifeCycle(DISABLE),再统一 rethrow。用户代码抛异常不会跳过框架清理。这个顺序是对的 |
commandLabelMatches 的 namespace 处理 |
有 : 时先校验前缀等于 plugin.name.lowercase(),不匹配直接 false;无 : 时按 label 比对主名与 alias,全部 ignoreCase。逻辑正确 |
removeMappingsByIdentity 用引用比较 |
filterValues { it === target },按 PluginCommand 实例身份删除,不会误删其他插件注册的同名命令。这是关键——按字符串删会伤到别人 |
| 同名重注册前清理 | registerCommand 里先 filter { it.structure.name.equals(command.name, ignoreCase = true) } 并 unregisterBinding,解决了 reload 后旧映射指向失效插件的问题 |
| 注册与身份记录的原子性 | knownCommands 写入、pluginCommand.register(commandMap)、registeredCommands.add、registeredCommandBindings.add 全部在同一个 synchronized(commandLock) 内,不会出现"命令已注册但没记录"的中间态 |
submit(now = true) 与 #712 的兼容 |
registerCommand 用的是 submit(now = true),而 #712 新增的 Folia 检查是 !now && !async,now = true 不会命中。两个 PR 在这一点上不冲突 |
AppCommand.matches 修复了主名匹配 |
旧代码用 it.command.aliases(CommandStructure.aliases,不含主名),新代码用 Command.aliases(含主名)。顺带修了按主名注销匹配不到的问题 |
CopyOnWriteArraySet 替换 |
AppCommand.commands 改为 CoW,配合 removeIf / clear,消除了 CME 与并发读风险 |
suggest 的 startsWith(ignoreCase) |
补齐了大小写不敏感,与 matches 保持一致 |
AfyBrokerTaskCancellation.bind 幂等 |
check(current == null || current === value) 允许重复 bind 同一实例(cancel() 里会再 bind 一次),不同实例才抛。设计正确 |
| 延迟绑定的取消 | bind 时若已 cancelled,立即调 cancelDelegate(value)。解决了"任务还没拿到 ScheduledTask 句柄就被取消"导致的取消丢失 |
BrokerPlatformTask.cancel 幂等 |
新增 AtomicBoolean cancelled CAS,重复 cancel 只执行一次 close |
| 测试覆盖 | 新增 6 个测试文件:DISABLE 放行、AfyBroker 执行器生命周期(249 行)、Velocity 激活门、Velocity 事件、Velocity 执行器、Bukkit 命令注册表、Application 平台。覆盖了状态机、幂等、延迟绑定、拒绝路径 |
总结
三个真 bug(Application 缺 ACTIVE 导致 @Schedule 一直失效、AppCommand.unregister 无限递归、isStopped 拦住 DISABLE 导致资源泄漏)都是长期存在且影响实际功能的,修得对。激活门 + DISABLE 幂等 + 用户回调异常不阻断框架清理这三层设计是处理生命周期终态的正确形态。Bukkit 命令按实例身份清理全部映射也修得完整。
建议处理:
- 问题 4(Application 补 ACTIVE)——影响面最大,兼容性说明里应明确"此前
@Schedule/@Awake(ACTIVE)在该平台不执行,现在会执行"。 - 问题 1(Velocity
@Subscribe迁移)——e(ProxyShutdownEvent)已不再被触发,注释说"保留旧同步入口"容易误解,建议说明或删除。 - 问题 3(停止后拒绝)——确认 DISABLE 阶段自身的清理代码不会撞上
RejectedExecutionException,并写进兼容性说明。 - 问题 2(
call()快照语义)——建议在 KDoc 里写明可靠性边界并指引callAsync()。 - 🔵 a(三份等价状态机可抽公共)和 c(AfyBroker 同步/异步分支异常处理不一致)建议一并考虑。
说明:本次审阅未实跑 gradle 测试(含 PR 描述列出的两条命令)。以下为本机实测:
velocity-api-3.1.1.jar反编译确认EventTask.resumeWhenComplete与EventManager.fire签名;reflex 1.2.4 双构造器newInstance()选择行为;LinkedHashSet边遍历边删的 CME 复现。其余结论基于 patch 与仓库源码推导,已逐条注明依据位置。
原有问题
平台生命周期和执行器缺少明确的终态,异步启动、关闭与任务句柄绑定之间可能发生倒退或遗漏清理:
ACTIVE,关闭期间仍可能收到迟到的启用回调,生命周期从 DISABLE 倒退到 ENABLE/ACTIVE。典型触发场景与后果
本 PR 修改
ACTIVE生命周期,并以单向终态状态机阻止关闭期间发生生命周期倒退;停止标记下仍允许执行 DISABLE 清理。EventTask串联异步关闭,保留旧同步入口,并为自定义事件增加callAsync()以暴露完成和异常状态。PluginCommand身份清理 Bukkit 主名、别名、namespace 及内部绑定,同名重注册前移除旧映射。修改目的
让所有平台生命周期只向前推进,并确保插件停止后不再产生新任务或残留注册;异步事件和关闭流程的异常必须能够被调用方观察。
兼容性与行为变化
验证
./gradlew :common:test :platform:platform-application:test :platform:platform-bukkit-impl:test :platform:platform-velocity:test :platform:platform-velocity-impl:test :platform:platform-afybroker:test --rerun-tasks --no-parallel./gradlew :common:build :platform:platform-application:build :platform:platform-bukkit-impl:build :platform:platform-velocity:build :platform:platform-velocity-impl:build :platform:platform-afybroker:build --no-parallelgit diff --check upstream/dev/6.3.0...HEADRefs #703