Skip to content

fix: 修复 Hytale 等平台适配正确性#716

Open
zhibeigg wants to merge 1 commit into
TabooLib:dev/6.3.0from
zhibeigg:fix/703-platform-adapters
Open

fix: 修复 Hytale 等平台适配正确性#716
zhibeigg wants to merge 1 commit into
TabooLib:dev/6.3.0from
zhibeigg:fix/703-platform-adapters

Conversation

@zhibeigg

@zhibeigg zhibeigg commented Jul 13, 2026

Copy link
Copy Markdown
Contributor

原有问题

Hytale 及其他平台适配层中存在阻塞等待、调度语义错误和平台对象包装不一致:

  • Hytale 命令/任务路径会同步等待 Future,调度器的同步、异步、取消和禁用清理语义也不完整。
  • Hytale 命令权限、首参数原生补全、sender/player 包装、异步事件 Future 和玩家退出回调与平台实际行为不一致。
  • Velocity 可能把已经是代理对象的 sender/player 再次包装。
  • Bungee aliases、注销和 title/subtitle 参数处理错误;Bukkit 床出生点可能为空;SimpleCommand 空树或只有父节点时执行行为不一致。

典型触发场景与后果

  • Hytale 主线程执行命令并同步等待异步 Future:线程互相等待,命令卡死甚至形成死锁。
  • 插件禁用或任务取消时 Hytale 任务仍持有句柄:回调继续执行,Future 不结束或资源无法释放。
  • 使用权限命令、首参数补全或玩家退出事件:可能错误拒绝/放行权限、缺少补全、sender 类型错误,退出清理不触发。
  • Velocity 已包装对象再次进入适配层:出现双重包装、类型判断失败或身份不一致。
  • Bungee 使用 alias、发送 subtitle,或 Bukkit 玩家没有床出生点:命令无法注销、标题参数错位或产生空指针异常。
  • 定义空命令体或仅父节点执行器:命令被错误拒绝或访问不存在节点。

本 PR 修改

  • 修复 Hytale 同步/异步调度、任务取消和禁用清理,并移除命令 Future 的阻塞等待,改为异步完成传播。
  • 修复 Hytale 权限、首位置原生补全、sender/player 包装、异步事件 Future 和 onQuit 回调。
  • 避免 Velocity 重复包装已有代理对象。
  • 修复 Bungee alias 注册/注销、title/subtitle 参数,Bukkit 空床点,以及 SimpleCommand 空树和父节点执行逻辑。
  • 增加动态代理、fake scheduler 和纯逻辑平台适配测试,并核对测试辅助声明的 JVM ABI。

修改目的

让平台抽象忠实反映各平台真实线程、命令、事件和对象语义,避免阻塞平台线程、类型包装错误及边界输入导致的崩溃。

兼容性与行为变化

  • 不修改公开平台抽象签名。
  • Hytale 失败和取消会通过 Future 明确传播,不再阻塞等待。
  • 修正后的权限、补全、alias、标题和空值行为与对应平台保持一致。

验证

  • ./gradlew :common-platform-api:test :platform:platform-bukkit-impl:test :platform:platform-bungee-impl:test :platform:platform-velocity-impl:test :platform:platform-hytale:test --rerun-tasks --no-parallel
  • ./gradlew :common-platform-api:build :platform:platform-bukkit-impl:build :platform:platform-bungee-impl:build :platform:platform-velocity-impl:build :platform:platform-hytale:build --rerun-tasks --no-parallel
  • git diff --check
  • 检查受影响源码与测试中无 Future.get.join()Thread.sleepCountDownLatch

Refs #703

@FxRayHughes

Copy link
Copy Markdown
Contributor

Code Review — #716 fix: 修复 Hytale 等平台适配正确性

这个 PR 修的几处都是真 bug,其中 SimpleCommand 的父节点执行器丢失、Bungee sendTitle 的 subtitle 写错、Bungee aliases 从未注册、Bukkit bedSpawnLocation 强解包,都是明确的功能缺陷。Hytale 的 future.get() 阻塞移除方向也对。

但有一个发现需要优先说:本 PR 用 WeakHashMap 修了 Hytale 的 onQuit,而同一个 bug 在 Bukkit / Bungee / Velocity 三个平台都存在且未修。我实测复现了回调丢失。既然这批 PR 的目标是修 Issue #703 的稳定性问题,这三处建议一并处理。

另有一处 performCommand 的返回值语义变更需要进兼容性说明。

审阅方式:读 patch + 对照源码逐条验证 + 实测复现(回调丢失、CME 类问题)。未实跑 gradle 测试


🔴 问题 1 — onQuit 回调丢失的同一个 bug 在 Bukkit / Bungee / Velocity 都存在,本 PR 只修了 Hytale

Hytale 侧的修法是对的。 新增 HytaleCommandSender.registerQuitCallback / fireQuitCallbacks,用 companion 里的 WeakHashMap<Any, LinkedHashSet<Runnable>> 按 session 键存储,解决了"回调注册在实例上、事件触发时拿不到"的问题。

但另外三个平台是同样的形态,且都没改:

// BukkitPlayer.kt:351-355, 368-370
val quitCallback = CopyOnWriteArraySet<Runnable>()            // ← 实例字段
override fun onQuit(callback: Runnable) { quitCallback += callback }

companion object {
    @SubscribeEvent
    private fun onQuit(e: PlayerQuitEvent) {
        BukkitPlayer(e.player).quitCallback.forEach(Runnable::run)   // ← new 一个新实例
    }
}

BungeePlayer.kt:322-334VelocityPlayer.kt:333-344 逐字相同的结构(分别对应 PlayerDisconnectEvent / DisconnectEvent)。

quitCallback实例字段,而事件处理里 new BukkitPlayer(e.player) 创建的是全新实例,其 quitCallback 必然为空集合。所以 onQuit() 注册的回调在这三个平台上从来不会被触发

我写了个最小复现:

$ java QuitBug
注册后, 该实例 callback 数 = 1
事件触发时 new BungeePlayer(player).quitCallback 数 = 0
事件触发结果: (以上无输出即回调丢失)

建议:把 Hytale 的方案复用到三个平台——companion 里按 UUID(Bukkit/Velocity)或 ProxiedPlayer 身份(Bungee)存回调表,onQuit 写入表而非实例字段。或者更简单:把 quitCallback 改成 companion 级的 ConcurrentHashMap<UUID, CopyOnWriteArraySet<Runnable>>

考虑到 onQuitProxyPlayer 的公开 API、且被插件用于清理玩家数据,这个 bug 的实际影响不小(插件以为注册了清理回调,实际从未执行)。既然本 PR 已经在处理"平台对象包装不一致"这一类问题,一并修掉比较合适。


🟡 问题 2 — performCommand 从"返回执行结果"变成"无条件返回 true"

 override fun performCommand(command: String): Boolean {
-    val future = CommandManager.get().handleCommand(sender, command)
-    return try {
-        future.get()
-        true
-    } catch (e: Exception) {
-        false
-    }
+    return dispatchCommand { CommandManager.get().handleCommand(sender, command) }
 }

dispatchCommand 是:

internal fun dispatchCommand(dispatch: () -> CompletableFuture<Void>): Boolean {
    dispatch()
    return true
}

去掉 future.get() 阻塞我完全认同——PR 描述里"主线程执行命令并同步等待异步 Future:线程互相等待,命令卡死甚至形成死锁"这个判断是准确的,在命令处理线程上等命令处理 Future 是典型的自死锁。

但代价是 performCommand 的返回值失去了意义:它现在恒为 true,即使命令不存在、权限不足或执行抛异常。三处(HytaleCommandSender.performCommandConsole.performCommandHytalePlayer.performCommand)都是这样。

performCommandProxyCommandSender 的公开 API,跨平台契约上通常表示"命令是否成功执行"。Bukkit 侧返回的是 player.performCommand(command) 的真实结果。所以这里出现了平台间行为不一致:Bukkit 返回真实结果,Hytale 恒 true。

建议:

  1. 兼容性说明里明确"Hytale 的 performCommand 不再等待执行结果,恒返回 true"
  2. 或者在 dispatchCommand 里挂一个 whenComplete 把失败记录到日志(现在异常会被 future 完全吞掉,连日志都没有)——至少让问题可见
  3. 长远看,ProxyCommandSender 可以加一个 performCommandAsync(): CompletableFuture<Boolean>,让需要结果的调用方有出路

第 2 点我建议在本 PR 里就做——目前 dispatch() 的返回值被直接丢弃,命令执行失败是完全静默的。


🟡 问题 3 — Hytale executor 是 #715 里那三份状态机的第四个副本

HytaleExecutor 的改动形态与 #715VelocityExecutor / AppExecutor / AfyBrokerExecutor 高度一致:

  • private constructor(taskScheduler, asyncExecutor, exceptionReporter, registerStopTask) + constructor() 委托
  • State.NEW / RUNNING / STOPPED + pendingTasks / activeTasksLinkedHashSet
  • registerLifeCycleTask(LifeCycle.DISABLE, 2) { stop() }
  • 停止后 throw RejectedExecutionException
  • failure 累加 + addSuppressed + 末尾 rethrow

四个平台四份几乎相同的实现。#715 我也提了同样的建议,这里再确认一次:抽到 common-platform-api 一个 PlatformExecutorSupport 之类的基类,四个平台只保留各自的 schedule 差异,能省下大量重复代码和后续维护成本。

由于 #715#716 是并行的两个 PR、各自引入两份,合并后会同时存在四份,建议在 #720 整合时统一处理。

另外与 #715 相同的一点:停止后 submitRejectedExecutionException,需要确认 DISABLE 阶段自身的清理代码不会撞上(优先级 2 排在默认 0 之后,看起来是对的)。


🔵 次要

a. HytalePlayer 的 init 块副作用。

class HytalePlayer(val player: Player) : ProxyPlayer {
    init {
        if (isOnline()) {
            HytaleCommandSender.activateQuitSession(player.playerRef)
        }
    }

每次 new HytalePlayer(...) 都会调 activateQuitSession,而后者会 completedQuitSessions.remove(session)。也就是说:玩家退出后(session 已标记 completed),如果某处又 new HytalePlayer(sameplayer),会把"已完成"标记清掉,导致之后 registerQuitCallback 不再立即执行回调而是继续等待。

adaptPlayer 每次调用都会 new 一个 HytalePlayer,所以这个路径不算罕见。建议 activateQuitSession 只在确认是新会话时调用,或者加个 session 版本号。

b. HytaleCommandSender.fireQuitCallbacks 会 rethrow。 回调抛异常时累加 addSuppressedfailure?.let { throw it }。这个方法由平台事件回调触发,抛出去之后行为取决于 Hytale 事件系统——可能中断后续监听器。#715 的同类代码(AfyBrokerPlugin.disable)我也提了这一点,建议统一为"记录但不抛"。

c. WeakHashMap 的键是 player.playerRef 用 WeakHashMap 避免泄漏是对的,但要求 playerRef 在玩家会话期间被强引用持有(否则可能提前被 GC 导致回调丢失)。这个前提取决于 Hytale 内部实现,建议加注释说明依赖,或者改用显式的 onQuit 后清理 + ConcurrentHashMap

d. completionArguments 的空输入处理。

if (input.isEmpty()) return arrayOf("")

空串返回 [""],而 "cmd " (末尾空格)返回 ["cmd", ""],"cmd" 返回 ["cmd"]。逻辑自洽。但注意 input.isEmpty() 用的是原串,而后面 input.trim()——纯空白输入(如 " ")会走到下面,split 后过滤掉全部元素得到空 list,再因 input.last().isWhitespace() 为真加一个 "",最终 [""]。结果与空串一致,没问题,只是路径绕。

e. commandSuggestions 里有个多余空行。

internal fun commandSuggestions(input: String, completer: (Array<String>) -> List<String>?): List<String> {

    return completer(completionArguments(input)) ?: emptyList()
}

函数体第一行是空行。纯格式。

f. HytaleCommand 里的全限定名调用。 HytalePlayer.performCommand 用了 com.hypixel.hytale.server.core.command.system.CommandManager.get() 全限定形式,而同文件顶部原本有 import ...CommandManager 被删掉了。既然只有一处使用,可以保留 import 让代码更整洁。

g. @JvmSynthetic 的使用。 新增的 commandPermission / commandArguments / commandSuggestions / completionArguments 都标了 @JvmSynthetic,而它们是 internal 且被测试调用。@JvmSynthetic 会让方法在 Java 侧不可见——如果测试是 Kotlin 写的没问题(本 PR 的测试是 Kotlin)。这个组合是刻意在防止 Java 侧误用,可以,只是值得确认测试全部是 Kotlin。


🟢 已核对无误

结论
SimpleCommand 父节点执行器丢失 旧代码 if (body.children.isEmpty()) body.func(this) else children.forEach { ... } 是 if/else——有子节点时父节点自己的 func 不执行。所以同时写了执行器和子命令的 @CommandBody 会静默丢掉父级执行器。新 registerTo 改成 func(this) 后再递归 children,两者都执行。真 bug 修复
registerTo 的递归正确性 this@registerTo.children.forEach { it.registerTo(this) },this 是新建的子 CommandComponent,限定符用得对,不会递归到自身
Bungee sendTitle 的 subtitle 旧代码 it.subTitle(TextComponent(title ?: "")) 用的是 title 而非 subtitle。真 bug,bungeeTitleComponents 修对了
Bungee aliases 从未注册 旧代码 object : Command(command.name, permission) 只传了两参,command.aliases 完全没用上。新 RegisteredBungeeCommandCommand(name, permission, *aliases) 三参重载。真 bug 修复
Bungee unregisterCommand 的 NPE getProperty<...>("commandMap")!![command] 在反射拿不到 commandMap 时 !! 抛 NPE;新 ?.get(command) ?: return 安全返回。修得对
Bukkit bedSpawnLocation setter 属性声明是 Location? 但 setter 里 value!!.toBukkitLocation(),传 null 即 NPE。改成 value?.toBukkitLocation() 与声明一致。真 bug 修复
Velocity 双重包装 adaptPlayer / adaptCommandSender 新增 is ProxyPlayer / is ProxyCommandSender 前置判断。旧代码 any as Player 在传入 VelocityPlayer 时会 ClassCastException。修得对,且顺序正确(先判代理再判 Player)
Hytale future.get() 阻塞的危害 三处 CommandManager.get().handleCommand(...).get() 确认存在(HytaleCommandSender.kt:55,95HytalePlayer.kt:327)。在命令处理线程上等命令 Future 是自死锁形态,移除方向正确
Hytale 命令权限 新增 commandPermission(structure.permission)permission.ifEmpty { null } + requirePermission(it)。旧代码完全没有处理 structure.permission,等于所有命令无权限校验。真修复
Hytale 首参数原生补全 新增 withRequiredArg("argument", "", ArgTypes.STRING).suggest { ... }。旧代码只有 setAllowsExtraArguments(true),没有任何 suggest 注册,所以 Tab 补全在 Hytale 上完全不工作
Hytale 无参变体 addUsageVariant(object : CommandBase(description) { ... executeSync → executeCommand(context, emptyArray()) })。补上了"只输命令名不带参数"的执行路径
Hytale sender 包装 adaptNativeCommandSendersender is Player 分派到 HytalePlayer / HytaleCommandSender。旧代码走 adaptCommandSender(context.sender()) 经过 PlatformAdapter,可能拿到错误类型
commandArguments 与旧实现等价性 旧代码 inputString.split(" ").filter { it.isNotBlank() } 后 drop(1);新代码 trim().split(Regex("\\s+")) 后同样 drop(1)。新版对 tab/多空格更健壮,语义一致
Hytale executor 状态机 NEW → RUNNING → STOPPED 单向;start() 在非 NEW 时直接 return;stop() 幂等;pendingTasks.filterNotTo { it.isCancelled } 过滤掉启动前已取消的任务。逻辑正确
Hytale 异步事件 Future 新增的 handler 包装:null 返回值转 completeExceptionally(NullPointerException),抛异常转 completeExceptionally(ex)。避免了 handler 返回 null 导致 NPE 或异常被吞
taskScheduler 测试接缝 private constructor 注入 HytaleTaskScheduler?,生产路径走 HytaleServerTaskScheduler。让测试能用 fake scheduler 而不碰 HytaleServer.SCHEDULED_EXECUTOR。设计合理
reflex 与私有主构造器 #715 相同形态,我在 #715 已实测验证 reflex 1.2.4 的 newInstance() 能正确选中公开无参次构造器,HytaleExecutor 同样安全
activateQuitSession / fireQuitCallbacks 的同步 都在 synchronized(quitLock) 内操作两张 WeakHashMap,回调执行放在锁外(registered.forEach 在同步块之后),避免持锁调用用户代码。这一点做得对
registerQuitCallback 的竞态处理 已 completed 时立即执行(锁外),否则加入表。不会出现"注册时玩家已退出导致回调永不执行"
测试覆盖 新增 8 个测试文件,覆盖 SimpleCommand 空树/父节点执行、Bukkit 空床点、Bungee alias/title、Velocity 双重包装、Hytale 命令参数/补全/权限、Hytale executor 状态机、Hytale listener、Hytale sender

总结

SimpleCommand 父节点执行器丢失、Bungee subtitle 写错、Bungee aliases 从未注册、Hytale 权限与补全完全未实现、Bukkit bedSpawnLocation 强解包,这几处都是明确的功能缺陷,修得对。移除 Hytale 的 future.get() 阻塞也是正确判断。

建议处理:

  1. 问题 1onQuit 回调丢失)——Hytale 修了,Bukkit / Bungee / Velocity 三处同样的 bug 未修。我实测复现了回调完全不触发。onQuit 是插件清理玩家数据的常用入口,建议一并修掉,方案可直接复用本 PR 在 Hytale 上的做法。
  2. 问题 2performCommand 恒返回 true)——去掉阻塞正确,但建议至少在 dispatchCommand 里挂 whenComplete 记录失败,否则命令执行失败完全静默;并把返回值语义变化写进兼容性说明。
  3. 问题 3(第四份状态机)——与 fix(platform): 修复生命周期终态与任务清理 #715 合并后会有四份近乎相同的 executor 实现,建议在 fix: 整合 Issue #703 稳定性修复并发布 6.3.1 #720 整合时抽公共基类。
  4. 🔵 a(HytalePlayer init 块会清掉已完成的 quit session 标记)建议一并看一下,adaptPlayer 每次都 new,这个路径不罕见。

说明:本次审阅未实跑 gradle 测试(含 PR 描述列出的两条命令)。onQuit 回调丢失为本机实测复现。reflex 双构造器行为在 #715 审阅时已实测。其余结论基于 patch 与仓库源码推导,已逐条注明依据位置。

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