Skip to content

fix(kether): 修复异步上下文完成与线程切换#706

Open
zhibeigg wants to merge 1 commit into
TabooLib:dev/6.3.0from
zhibeigg:fix/703-kether-async-context
Open

fix(kether): 修复异步上下文完成与线程切换#706
zhibeigg wants to merge 1 commit into
TabooLib:dev/6.3.0from
zhibeigg:fix/703-kether-async-context

Conversation

@zhibeigg

@zhibeigg zhibeigg commented Jul 13, 2026

Copy link
Copy Markdown
Contributor

原有问题

Kether 异步上下文在异常、取消和线程切换方面存在三类问题:

  • 动作同步抛错、返回的 Future 异常完成或被取消时,Quest frame 没有完整传播终态,后续动作与父上下文可能继续等待。
  • RemoteQuestReader 的读取游标和反射状态可被多个线程同时访问,缺少串行化保护。
  • 计分板动作可能从 Kether 的异步执行线程直接访问 Bukkit/NMS 对象。

典型触发场景与后果

  • 脚本中的异步动作访问数据库、网络或用户代码并失败:脚本 Future 永久 pending,调用命令或任务无法结束。
  • 嵌套 frame 被取消后,子 Future 仍运行或后续动作继续执行:产生重复副作用和无法回收的任务。
  • 多线程复用远程脚本 Reader:读取位置互相覆盖,可能解析出错误 token 或抛出随机异常。
  • 异步 Kether 动作更新计分板:在 Bukkit/Folia 下触发异步线程访问异常,严重时导致状态竞态。

本 PR 修改

  • AbstractQuestContext 中统一传播动作的同步异常、异步异常和取消,并停止后续动作、反向取消仍在运行的 Future。
  • 串行化 RemoteQuestReader 的反射读取状态,避免共享游标竞态。
  • 将计分板 Bukkit/NMS 更新封送回平台同步调度线程,并传播调度失败、回调异常和取消状态。
  • 增加上下文生命周期、嵌套 Future 和远程读取并发测试。

修改目的

保证 Kether 脚本无论成功、失败还是取消都能可靠结束,同时遵守 Bukkit/Folia 的线程所有权要求,避免脚本挂死和跨线程平台访问。

兼容性与行为变化

  • 不修改公开 API 签名。
  • 脚本失败由“可能永久等待”改为“可观察的异常完成”。
  • 计分板动作可能由错误的异步立即执行改为正确线程上的调度执行。

验证

  • ./gradlew :module:minecraft:minecraft-kether:test --rerun-tasks --no-parallel
  • ./gradlew :module:minecraft:minecraft-kether:build --rerun-tasks --no-parallel
  • git diff --check

Refs #703

确保动作异常与取消能够结束上下文并回收运行任务,同时序列化远程读取并将计分板更新切回平台线程。

@FxRayHughes FxRayHughes left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review:PR #706 fix(kether): 修复异步上下文完成与线程切换

修复的底层问题是真实的,thenRunwhenCompleteActionScoreboard 的改动都切中要害。但为了线程安全引入的 synchronized 造成了新的、后果更严重的问题,建议在合并前解决。

说明:以下结论基于源码路径推导,相关行号已逐一核对;死锁未实测复现。


🔴 严重问题(建议阻止合并)

1. 父子 frame 锁顺序反转 → 主线程 ABBA 死锁

文件: module/minecraft/minecraft-kether/src/main/java/taboolib/library/kether/AbstractQuestContext.java

PR 给 SimpleNamedFramerun() / 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.framesAbstractQuestContext.java:135:148
  • AbstractFrame.close() 遍历 this.frames 逐个 frame.close()
  • SimpleNamedFrame.close() 在本 PR 中被标记 synchronized
  • rootFrame 就是 SimpleNamedFrameAbstractQuestContext.java:31

两条路径并发即死锁。触发场景很常见:/reload、插件禁用、Workspace.cancelAll() 执行时,恰好有异步动作(数据库查询、网络请求)在别的线程完成。路径 A 通常跑在主线程上,死锁 = 服务器冻结。

这等于把"脚本挂起"换成了"服务器挂死",后果比原 bug 更严重。


2. 持锁期间调用第三方插件代码

文件: AbstractQuestContext.javaSimpleNamedFrame.process

process()synchronized,内部直接 action.process(this) 执行任意插件代码。两种自锁路径:

  • 动作内 newFrame().run():在持父锁时获取子锁,重现问题 1 的锁序
  • 动作内同步等待任何需要同一 frame 锁的操作:直接自锁

持锁调用外部回调是公认的反模式,它也大幅放大了问题 1 的触发窗口。

建议与问题 1 一并重新设计——例如改用无锁状态机(CAS + 状态字段),或把 future 完成回调移出锁外执行。


🟡 中等问题

3. ExitStatus 的三种语义被统一当作"成功"

文件: AbstractQuestContext.javaSimpleNamedFrame.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.javaAbstractFrame.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
异步动作 thenRunwhenComplete 核心修复thenRun 在异常时不执行,导致 frame 永久挂起
动作同步抛异常走 fail() 异常不再逃逸到调用栈,调用方能拿到终态
SimpleActionFrame.run() 捕获同步异常转失败 future 保证总返回可观察的 future
Objects.requireNonNull(action.process(...)) 明确报错,替代后续位置的莫名 NPE
解包 CompletionException 后区分 CancellationException 正确区分"取消"与"异常"语义
contextFuture 取消反向传播到 frameFuture 取消语义双向打通
exitStatus / futurevolatile 跨线程可见性
ActionScoreboard actionNowactionTake 旧代码 completedFuture(func(frame)) 让动作立即"完成",既不等计分板更新也丢弃异常——真实 bug
submit { } 把 Bukkit/NMS 调用封送回主线程 满足 Bukkit/Folia 线程所有权要求
body.isEmpty() 分支 顺带修了空集合时的 IndexOutOfBoundsException
测试用 fake Quest/Block/Context 注入 无需服务端环境即可测状态机,设计得好

总结

问题 1 建议在合并前解决——它用"主线程死锁"替换了"脚本挂起",触发条件(reload/禁用时有异步动作完成)在生产环境不罕见。问题 2 与之同源,宜一并重新设计并发模型。问题 3、5 是正确性问题,建议同批修复。

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.

2 participants