fix(kether): 修复异步上下文完成与线程切换#706
Conversation
确保动作异常与取消能够结束上下文并回收运行任务,同时序列化远程读取并将计分板更新切回平台线程。
FxRayHughes
left a comment
There was a problem hiding this comment.
Code Review:PR #706 fix(kether): 修复异步上下文完成与线程切换
修复的底层问题是真实的,thenRun → whenComplete 和 ActionScoreboard 的改动都切中要害。但为了线程安全引入的 synchronized 造成了新的、后果更严重的问题,建议在合并前解决。
说明:以下结论基于源码路径推导,相关行号已逐一核对;死锁未实测复现。
🔴 严重问题(建议阻止合并)
1. 父子 frame 锁顺序反转 → 主线程 ABBA 死锁
文件: module/minecraft/minecraft-kether/src/main/java/taboolib/library/kether/AbstractQuestContext.java
PR 给 SimpleNamedFrame 的 run() / process() / resume() / close() 全部加了 synchronized。由于父 frame 持有子 frame 引用,形成两条方向相反的锁依赖:
路径 A(终止方向,父 → 子)
terminate() → rootFrame.close() [持父锁]
→ 遍历 this.frames
→ childFrame.close() [需子锁]
路径 B(完成方向,子 → 父)
子异步动作完成 → child.resume() [持子锁]
→ 完成子 resultFuture
→ 同步触发父注册的 actionFuture.whenComplete
→ parent.resume() [需父锁]
已核实的支撑事实:
newFrame()将子 frame 加入this.frames(AbstractQuestContext.java:135、:148)AbstractFrame.close()遍历this.frames逐个frame.close()SimpleNamedFrame.close()在本 PR 中被标记synchronizedrootFrame就是SimpleNamedFrame(AbstractQuestContext.java:31)
两条路径并发即死锁。触发场景很常见:/reload、插件禁用、Workspace.cancelAll() 执行时,恰好有异步动作(数据库查询、网络请求)在别的线程完成。路径 A 通常跑在主线程上,死锁 = 服务器冻结。
这等于把"脚本挂起"换成了"服务器挂死",后果比原 bug 更严重。
2. 持锁期间调用第三方插件代码
文件: AbstractQuestContext.java(SimpleNamedFrame.process)
process() 是 synchronized,内部直接 action.process(this) 执行任意插件代码。两种自锁路径:
- 动作内
newFrame().run():在持父锁时获取子锁,重现问题 1 的锁序 - 动作内同步等待任何需要同一 frame 锁的操作:直接自锁
持锁调用外部回调是公认的反模式,它也大幅放大了问题 1 的触发窗口。
建议与问题 1 一并重新设计——例如改用无锁状态机(CAS + 状态字段),或把 future 完成回调移出锁外执行。
🟡 中等问题
3. ExitStatus 的三种语义被统一当作"成功"
文件: AbstractQuestContext.java(SimpleNamedFrame.process / completeResult)
ExitStatus 有三种含义:success()(正常结束)、paused()(被暂停/终止)、cooldown(timeout)(挂起等待)。
旧代码 while (!context().getExitStatus().isPresent()) 退出循环后不完成 future——这确实是 #703 报的 bug。但新代码一律走 completeResult(),用最后一个动作的返回值正常完成。
问题在于 Workspace.terminateScript() 设置的是 ExitStatus.paused()(Workspace.kt:103)。改动后,被强制终止的脚本会以成功状态完成,调用方拿不到任何"我是被中断的"信号。
建议按 status.isRunning() 分流:非正常结束走 cancel(false) 或 completeExceptionally。
4. AbstractFrame.close() 改为主动 completeExceptionally
文件: AbstractQuestContext.java(AbstractFrame.close)
对 SimpleActionFrame 而言,this.future 是动作自己创建并持有的对象(actionFuture DSL 就是把 future 交给用户代码稍后完成)。frame 关闭后,用户代码后续的 complete() 调用会静默失效。
方向是对的(终止就该让等待方收到终态),但属于行为变化,建议写入兼容性说明。
5. RemoteQuestReader 的 @Synchronized 锁错了对象
文件: module/minecraft/minecraft-kether/src/main/kotlin/taboolib/module/kether/RemoteQuestReader.kt
@Synchronized 锁的是 RemoteQuestReader 实例,但真正被共享的读取游标在 source 上。若两个 Reader 包装同一个 source,两者之间完全不互斥,竞态依然存在。
应改为 synchronized(source) { ... }。现有测试只构造了单个 Reader,覆盖不到这一情形。
🔵 小建议
计分板更新从"立即执行"改为"下一 tick 执行"。线程安全的必要代价,但同一 tick 内多次 scoreboard 动作的可见顺序可能改变,建议在说明中提一句。
🟢 确认正确的改动
| 改动 | 评价 |
|---|---|
process 循环退出后完成 future |
修复真实的永久 pending |
异步动作 thenRun → whenComplete |
核心修复:thenRun 在异常时不执行,导致 frame 永久挂起 |
动作同步抛异常走 fail() |
异常不再逃逸到调用栈,调用方能拿到终态 |
SimpleActionFrame.run() 捕获同步异常转失败 future |
保证总返回可观察的 future |
Objects.requireNonNull(action.process(...)) |
明确报错,替代后续位置的莫名 NPE |
解包 CompletionException 后区分 CancellationException |
正确区分"取消"与"异常"语义 |
contextFuture 取消反向传播到 frameFuture |
取消语义双向打通 |
exitStatus / future 改 volatile |
跨线程可见性 |
ActionScoreboard actionNow → actionTake |
旧代码 completedFuture(func(frame)) 让动作立即"完成",既不等计分板更新也丢弃异常——真实 bug |
submit { } 把 Bukkit/NMS 调用封送回主线程 |
满足 Bukkit/Folia 线程所有权要求 |
body.isEmpty() 分支 |
顺带修了空集合时的 IndexOutOfBoundsException |
测试用 fake Quest/Block/Context 注入 |
无需服务端环境即可测状态机,设计得好 |
总结
问题 1 建议在合并前解决——它用"主线程死锁"替换了"脚本挂起",触发条件(reload/禁用时有异步动作完成)在生产环境不罕见。问题 2 与之同源,宜一并重新设计并发模型。问题 3、5 是正确性问题,建议同批修复。
原有问题
Kether 异步上下文在异常、取消和线程切换方面存在三类问题:
RemoteQuestReader的读取游标和反射状态可被多个线程同时访问,缺少串行化保护。典型触发场景与后果
本 PR 修改
AbstractQuestContext中统一传播动作的同步异常、异步异常和取消,并停止后续动作、反向取消仍在运行的 Future。RemoteQuestReader的反射读取状态,避免共享游标竞态。修改目的
保证 Kether 脚本无论成功、失败还是取消都能可靠结束,同时遵守 Bukkit/Folia 的线程所有权要求,避免脚本挂死和跨线程平台访问。
兼容性与行为变化
验证
./gradlew :module:minecraft:minecraft-kether:test --rerun-tasks --no-parallel./gradlew :module:minecraft:minecraft-kether:build --rerun-tasks --no-parallelgit diff --checkRefs #703