fix(bukkit): 收紧 Folia 调度与背包线程所有权#712
Conversation
避免无上下文同步任务错误落到全局区域线程,并让 UI、虚拟背包与 NMS 操作按玩家或区域线程执行;同时保证物品数量不足时不发生部分扣除。
FxRayHughes
left a comment
There was a problem hiding this comment.
Code Review — #712 fix(bukkit): 收紧 Folia 调度与背包线程所有权
先说结论:物品扣除的原子化(planRemoval)、UI 操作封送回玩家线程、移除 callRegion 的跨区域阻塞等待,这三块都修得对,尤其「先算计划再一次性应用」是干净的解法。
但有两处会破坏现有代码,需要在合并前处理:
BukkitExecutor.submit现在对 Folia 上的「无上下文同步任务」直接error(...),而@Schedule、Debounce、submitChain的同步分支全都走这条路——包括 TabooLib 自己的DataContainer.checkUpdate,而且失败是静默的。isOwnedByCurrentRegion()在非 Folia 环境下从「恒为 true」改成Bukkit.isPrimaryThread(),配合callRegion新增的check(...),让 bukkit-navigation 的 15 处调用在异步线程上从「能跑」变成抛异常。这条影响普通 Paper 服务器,不只 Folia。
审阅方式:读 patch + 对照仓库源码逐条验证调用链。未实跑 gradle 测试,结论均基于源码推导,已注明依据位置。
🔴 问题 1 — Folia 上 @Schedule 会静默失效,包括 TabooLib 自己的定时任务
改动
BukkitExecutor.submit 开头新增:
if (Folia.isFolia && !runnable.now && !runnable.async) {
error("Context-free synchronous tasks are unsupported on Folia. ...")
}FoliaRunningTask.execute 里也加了对应的 check(async)。
为什么这会打到大量现有代码
全局 submit 的默认参数就是 now = false, async = false(Executor.kt:34-42)。也就是说任何 submit { } / submit(delay = n) { } / submit(period = n) { } 在 Folia 上都会抛。
ClassVisitorSchedule.kt:17 正是这个形态:
submit(async = annotation.property("async", false), delay = ..., period = ...) { ... }@Schedule 不写 async = true 时 async 就是 false → 抛异常。而 ClassVisitorHandler.visitMethod(common-util/.../ClassVisitorHandler.java:384-393)是这样处理的:
try {
visitor.visit(method, clazz);
} catch (Throwable ex) {
new ClassVisitException(clazz, group, lifeCycle, method, ex).printStackTrace();
}catch 住只打栈,任务不注册。 所以现象不是启动崩溃,而是:控制台多一段栈,然后这个定时任务永远不执行,且插件看起来启动正常。这比直接崩更难定位。
受影响的 in-tree 位置(grep 确认,本 PR 未改动的)
| 位置 | 形态 | Folia 上的结果 |
|---|---|---|
ClassVisitorSchedule.kt:17 |
@Schedule 不带 async = true |
静默不注册 |
database-player/DataContainer.kt:154 |
@Schedule(period = 20) |
延迟写回永不触发 |
Debounce.kt:56,101,153 |
submit(async = async, ...),async 默认 false |
抛给调用方 |
SynchronousRepeatChain.kt:17 |
submit(period, now, delay),async 恒 false |
now = false 时抛 |
ParticleObj.kt:110,121,143,163,174,205 |
submit(period = ...) / submit(delay = 2) |
抛给调用方 |
Actions.kt:56 |
submit(delay = ticks, async = !isPrimaryThread) |
区域线程上 isPrimaryThread 为 true → async = false → 抛 |
DataContainer.checkUpdate 这一条要特别提一下:它和 #710 叠在一起后,#710 刚把 setDelayed 的延迟改成真正生效(依赖 checkUpdate 每秒扫描落库),而本 PR 让 checkUpdate 在 Folia 上根本不注册。两个 PR 都合之后,Folia 上的 setDelayed 会变成永不落库。
建议
「Folia 上不该有无上下文的同步任务」这个判断我认同,GLOBAL_REGION_SCHEDULER 语义上确实和 Bukkit 主线程不等价。但直接 error 的代价是把一个设计问题转成了运行期静默失效。三个可选方向:
- 保留旧的
GLOBAL_REGION_SCHEDULER兜底 + 一次性告警。 行为不变,但首次命中时warning打出调用栈,提示改用Location.submit()/Entity.submit()/submitGlobal()。兼容性最好,也能推动迁移。 - 先把 in-tree 调用点全部迁完再收紧。 上表六处(尤其
ClassVisitorSchedule)改用submitGlobal,@Schedule增加一个global属性。之后再对插件侧收紧。 - 若坚持立即收紧,至少要让失败可见:
ClassVisitorSchedule里自己 catch 并warning出「@Schedule在 Folia 上需要async = true或 global 调度」,而不是依赖上层printStackTrace。
我倾向 1 或 2。本 PR 已经提供了 submitGlobal,迁移路径是通的,只是还没走完。
🔴 问题 2 — isOwnedByCurrentRegion() 的非 Folia 语义反转,让 navigation 在普通 Paper 上也开始抛异常
改动
fun Location.isOwnedByCurrentRegion(): Boolean {
if (!Folia.isFolia) {
- return true
+ return Bukkit.isPrimaryThread()
}
return kotlin.runCatching {
- Bukkit::class.java.invokeMethod<Boolean>("isOwnedByCurrentRegion", ...) ?: true
- }.getOrDefault(true)
+ Bukkit::class.java.invokeMethod<Boolean>("isOwnedByCurrentRegion", ...) == true
+ }.getOrDefault(false)
}同时 callRegion 从「不拥有就阻塞等待」改成「不拥有就抛」:
fun <T> Location.callRegion(executor: () -> T): T {
- if (isOwnedByCurrentRegion()) return callDirect(executor)
- ...
- return future.awaitResult()
+ check(isOwnedByCurrentRegion()) { "The current thread does not own this location. ..." }
+ return executor()
}去掉阻塞等待是对的——在区域线程上 future.get() 等另一个区域,确实可能卡住整个区域 tick,PR 描述里这一条判断准确。
问题在两个改动叠加后的非 Folia 行为。 改动前:非 Folia 下 isOwnedByCurrentRegion() 恒为 true → callRegion 走 callDirect → 在任何线程上都直接执行。改动后:非 Folia 下要求 Bukkit.isPrimaryThread() → 异步线程调用 callRegion 抛 IllegalStateException。
bukkit-navigation 有 15 处 callRegion 调用(grep 确认):
PathSmoothing.kt:30,63,89 PathFinder.kt:29 RandomPositionGenerator.kt:100
NodeEntity.kt:73,100,106 NodeReader.kt:54,119,138,233,271 Utils.kt:24
且都是入口即包裹的形态,例如 PathSmoothing.kt:30:
fun smooth(path: Path, entity: NodeEntity): List<Vector> {
return entity.location.callRegion { smoothAtRegion(path, entity) }
}寻路放异步线程跑是很常见的用法(正是为了不卡主线程)。改动前在普通 Paper 上这样用是可行的,改动后会抛异常。navigation 模块自身没有任何异步封送,责任完全推给调用方。
与 #717 的交叉:我查了 #717 的 diff,它同样改 navigation 这几个文件,但保留了 callRegion 调用(PathSmoothing、NodeReader、Utils 里都还在用)。所以两个 PR 都合并之后,这个问题依然存在。
建议
- 非 Folia 下的
isOwnedByCurrentRegion()建议保持返回true。非 Folia 服务器没有区域概念,「当前线程是否拥有该位置」在语义上不适用;用isPrimaryThread代替会把「线程安全检查」偷偷加到一个原本没有这层语义的 API 上。要做主线程检查的话,应该是显式的checkPrimaryThread()。 - 若确实想收紧非 Folia 的线程检查,那么 navigation 这 15 处需要在本 PR 或 #717 里一并迁移到
callRegionAsync,否则等于把一个能跑的模块改成会抛。 Folia.isFolia为 true 时把getOrDefault(true)改成getOrDefault(false)我认为是对的——反射失败时保守假定「不拥有」,会走调度器而不是直接执行,这个方向安全。
🟡 问题 3 — openVirtualInventory 从「自动封送」改成「抛异常」
fun HumanEntity.openVirtualInventory(inventory: VirtualInventory, updateId: Boolean = true): RemoteInventory {
check(isOwnedByCurrentRegion()) {
"Virtual inventory must be opened on the thread that owns the viewer. ..."
}改动前这个函数在非主线程下会把事件调用 submit 出去(isPrimaryThread 分支),函数本体照常执行并返回 RemoteInventory;改动后在异步线程直接抛。
考虑到它内部要发包、要改 playerRemoteInventoryMap,异步调用本来就不安全,改成显式失败是合理的方向,而且提供了 openVirtualInventoryAsync。但这是公开 API 的破坏性变更,异步打开虚拟菜单的插件会直接崩。加上问题 2 的语义反转,非 Folia 服务器上也一样会抛。
建议在兼容性说明里点明,并且错误信息里已经列了三个替代入口(openVirtualInventoryAsync() / openMenu() / runTask()),这点做得好。
MenuBuilder.openMenu 侧不受影响——它在调 openVirtualInventory 之前已经先 isOwnedByCurrentRegion() 判断并 runTask 封送,PageableChestImpl 改成走 viewer.openMenu(build()) 后也一样被覆盖。
🟡 问题 4 — takeItem 的返回值与 takeList 语义变化
原子化本身是对的,但三处语义变了:
| 场景 | 旧行为 | 新行为 |
|---|---|---|
| 数量不足 | 扣掉已遍历到的槽位,takeList 装部分物品,返回 true |
不动 Inventory,takeList 不变,返回 false |
amount = 0 |
返回 takeList.isNotEmpty() = false |
planRemoval 返回空列表 → 返回 true |
checkItem(item, 0, remove = true) |
hasItem(0) 为 true,takeItem(0) 为 false → false |
takeItem(0) → true |
第一条是本 PR 的核心修复,PR 描述已写明。第二、三条是边界翻转,没在说明里。判断「扣 0 个」算成功我认为更合理,但依赖旧返回值的代码会受影响。
另外 checkItem 从 hasItem(...) && (!remove || takeItem(...)) 改成 if (remove) takeItem(...) else hasItem(...),少一次全背包遍历,等价且更快——这个改得好。
🔵 次要
a. toastMap.compute 里调用 Bukkit API。 NMSToast.kt 新代码在 ConcurrentHashMap.compute 的 lambda 内调 Bukkit.getAdvancement(cachedKey) 和 injectAdvancement(...)。compute 期间持有 bin 锁,在锁内调外部 API 是通用反模式。这里跑在全局区域线程、操作很短,实际风险低,但值得留意。用 getOrPut + 事后校验也能达到「缓存失效则重建」的目的。
b. NMSToast / NMSSign 的 submit 依赖 taboolib.platform.util 的扩展。 NMSMap.kt 把 import taboolib.common.platform.function.submit 换成 import taboolib.platform.util.submit,即 Entity.submit。这让 bukkit-nms 模块对 platform-bukkit-impl 的扩展函数产生依赖——请确认 bukkit-nms-legacy 的 build 配置里能解析到(本 PR 未改 bukkit-nms 的 build.gradle.kts,只改了 bukkit-util 和 platform-bukkit-impl)。
c. Folia.isFolia 是可变 public static 字段。 测试通过 Folia.isFolia = true 切换环境,可行但依赖了一个本应只读的字段。改成 @JvmStatic var 或提供测试专用 setter 会更清晰。不阻塞。
d. PlaceholderExpansion 的 import 位置。 新增的 taboolib.platform.Folia / FoliaExecutor 插在 taboolib.common.util.unsafeLazy 之前,打断了原有分组顺序。纯格式问题。
e. PageableChestImpl 异步分支引用 lastInventory。 onFinalBuild(async = true) 里通过 lastInventory !== inventory 判断菜单是否已被替换,这个「过期就丢弃」的守卫加得好。lastInventory 是 lateinit var(ChestImpl.kt:25),但 build() 在回调触发前必然已赋值,不会 UninitializedPropertyAccessException。
🟢 已核对无误
| 项 | 结论 |
|---|---|
planRemoval 原子化 |
先算完整计划、null 表示不足、调用方不足即返回不改背包。惰性 Sequence + 够了就 return plan,测试 planning stops after enough items are found 断言只求值 2 次,实现与断言一致 |
planRemoval 部分扣除 |
takenAmount == itemStack.amount 时 setItem(index, null),否则 setItem 一个减量后的 clone。不再原地改 itemStack.amount,避免了对调用方持有的引用产生副作用 |
旧 takeItem 的部分扣除数学 |
旧代码 itemStack.amount -= takeAmount + itemStack.amount 在 takeAmount 为负时确实等于「减去实际拿走的量」,数学没错,问题只在非原子 |
hasItem(amount <= 0) 提前返回 |
旧代码 checkAmount 从 0 开始也会返回 true,行为一致,只是省了遍历 |
maxPage 改成向上取整 |
旧 elementsCache.size / menuSlots.size 在 10 元素 / 9 槽位时得 1,而 isNext 判定有第 2 页 → 翻页后空白。新 (size + n - 1) / n 与 isNext 自洽,是真 bug 修复 |
menuSlots.isEmpty() / entry > 0 守卫 |
旧代码 size / entry 与 size / menuSlots.size 在槽位为空时除零。新增守卫正确 |
(maxPage - 1).coerceAtLeast(0) |
空列表时 maxPage = 0,旧代码会把 page 设成 -1。修得对 |
PageableChestImpl 改走 viewer.openMenu(build()) |
MenuBuilder.openMenu(Inventory) 内部已处理 VirtualInventory 分支并 inject(basic),与被删掉的手写分支等价,且额外获得线程封送 |
| 异步生成 / 同步写入拆分 | onFinalBuild(async = true) 只在异步线程调 asyncGenerateCallback 生成 ItemStack,inventory.setItem 全部收进 p.runTask { }。符合 Bukkit 线程模型 |
elementMap 改为不可变 associate |
旧代码在两个 processBuild 之间共享可变 hashMapOf,同步/异步回调并发写同一个 HashMap。新代码预先算好,消除了这个竞态 |
menuSlots.getOrNull(index) ?: 0 的移除 |
旧代码槽位越界时 fallback 到 slot 0(会覆盖第一格),新代码 mapIndexedNotNull 直接丢弃。更合理 |
InventoryHandler 三个包处理封送 |
close / click / rename 都改成 player.runTask { },且 remove 先在包线程执行、闭包内只用局部变量 removedInventory,避免了封送延迟内的重复 remove |
InventoryHandlerImpl.close / handle 的自封送 |
入口 if (!viewer.isOwnedByCurrentRegion()) { viewer.runTask { ... }; return },之后内部所有 submit 包裹都可以去掉——因为已保证在正确线程。逻辑自洽 |
VirtualInventory.mutate |
统一封送 setItem / setContents / setStorageItem(s) / setMaxStackSize。viewer == null 时直接执行(尚未打开,无线程归属),判断正确 |
callRegionAsync 三分支 |
已拥有 → 直接 completeWith;Folia → 区域/实体调度器;非 Folia → submitPlatform。三条路径都会终结 future,不会悬挂 |
| 实体调度器拒绝的终态传播 | retired 回调 → completeExceptionally,scheduledTask == null && !future.isDone → 再补一次 completeExceptionally。两种拒绝路径都不会让 future 永久 pending |
移除 awaitResult() |
该函数是 future.get() 阻塞,在区域线程上调用会卡区域 tick。删除方向正确,替代品是 callRegionAsync |
submitGlobal 非 Folia 分支 |
走 submitPlatform(runNow, false, ...),async = false,但不经过 BukkitExecutor.submit 的 Folia 检查(Folia.isFolia 为 false),不会误抛 |
now = true 时清零 delay/period |
与既有语义一致——BukkitExecutor.submit 在 runnable.now 时调 task.execute(),本来就忽略 delay/period。if (now) 0 else delay 是忠实保留 |
now && isOwnedByCurrentRegion() 才内联 |
旧代码 if (now) 无条件内联执行,在错误线程上直接跑。新增所有权判断是真修复 |
BukkitPlugin.onEnable 的 invokeActive |
从 ASYNC_SCHEDULER.runNow 改成 GLOBAL_REGION_SCHEDULER.run。onActive() 会触发 ACTIVE 生命周期的 ClassVisitor(含命令注册等),在异步线程跑本来就不对。修得对 |
PacketSender.onQuit 改 submitAsync |
只做 ConcurrentHashMap.remove,无需主线程,且规避了问题 1 的检查。合理 |
NMSSign 去掉手写 Folia 分支 |
e.player.runTask(...) 与原 if (Folia.isFolia) REGION_SCHEDULER.run(...) else submit { } 等价,且用的是实体调度器(比按 location 取区域更准) |
NMSToast 全局/玩家线程拆分 |
成就注册(服务端全局状态)走 submitGlobal,玩家进度走 player.submit。拆分符合 Folia 模型;Bukkit.getAdvancement(cachedKey) == null 的重建判断也修了「缓存 key 已被卸载」的隐患 |
awardAdvancement / revokeAdvancement 重构 |
旧代码每次 player.getAdvancementProgress(advancement) 都重新取一次(一处调了 3 次),新代码提出局部变量。等价且更省 |
TypeBossBar 双层封送 |
外层 player.submit(now = true) 建 bossBar,内层 player.submit(period = period) 走实体调度器。BossBar 属于玩家,用实体调度器正确 |
openMenu 的 crossinline |
因为 builder 现在被捕获进 Runnable 闭包,必须加 crossinline;MenuBuilderRaw.kt 的转发重载同步加了。编译上必需且完整 |
ClickListener.onDisable 封送 |
DISABLE 阶段逐玩家 runTask 关闭菜单。注意这依赖调度器此时仍可接受任务,若 Folia 在插件禁用阶段已拒绝调度则菜单不会关闭——但旧代码在 Folia 上是直接跨线程访问,更差 |
| 测试对 Folia 分支的覆盖 | BukkitExecutorTest 用 withFolia { } 切换标志并在 finally 复原,三个用例分别覆盖 submit 层拒绝、async/now 放行、FoliaRunningTask.execute 兜底检查。测试本身写得规范 |
总结
物品扣除原子化、maxPage 向上取整、elementMap 竞态消除、UI 操作封送、去掉 awaitResult() 阻塞,这些都是实打实的修复,且 PageableChestImpl 里「异步只生成、同步只写入」的拆分是处理这类问题的正确形态。
需要合并前处理的两点:
- 问题 1:Folia 上
@Schedule会静默不注册,ClassVisitorHandler.visitMethod只printStackTrace而不中断。in-tree 至少 6 处未迁移,其中DataContainer.checkUpdate与 #710 叠加后会让 Folia 上的setDelayed永不落库。建议先迁 in-tree 调用点、或保留GLOBAL_REGION_SCHEDULER兜底并降级为一次性告警。 - 问题 2:
isOwnedByCurrentRegion()在非 Folia 下改成Bukkit.isPrimaryThread(),配合callRegion的check(...),让 bukkit-navigation 的 15 处调用在异步线程上从「能跑」变成抛异常,且 #717 保留了这些调用。建议非 Folia 下仍返回true,或在本 PR / #717 里一并迁到callRegionAsync。
问题 3(openVirtualInventory 改为抛异常)和问题 4(takeItem 边界翻转)方向我认同,但需要进兼容性说明。
说明:本次审阅未实跑 gradle 测试。PR 的验证清单里「相关 Bukkit/Folia/UI/NMS 模块无缓存串行测试」没有给出具体命令,建议补上,便于复核。全部结论基于 patch 与仓库源码推导,已逐条注明依据位置;跨 PR 结论(#710 的
@Schedule依赖、#717 的callRegion保留)来自对应 PR 的 diff。
原有问题
Bukkit/Folia 调度与背包操作没有始终遵守对象所属线程:
典型触发场景与后果
takeList与实际背包状态不一致。本 PR 修改
CompletableFuture形式的非阻塞结果入口。takeList。修改目的
遵守 Bukkit 与 Folia 的线程模型,避免跨线程访问、区域线程阻塞和背包部分修改,保证 UI 与物品操作具备确定且原子的结果。
兼容性与行为变化
验证
git diff --checkRefs #703