Skip to content

fix(bukkit): 收紧 Folia 调度与背包线程所有权#712

Open
zhibeigg wants to merge 1 commit into
TabooLib:dev/6.3.0from
zhibeigg:fix/703-bukkit-folia-threading
Open

fix(bukkit): 收紧 Folia 调度与背包线程所有权#712
zhibeigg wants to merge 1 commit into
TabooLib:dev/6.3.0from
zhibeigg:fix/703-bukkit-folia-threading

Conversation

@zhibeigg

@zhibeigg zhibeigg commented Jul 13, 2026

Copy link
Copy Markdown
Contributor

原有问题

Bukkit/Folia 调度与背包操作没有始终遵守对象所属线程:

  • Folia 的全局、区域、实体和异步调度被混用,部分路径通过阻塞等待跨区域结果。
  • 菜单、分页、虚拟背包及相关 NMS 操作可能从异步线程或错误区域直接访问 Player、Inventory、World。
  • 物品扣除边遍历边修改背包;当总数量不足时,前面槽位已经被扣除,无法原子回滚。

典型触发场景与后果

  • Folia 服务器上从异步数据库回调打开或刷新玩家菜单:触发线程检查异常,或与玩家点击同时修改背包造成竞态。
  • 玩家移动区域、插件禁用或任务取消时仍执行旧区域任务:访问失效实体、重复提交 UI 更新或遗留任务。
  • 扣除数量跨多个槽位,最终发现物品不足:玩家仍损失部分物品,takeList 与实际背包状态不一致。
  • 在区域线程中阻塞等待其他区域:可能卡住区域 tick,放大为服务器卡顿。

本 PR 修改

  • 明确区分 Folia 全局、区域、实体和异步调度,移除跨区域阻塞等待,并提供 CompletableFuture 形式的非阻塞结果入口。
  • 将菜单创建、分页刷新、虚拟背包提交和相关 NMS 操作封送到玩家所属线程。
  • 补齐任务取消、插件禁用和调度失败的终态传播。
  • 将物品扣除改为“先计算完整扣除计划,再一次性应用”;数量不足时不修改 Inventory 或 takeList
  • 增加 fake scheduler、UI 线程所有权和物品跨槽/不足边界测试。

修改目的

遵守 Bukkit 与 Folia 的线程模型,避免跨线程访问、区域线程阻塞和背包部分修改,保证 UI 与物品操作具备确定且原子的结果。

兼容性与行为变化

  • 保持现有同步 API,并增加可用于异步链路的 Future 入口。
  • 原本在错误线程立即执行的操作会改为在正确的玩家/区域线程调度。
  • 物品不足时由“可能部分扣除”改为“完全不修改”。

验证

  • 相关 Bukkit/Folia/UI/NMS 模块无缓存串行测试
  • git diff --check
  • 独立只读复审

Refs #703

避免无上下文同步任务错误落到全局区域线程,并让 UI、虚拟背包与 NMS 操作按玩家或区域线程执行;同时保证物品数量不足时不发生部分扣除。

@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 — #712 fix(bukkit): 收紧 Folia 调度与背包线程所有权

先说结论:物品扣除的原子化(planRemoval)、UI 操作封送回玩家线程、移除 callRegion 的跨区域阻塞等待,这三块都修得对,尤其「先算计划再一次性应用」是干净的解法。

但有两处会破坏现有代码,需要在合并前处理:

  1. BukkitExecutor.submit 现在对 Folia 上的「无上下文同步任务」直接 error(...),而 @ScheduleDebouncesubmitChain 的同步分支全都走这条路——包括 TabooLib 自己的 DataContainer.checkUpdate,而且失败是静默的。
  2. 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 = falseExecutor.kt:34-42)。也就是说任何 submit { } / submit(delay = n) { } / submit(period = n) { } 在 Folia 上都会抛

ClassVisitorSchedule.kt:17 正是这个形态:

submit(async = annotation.property("async", false), delay = ..., period = ...) { ... }

@Schedule 不写 async = trueasync 就是 false → 抛异常。而 ClassVisitorHandler.visitMethodcommon-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)asyncfalse 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 的代价是把一个设计问题转成了运行期静默失效。三个可选方向:

  1. 保留旧的 GLOBAL_REGION_SCHEDULER 兜底 + 一次性告警。 行为不变,但首次命中时 warning 打出调用栈,提示改用 Location.submit() / Entity.submit() / submitGlobal()。兼容性最好,也能推动迁移。
  2. 先把 in-tree 调用点全部迁完再收紧。 上表六处(尤其 ClassVisitorSchedule)改用 submitGlobal@Schedule 增加一个 global 属性。之后再对插件侧收紧。
  3. 若坚持立即收紧,至少要让失败可见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() 恒为 truecallRegioncallDirect在任何线程上都直接执行。改动后:非 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 调用PathSmoothingNodeReaderUtils 里都还在用)。所以两个 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)truetakeItem(0)falsefalse takeItem(0)true

第一条是本 PR 的核心修复,PR 描述已写明。第二、三条是边界翻转,没在说明里。判断「扣 0 个」算成功我认为更合理,但依赖旧返回值的代码会受影响。

另外 checkItemhasItem(...) && (!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 / NMSSignsubmit 依赖 taboolib.platform.util 的扩展。 NMSMap.ktimport 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-utilplatform-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 判断菜单是否已被替换,这个「过期就丢弃」的守卫加得好。lastInventorylateinit varChestImpl.kt:25),但 build() 在回调触发前必然已赋值,不会 UninitializedPropertyAccessException


🟢 已核对无误

结论
planRemoval 原子化 先算完整计划、null 表示不足、调用方不足即返回不改背包。惰性 Sequence + 够了就 return plan,测试 planning stops after enough items are found 断言只求值 2 次,实现与断言一致
planRemoval 部分扣除 takenAmount == itemStack.amountsetItem(index, null),否则 setItem 一个减量后的 clone。不再原地改 itemStack.amount,避免了对调用方持有的引用产生副作用
takeItem 的部分扣除数学 旧代码 itemStack.amount -= takeAmount + itemStack.amounttakeAmount 为负时确实等于「减去实际拿走的量」,数学没错,问题只在非原子
hasItem(amount <= 0) 提前返回 旧代码 checkAmount 从 0 开始也会返回 true,行为一致,只是省了遍历
maxPage 改成向上取整 elementsCache.size / menuSlots.size 在 10 元素 / 9 槽位时得 1,而 isNext 判定有第 2 页 → 翻页后空白。新 (size + n - 1) / nisNext 自洽,是真 bug 修复
menuSlots.isEmpty() / entry > 0 守卫 旧代码 size / entrysize / 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) / setMaxStackSizeviewer == null 时直接执行(尚未打开,无线程归属),判断正确
callRegionAsync 三分支 已拥有 → 直接 completeWith;Folia → 区域/实体调度器;非 Folia → submitPlatform。三条路径都会终结 future,不会悬挂
实体调度器拒绝的终态传播 retired 回调 → completeExceptionallyscheduledTask == 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.submitrunnable.now 时调 task.execute(),本来就忽略 delay/period。if (now) 0 else delay 是忠实保留
now && isOwnedByCurrentRegion() 才内联 旧代码 if (now) 无条件内联执行,在错误线程上直接跑。新增所有权判断是真修复
BukkitPlugin.onEnableinvokeActive ASYNC_SCHEDULER.runNow 改成 GLOBAL_REGION_SCHEDULER.runonActive() 会触发 ACTIVE 生命周期的 ClassVisitor(含命令注册等),在异步线程跑本来就不对。修得对
PacketSender.onQuitsubmitAsync 只做 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 属于玩家,用实体调度器正确
openMenucrossinline 因为 builder 现在被捕获进 Runnable 闭包,必须加 crossinlineMenuBuilderRaw.kt 的转发重载同步加了。编译上必需且完整
ClickListener.onDisable 封送 DISABLE 阶段逐玩家 runTask 关闭菜单。注意这依赖调度器此时仍可接受任务,若 Folia 在插件禁用阶段已拒绝调度则菜单不会关闭——但旧代码在 Folia 上是直接跨线程访问,更差
测试对 Folia 分支的覆盖 BukkitExecutorTestwithFolia { } 切换标志并在 finally 复原,三个用例分别覆盖 submit 层拒绝、async/now 放行、FoliaRunningTask.execute 兜底检查。测试本身写得规范

总结

物品扣除原子化、maxPage 向上取整、elementMap 竞态消除、UI 操作封送、去掉 awaitResult() 阻塞,这些都是实打实的修复,且 PageableChestImpl 里「异步只生成、同步只写入」的拆分是处理这类问题的正确形态。

需要合并前处理的两点:

  1. 问题 1:Folia 上 @Schedule 会静默不注册,ClassVisitorHandler.visitMethodprintStackTrace 而不中断。in-tree 至少 6 处未迁移,其中 DataContainer.checkUpdate#710 叠加后会让 Folia 上的 setDelayed 永不落库。建议先迁 in-tree 调用点、或保留 GLOBAL_REGION_SCHEDULER 兜底并降级为一次性告警。
  2. 问题 2isOwnedByCurrentRegion() 在非 Folia 下改成 Bukkit.isPrimaryThread(),配合 callRegioncheck(...),让 bukkit-navigation 的 15 处调用在异步线程上从「能跑」变成抛异常,且 #717 保留了这些调用。建议非 Folia 下仍返回 true,或在本 PR / #717 里一并迁到 callRegionAsync

问题 3(openVirtualInventory 改为抛异常)和问题 4(takeItem 边界翻转)方向我认同,但需要进兼容性说明。

说明:本次审阅未实跑 gradle 测试。PR 的验证清单里「相关 Bukkit/Folia/UI/NMS 模块无缓存串行测试」没有给出具体命令,建议补上,便于复核。全部结论基于 patch 与仓库源码推导,已逐条注明依据位置;跨 PR 结论(#710@Schedule 依赖、#717callRegion 保留)来自对应 PR 的 diff。

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