From d42347c60ec15cc92658bf7be4fa4900b4e28dbd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=BB=91?= Date: Tue, 23 Jun 2026 17:40:56 +0800 Subject: [PATCH 01/37] =?UTF-8?q?fix(kether):=20=E6=94=AF=E6=8C=81?= =?UTF-8?q?=E7=A9=BA=E5=AD=97=E7=AC=A6=E4=B8=B2=E5=AD=97=E9=9D=A2=E9=87=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../taboolib/library/kether/SimpleReader.java | 5 ++++ .../library/kether/SimpleReaderTest.java | 23 +++++++++++++++++++ 2 files changed, 28 insertions(+) create mode 100644 module/minecraft/minecraft-kether/src/test/java/taboolib/library/kether/SimpleReaderTest.java diff --git a/module/minecraft/minecraft-kether/src/main/java/taboolib/library/kether/SimpleReader.java b/module/minecraft/minecraft-kether/src/main/java/taboolib/library/kether/SimpleReader.java index e17771439..c967b99ba 100644 --- a/module/minecraft/minecraft-kether/src/main/java/taboolib/library/kether/SimpleReader.java +++ b/module/minecraft/minecraft-kether/src/main/java/taboolib/library/kether/SimpleReader.java @@ -35,6 +35,11 @@ public TokenBlock nextTokenBlock() { skipBlank(); switch (peek()) { case '"': { + // 空字符串要先于连续引号分隔符解析,否则 "" 会被当成长度为 2 的未闭合字符串。 + if (index + 1 < content.length && content[index + 1] == '"' && (index + 2 >= content.length || content[index + 2] != '"')) { + skip(2); + return new TokenBlock("", true); + } int cnt = 0; while (peek() == '"') { cnt++; diff --git a/module/minecraft/minecraft-kether/src/test/java/taboolib/library/kether/SimpleReaderTest.java b/module/minecraft/minecraft-kether/src/test/java/taboolib/library/kether/SimpleReaderTest.java new file mode 100644 index 000000000..d7082d577 --- /dev/null +++ b/module/minecraft/minecraft-kether/src/test/java/taboolib/library/kether/SimpleReaderTest.java @@ -0,0 +1,23 @@ +package taboolib.library.kether; + +import org.junit.jupiter.api.Test; + +import java.util.Collections; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +public class SimpleReaderTest { + + @Test + public void nextTokenBlockShouldReadEmptyQuotedString() { + BlockReader blockReader = new BlockReader("\"\"".toCharArray(), null, Collections.emptyList()); + SimpleReader reader = new SimpleReader(null, blockReader, Collections.emptyList()); + + TokenBlock tokenBlock = reader.nextTokenBlock(); + + assertEquals("", tokenBlock.getToken()); + assertTrue(tokenBlock.isBlock()); + assertEquals(2, reader.getIndex()); + } +} From 93a15fe3dcced514866b1afb2b2b49dcf1c19178 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=BB=91?= Date: Tue, 23 Jun 2026 18:20:19 +0800 Subject: [PATCH 02/37] =?UTF-8?q?feat(ptc):=20=E5=A2=9E=E5=8A=A0=20SQL=20?= =?UTF-8?q?=E6=96=87=E4=BB=B6=E8=BF=81=E7=A7=BB?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增基于 classpath SQL 文件的迁移机制与历史表校验 - 将 migration 相关实现集中到独立包 - 补充迁移执行器与 PersistentContainer 端到端测试 --- .../database-ptc-object/build.gradle.kts | 2 +- .../kotlin/taboolib/expansion/Annotations.kt | 2 +- .../taboolib/expansion/ContainerOperator.kt | 6 +- .../kotlin/taboolib/expansion/CustomType.kt | 2 +- .../kotlin/taboolib/expansion/DataMapper.kt | 6 +- .../kotlin/taboolib/expansion/JoinQuery.kt | 15 +- .../taboolib/expansion/MapperDelegate.kt | 3 +- .../taboolib/expansion/PersistentContainer.kt | 35 ++- .../taboolib/expansion/TransactionContext.kt | 2 - .../taboolib/expansion/container/Container.kt | 16 +- .../container/ContainerPostgreSQL.kt | 4 +- .../expansion/container/ContainerSQL.kt | 3 +- .../expansion/container/ContainerSQLite.kt | 3 +- .../expansion/container/DatabaseDialect.kt | 8 +- .../expansion/mapper/AbstractDataMapper.kt | 6 +- .../expansion/mapper/DataMapperImpl.kt | 7 +- .../mapper/TransactionalDataMapper.kt | 8 +- .../{ => migration}/MigrationConfig.kt | 2 +- .../expansion/migration/MigrationException.kt | 9 + .../expansion/migration/MigrationFiles.kt | 52 ++++ .../expansion/migration/MigrationRecord.kt | 20 ++ .../migration/MigrationResourceScanner.kt | 89 ++++++ .../expansion/migration/MigrationRunner.kt | 254 ++++++++++++++++++ .../expansion/migration/MigrationScript.kt | 59 ++++ .../migration/MigrationStatementReader.kt | 44 +++ .../migration/MigrationValidationException.kt | 8 + .../operator/CollectionTableHandler.kt | 8 +- .../operator/ContainerOperatorImpl.kt | 17 +- .../expansion/operator/LinkTableHandler.kt | 1 - .../taboolib/expansion/orm/AnalyzedClass.kt | 3 - .../expansion/orm/AnalyzedClassMember.kt | 15 +- .../com/zaxxer/hikari_4_0_3/HikariConfig.kt | 7 + .../zaxxer/hikari_4_0_3/HikariDataSource.kt | 7 + .../migration/MigrationRunnerTest.kt | 69 +++++ .../PersistentContainerMigrationTest.kt | 132 +++++++++ 35 files changed, 840 insertions(+), 84 deletions(-) rename module/database/database-ptc-object/src/main/kotlin/taboolib/expansion/{ => migration}/MigrationConfig.kt (96%) create mode 100644 module/database/database-ptc-object/src/main/kotlin/taboolib/expansion/migration/MigrationException.kt create mode 100644 module/database/database-ptc-object/src/main/kotlin/taboolib/expansion/migration/MigrationFiles.kt create mode 100644 module/database/database-ptc-object/src/main/kotlin/taboolib/expansion/migration/MigrationRecord.kt create mode 100644 module/database/database-ptc-object/src/main/kotlin/taboolib/expansion/migration/MigrationResourceScanner.kt create mode 100644 module/database/database-ptc-object/src/main/kotlin/taboolib/expansion/migration/MigrationRunner.kt create mode 100644 module/database/database-ptc-object/src/main/kotlin/taboolib/expansion/migration/MigrationScript.kt create mode 100644 module/database/database-ptc-object/src/main/kotlin/taboolib/expansion/migration/MigrationStatementReader.kt create mode 100644 module/database/database-ptc-object/src/main/kotlin/taboolib/expansion/migration/MigrationValidationException.kt create mode 100644 module/database/database-ptc-object/src/test/kotlin/com/zaxxer/hikari_4_0_3/HikariConfig.kt create mode 100644 module/database/database-ptc-object/src/test/kotlin/com/zaxxer/hikari_4_0_3/HikariDataSource.kt create mode 100644 module/database/database-ptc-object/src/test/kotlin/taboolib/expansion/migration/MigrationRunnerTest.kt create mode 100644 module/database/database-ptc-object/src/test/kotlin/taboolib/expansion/migration/PersistentContainerMigrationTest.kt diff --git a/module/database/database-ptc-object/build.gradle.kts b/module/database/database-ptc-object/build.gradle.kts index 7766e7968..3fdd79e35 100644 --- a/module/database/database-ptc-object/build.gradle.kts +++ b/module/database/database-ptc-object/build.gradle.kts @@ -26,4 +26,4 @@ tasks.withType { project(":module:database").tasks.named("shadowJar"), project(":module:basic:basic-configuration").tasks.named("shadowJar"), ) -} \ No newline at end of file +} diff --git a/module/database/database-ptc-object/src/main/kotlin/taboolib/expansion/Annotations.kt b/module/database/database-ptc-object/src/main/kotlin/taboolib/expansion/Annotations.kt index ce21d6453..afe2cfc70 100644 --- a/module/database/database-ptc-object/src/main/kotlin/taboolib/expansion/Annotations.kt +++ b/module/database/database-ptc-object/src/main/kotlin/taboolib/expansion/Annotations.kt @@ -1,8 +1,8 @@ package taboolib.expansion +import taboolib.module.database.ColumnTypePostgreSQL import taboolib.module.database.ColumnTypeSQL import taboolib.module.database.ColumnTypeSQLite -import taboolib.module.database.ColumnTypePostgreSQL /** * 标记数据类的逻辑主键字段。 diff --git a/module/database/database-ptc-object/src/main/kotlin/taboolib/expansion/ContainerOperator.kt b/module/database/database-ptc-object/src/main/kotlin/taboolib/expansion/ContainerOperator.kt index e00102418..0610987cd 100644 --- a/module/database/database-ptc-object/src/main/kotlin/taboolib/expansion/ContainerOperator.kt +++ b/module/database/database-ptc-object/src/main/kotlin/taboolib/expansion/ContainerOperator.kt @@ -1,10 +1,6 @@ package taboolib.expansion -import taboolib.module.database.Action -import taboolib.module.database.ActionSelect -import taboolib.module.database.Filter -import taboolib.module.database.HostPostgreSQL -import taboolib.module.database.Table +import taboolib.module.database.* import java.sql.ResultSet import java.util.* import javax.sql.DataSource diff --git a/module/database/database-ptc-object/src/main/kotlin/taboolib/expansion/CustomType.kt b/module/database/database-ptc-object/src/main/kotlin/taboolib/expansion/CustomType.kt index cb7a91e64..e0bc6125f 100644 --- a/module/database/database-ptc-object/src/main/kotlin/taboolib/expansion/CustomType.kt +++ b/module/database/database-ptc-object/src/main/kotlin/taboolib/expansion/CustomType.kt @@ -2,9 +2,9 @@ package taboolib.expansion import taboolib.module.configuration.Configuration import taboolib.module.configuration.Type +import taboolib.module.database.ColumnTypePostgreSQL import taboolib.module.database.ColumnTypeSQL import taboolib.module.database.ColumnTypeSQLite -import taboolib.module.database.ColumnTypePostgreSQL /** * TabooLib diff --git a/module/database/database-ptc-object/src/main/kotlin/taboolib/expansion/DataMapper.kt b/module/database/database-ptc-object/src/main/kotlin/taboolib/expansion/DataMapper.kt index 1025ec8da..5c52d536e 100644 --- a/module/database/database-ptc-object/src/main/kotlin/taboolib/expansion/DataMapper.kt +++ b/module/database/database-ptc-object/src/main/kotlin/taboolib/expansion/DataMapper.kt @@ -1,10 +1,6 @@ package taboolib.expansion -import taboolib.module.database.Action -import taboolib.module.database.ActionDelete -import taboolib.module.database.ActionSelect -import taboolib.module.database.ActionUpdate -import taboolib.module.database.Filter +import taboolib.module.database.* import java.sql.ResultSet /** diff --git a/module/database/database-ptc-object/src/main/kotlin/taboolib/expansion/JoinQuery.kt b/module/database/database-ptc-object/src/main/kotlin/taboolib/expansion/JoinQuery.kt index 65fde9117..24eba8b9c 100644 --- a/module/database/database-ptc-object/src/main/kotlin/taboolib/expansion/JoinQuery.kt +++ b/module/database/database-ptc-object/src/main/kotlin/taboolib/expansion/JoinQuery.kt @@ -5,7 +5,6 @@ import taboolib.common5.* import taboolib.expansion.orm.AnalyzedClass import taboolib.expansion.orm.AnalyzedClassMember import taboolib.expansion.orm.AnalyzedClassMember.Companion.resolveTableName -import taboolib.expansion.orm.AnalyzedClassMember.Companion.toColumnName import taboolib.expansion.orm.EntityMapper import taboolib.module.database.ActionSelect import taboolib.module.database.Filter @@ -16,6 +15,20 @@ import java.sql.Connection import java.sql.ResultSet import java.sql.SQLException import java.util.* +import kotlin.Any +import kotlin.Array +import kotlin.Enum +import kotlin.Int +import kotlin.Pair +import kotlin.PublishedApi +import kotlin.String +import kotlin.Unit +import kotlin.also +import kotlin.apply +import kotlin.arrayOf +import kotlin.error +import kotlin.to +import kotlin.use /** * 多表联查 DSL diff --git a/module/database/database-ptc-object/src/main/kotlin/taboolib/expansion/MapperDelegate.kt b/module/database/database-ptc-object/src/main/kotlin/taboolib/expansion/MapperDelegate.kt index c87f6f926..bf51ab0a9 100644 --- a/module/database/database-ptc-object/src/main/kotlin/taboolib/expansion/MapperDelegate.kt +++ b/module/database/database-ptc-object/src/main/kotlin/taboolib/expansion/MapperDelegate.kt @@ -1,6 +1,7 @@ package taboolib.expansion import taboolib.expansion.mapper.DataMapperImpl +import taboolib.expansion.migration.MigrationConfig import kotlin.properties.ReadOnlyProperty import kotlin.reflect.KProperty @@ -164,4 +165,4 @@ class MapperDelegate( } return DataMapperImpl(type, container, mapperConfig.cacheInstance) } -} \ No newline at end of file +} diff --git a/module/database/database-ptc-object/src/main/kotlin/taboolib/expansion/PersistentContainer.kt b/module/database/database-ptc-object/src/main/kotlin/taboolib/expansion/PersistentContainer.kt index b62d56312..e0c7ff992 100644 --- a/module/database/database-ptc-object/src/main/kotlin/taboolib/expansion/PersistentContainer.kt +++ b/module/database/database-ptc-object/src/main/kotlin/taboolib/expansion/PersistentContainer.kt @@ -7,9 +7,8 @@ import taboolib.expansion.container.ContainerPostgreSQL import taboolib.expansion.container.ContainerSQL import taboolib.expansion.container.ContainerSQLite import taboolib.expansion.mapper.DataMapperImpl +import taboolib.expansion.migration.MigrationFiles import taboolib.expansion.orm.AnalyzedClass -import taboolib.expansion.orm.AnalyzedClassMember.Companion.resolveTableName -import taboolib.expansion.orm.AnalyzedClassMember.Companion.toColumnName import taboolib.library.configuration.ConfigurationSection import taboolib.module.configuration.ConfigLoader import taboolib.module.configuration.Configuration @@ -178,6 +177,38 @@ class PersistentContainer { container.createTable(AnalyzedClass.of(type), name ?: container.resolveTableName(type)) } + /** + * 启用 SQL 文件迁移。 + * 默认扫描 classpath 下 `ptc-migrations/`,脚本命名为 `V版本__说明.sql`。 + * + * @param path classpath 中的迁移目录 + * @param statementSeparator 同一个 SQL 文件中分隔多条语句的独占行标记 + * @param baselineOnCreate 新库自动建最新表后,是否把现有迁移脚本标记为已执行 + * @param validateChecksum 是否校验已执行脚本的 SHA-256 + * @param failOnMissingMigration 历史表中存在但资源目录缺失的脚本是否阻止启动 + * @param baselineVersion 已有老库首次接入迁移时,标记为已执行的最大版本 + * @param classLoader 读取迁移脚本资源的类加载器 + */ + fun migrations( + path: String = MigrationFiles.DEFAULT_PATH, + statementSeparator: String = MigrationFiles.DEFAULT_STATEMENT_SEPARATOR, + baselineOnCreate: Boolean = true, + validateChecksum: Boolean = true, + failOnMissingMigration: Boolean = true, + baselineVersion: Int? = null, + classLoader: ClassLoader = Thread.currentThread().contextClassLoader ?: MigrationFiles::class.java.classLoader, + ) { + container.migrationFiles = MigrationFiles( + path = path, + statementSeparator = statementSeparator, + baselineOnCreate = baselineOnCreate, + validateChecksum = validateChecksum, + failOnMissingMigration = failOnMissingMigration, + baselineVersion = baselineVersion, + classLoader = classLoader, + ) + } + /** * 解析带前缀的表名 */ diff --git a/module/database/database-ptc-object/src/main/kotlin/taboolib/expansion/TransactionContext.kt b/module/database/database-ptc-object/src/main/kotlin/taboolib/expansion/TransactionContext.kt index c626ca2c4..0d7ad92e4 100644 --- a/module/database/database-ptc-object/src/main/kotlin/taboolib/expansion/TransactionContext.kt +++ b/module/database/database-ptc-object/src/main/kotlin/taboolib/expansion/TransactionContext.kt @@ -2,8 +2,6 @@ package taboolib.expansion import taboolib.expansion.container.Container import taboolib.expansion.operator.ContainerOperatorImpl -import taboolib.expansion.orm.AnalyzedClassMember.Companion.resolveTableName -import taboolib.expansion.orm.AnalyzedClassMember.Companion.toColumnName import java.sql.Connection /** diff --git a/module/database/database-ptc-object/src/main/kotlin/taboolib/expansion/container/Container.kt b/module/database/database-ptc-object/src/main/kotlin/taboolib/expansion/container/Container.kt index 8a2268775..f4df41e5c 100644 --- a/module/database/database-ptc-object/src/main/kotlin/taboolib/expansion/container/Container.kt +++ b/module/database/database-ptc-object/src/main/kotlin/taboolib/expansion/container/Container.kt @@ -3,12 +3,12 @@ package taboolib.expansion.container import org.tabooproject.reflex.Reflex.Companion.invokeMethod import taboolib.expansion.CollectionTableInfo import taboolib.expansion.ContainerOperator -import taboolib.expansion.MigrationConfig +import taboolib.expansion.migration.MigrationConfig +import taboolib.expansion.migration.MigrationFiles import taboolib.expansion.operator.ContainerOperatorImpl import taboolib.expansion.orm.AnalyzedClass import taboolib.expansion.orm.AnalyzedClassMember import taboolib.expansion.orm.AnalyzedClassMember.Companion.resolveTableName -import taboolib.expansion.orm.AnalyzedClassMember.Companion.toColumnName import taboolib.module.database.ColumnBuilder import taboolib.module.database.Host import taboolib.module.database.Table @@ -40,6 +40,9 @@ abstract class Container(val host: Host) { /** 版本迁移配置 */ internal var migrationInstance: MigrationConfig? = null + /** SQL 文件迁移配置 */ + internal var migrationFiles: MigrationFiles? = null + /** 数据库方言(由子类提供) */ protected abstract val dialect: DatabaseDialect @@ -97,6 +100,12 @@ abstract class Container(val host: Host) { /** 初始化所有表 */ open fun init() { + val migrationRunner = migrationFiles?.runner(dataSource) + val freshDatabase = migrationRunner?.isFreshDatabase() == true + if (!freshDatabase) { + migrationRunner?.migrate() + } + if (manualTableStatements != null) { // 手动建表:执行用户提供的 SQL dataSource.connection.use { conn -> @@ -112,6 +121,9 @@ abstract class Container(val host: Host) { infos.forEach { it.table.createTable(dataSource) } } } + if (freshDatabase) { + migrationRunner?.baselineLatest() + } // 版本迁移 migrationInstance?.let { runMigrations(it) } // 方言后处理(如创建索引) diff --git a/module/database/database-ptc-object/src/main/kotlin/taboolib/expansion/container/ContainerPostgreSQL.kt b/module/database/database-ptc-object/src/main/kotlin/taboolib/expansion/container/ContainerPostgreSQL.kt index fa1f435e5..c9dc9f04a 100644 --- a/module/database/database-ptc-object/src/main/kotlin/taboolib/expansion/container/ContainerPostgreSQL.kt +++ b/module/database/database-ptc-object/src/main/kotlin/taboolib/expansion/container/ContainerPostgreSQL.kt @@ -1,6 +1,8 @@ package taboolib.expansion.container -import taboolib.module.database.* +import taboolib.module.database.HostPostgreSQL +import taboolib.module.database.PostgreSQL +import taboolib.module.database.use class ContainerPostgreSQL( host: String, diff --git a/module/database/database-ptc-object/src/main/kotlin/taboolib/expansion/container/ContainerSQL.kt b/module/database/database-ptc-object/src/main/kotlin/taboolib/expansion/container/ContainerSQL.kt index 75d53537e..141bfd362 100644 --- a/module/database/database-ptc-object/src/main/kotlin/taboolib/expansion/container/ContainerSQL.kt +++ b/module/database/database-ptc-object/src/main/kotlin/taboolib/expansion/container/ContainerSQL.kt @@ -1,6 +1,7 @@ package taboolib.expansion.container -import taboolib.module.database.* +import taboolib.module.database.HostSQL +import taboolib.module.database.SQL class ContainerSQL( host: String, diff --git a/module/database/database-ptc-object/src/main/kotlin/taboolib/expansion/container/ContainerSQLite.kt b/module/database/database-ptc-object/src/main/kotlin/taboolib/expansion/container/ContainerSQLite.kt index fadc09007..30c622523 100644 --- a/module/database/database-ptc-object/src/main/kotlin/taboolib/expansion/container/ContainerSQLite.kt +++ b/module/database/database-ptc-object/src/main/kotlin/taboolib/expansion/container/ContainerSQLite.kt @@ -1,6 +1,7 @@ package taboolib.expansion.container -import taboolib.module.database.* +import taboolib.module.database.HostSQLite +import taboolib.module.database.SQLite import java.io.File class ContainerSQLite(file: File) : Container(HostSQLite(file)) { diff --git a/module/database/database-ptc-object/src/main/kotlin/taboolib/expansion/container/DatabaseDialect.kt b/module/database/database-ptc-object/src/main/kotlin/taboolib/expansion/container/DatabaseDialect.kt index a9e6e87c3..0eaf18834 100644 --- a/module/database/database-ptc-object/src/main/kotlin/taboolib/expansion/container/DatabaseDialect.kt +++ b/module/database/database-ptc-object/src/main/kotlin/taboolib/expansion/container/DatabaseDialect.kt @@ -193,9 +193,11 @@ object SQLiteDialect : DatabaseDialect { keyColumns[name] = keys } return Table(name, host as Host) { - // SQLite 始终使用自增 ID 作为主键 - // @Id 字段通过 CREATE INDEX 建普通索引(与 MySQL 的 KEY 行为对齐) - add { id() } + // 没有自定义 @Id 字段时才加自增 id 主键,与 MySQLDialect、Annotations 文档对齐; + // 有 @Id 时由该字段承担查询键,postInit 建普通索引,避免与自增 id 列同名冲突 + if (!type.members.any { it.isPrimary }) { + add { id() } + } type.members.forEach { member -> // 跳过 @Ignore 成员 if (member.isIgnored) return@forEach diff --git a/module/database/database-ptc-object/src/main/kotlin/taboolib/expansion/mapper/AbstractDataMapper.kt b/module/database/database-ptc-object/src/main/kotlin/taboolib/expansion/mapper/AbstractDataMapper.kt index 737d3a087..d6afd42c5 100644 --- a/module/database/database-ptc-object/src/main/kotlin/taboolib/expansion/mapper/AbstractDataMapper.kt +++ b/module/database/database-ptc-object/src/main/kotlin/taboolib/expansion/mapper/AbstractDataMapper.kt @@ -1,10 +1,6 @@ package taboolib.expansion.mapper -import taboolib.expansion.ContainerOperator -import taboolib.expansion.Cursor -import taboolib.expansion.DataMapper -import taboolib.expansion.L2Cache -import taboolib.expansion.Page +import taboolib.expansion.* import taboolib.expansion.operator.ContainerOperatorImpl import taboolib.expansion.orm.AnalyzedClass import taboolib.expansion.orm.EntityMapper diff --git a/module/database/database-ptc-object/src/main/kotlin/taboolib/expansion/mapper/DataMapperImpl.kt b/module/database/database-ptc-object/src/main/kotlin/taboolib/expansion/mapper/DataMapperImpl.kt index b49339411..bed877eec 100644 --- a/module/database/database-ptc-object/src/main/kotlin/taboolib/expansion/mapper/DataMapperImpl.kt +++ b/module/database/database-ptc-object/src/main/kotlin/taboolib/expansion/mapper/DataMapperImpl.kt @@ -1,11 +1,6 @@ package taboolib.expansion.mapper -import taboolib.expansion.DataMapper -import taboolib.expansion.JoinQuery -import taboolib.expansion.L2Cache -import taboolib.expansion.PersistentContainer -import taboolib.expansion.ContainerOperator -import taboolib.expansion.orm.AnalyzedClassMember.Companion.resolveTableName +import taboolib.expansion.* /** * DataMapper 的标准实现 diff --git a/module/database/database-ptc-object/src/main/kotlin/taboolib/expansion/mapper/TransactionalDataMapper.kt b/module/database/database-ptc-object/src/main/kotlin/taboolib/expansion/mapper/TransactionalDataMapper.kt index a6e9579bc..c7a42ca26 100644 --- a/module/database/database-ptc-object/src/main/kotlin/taboolib/expansion/mapper/TransactionalDataMapper.kt +++ b/module/database/database-ptc-object/src/main/kotlin/taboolib/expansion/mapper/TransactionalDataMapper.kt @@ -1,11 +1,7 @@ package taboolib.expansion.mapper -import taboolib.expansion.ContainerOperator -import taboolib.expansion.Cursor -import taboolib.expansion.DataMapper -import taboolib.expansion.JoinQuery -import taboolib.expansion.L2Cache -import taboolib.module.database.* +import taboolib.expansion.* +import taboolib.module.database.Filter import java.sql.Connection /** diff --git a/module/database/database-ptc-object/src/main/kotlin/taboolib/expansion/MigrationConfig.kt b/module/database/database-ptc-object/src/main/kotlin/taboolib/expansion/migration/MigrationConfig.kt similarity index 96% rename from module/database/database-ptc-object/src/main/kotlin/taboolib/expansion/MigrationConfig.kt rename to module/database/database-ptc-object/src/main/kotlin/taboolib/expansion/migration/MigrationConfig.kt index a010ec2cc..6fde9a43c 100644 --- a/module/database/database-ptc-object/src/main/kotlin/taboolib/expansion/MigrationConfig.kt +++ b/module/database/database-ptc-object/src/main/kotlin/taboolib/expansion/migration/MigrationConfig.kt @@ -1,4 +1,4 @@ -package taboolib.expansion +package taboolib.expansion.migration /** * 版本迁移配置 diff --git a/module/database/database-ptc-object/src/main/kotlin/taboolib/expansion/migration/MigrationException.kt b/module/database/database-ptc-object/src/main/kotlin/taboolib/expansion/migration/MigrationException.kt new file mode 100644 index 000000000..7deafa33c --- /dev/null +++ b/module/database/database-ptc-object/src/main/kotlin/taboolib/expansion/migration/MigrationException.kt @@ -0,0 +1,9 @@ +package taboolib.expansion.migration + +/** + * SQL 文件迁移执行失败时抛出的异常。 + * + * @param message 失败原因 + * @param cause 原始异常 + */ +open class MigrationException(message: String, cause: Throwable? = null) : RuntimeException(message, cause) diff --git a/module/database/database-ptc-object/src/main/kotlin/taboolib/expansion/migration/MigrationFiles.kt b/module/database/database-ptc-object/src/main/kotlin/taboolib/expansion/migration/MigrationFiles.kt new file mode 100644 index 000000000..e58b31f71 --- /dev/null +++ b/module/database/database-ptc-object/src/main/kotlin/taboolib/expansion/migration/MigrationFiles.kt @@ -0,0 +1,52 @@ +package taboolib.expansion.migration + +import javax.sql.DataSource + +/** + * SQL 文件迁移配置。 + * 默认扫描 classpath 下 `ptc-migrations/`,脚本命名为 `V版本__说明.sql`。 + * + * @property path classpath 中的迁移目录 + * @property statementSeparator 同一个 SQL 文件中分隔多条语句的独占行标记 + * @property baselineOnCreate 新库自动建最新表后,是否把现有迁移脚本标记为已执行 + * @property validateChecksum 是否校验已执行脚本的 SHA-256 + * @property failOnMissingMigration 历史表中存在但资源目录缺失的脚本是否阻止启动 + * @property baselineVersion 已有老库首次接入迁移时,标记为已执行的最大版本 + * @property classLoader 读取迁移脚本资源的类加载器 + */ +class MigrationFiles( + val path: String = DEFAULT_PATH, + val statementSeparator: String = DEFAULT_STATEMENT_SEPARATOR, + val baselineOnCreate: Boolean = true, + val validateChecksum: Boolean = true, + val failOnMissingMigration: Boolean = true, + val baselineVersion: Int? = null, + val classLoader: ClassLoader = Thread.currentThread().contextClassLoader ?: MigrationFiles::class.java.classLoader, +) { + + /** + * 创建当前数据源的迁移执行器。 + * + * @param dataSource PTC 容器的数据源 + * @return 迁移执行器 + */ + fun runner(dataSource: DataSource): MigrationRunner { + return MigrationRunner( + dataSource = dataSource, + files = this, + ) + } + + companion object { + + /** + * 默认迁移脚本目录。 + */ + const val DEFAULT_PATH = "ptc-migrations" + + /** + * 默认 SQL 语句分段标记。 + */ + const val DEFAULT_STATEMENT_SEPARATOR = "-- @ptc:statement" + } +} diff --git a/module/database/database-ptc-object/src/main/kotlin/taboolib/expansion/migration/MigrationRecord.kt b/module/database/database-ptc-object/src/main/kotlin/taboolib/expansion/migration/MigrationRecord.kt new file mode 100644 index 000000000..d5f0dd5c1 --- /dev/null +++ b/module/database/database-ptc-object/src/main/kotlin/taboolib/expansion/migration/MigrationRecord.kt @@ -0,0 +1,20 @@ +package taboolib.expansion.migration + +/** + * `_ptc_schema_history` 中的一条已执行迁移记录。 + * + * @property version 迁移版本号 + * @property description 文件名中的描述部分 + * @property script 文件名 + * @property checksum 执行时记录的 SHA-256 + * @property appliedAt 执行完成时间戳 + * @property executionTime 执行耗时毫秒 + */ +data class MigrationRecord( + val version: Int, + val description: String, + val script: String, + val checksum: String, + val appliedAt: Long, + val executionTime: Long, +) diff --git a/module/database/database-ptc-object/src/main/kotlin/taboolib/expansion/migration/MigrationResourceScanner.kt b/module/database/database-ptc-object/src/main/kotlin/taboolib/expansion/migration/MigrationResourceScanner.kt new file mode 100644 index 000000000..d5edb1960 --- /dev/null +++ b/module/database/database-ptc-object/src/main/kotlin/taboolib/expansion/migration/MigrationResourceScanner.kt @@ -0,0 +1,89 @@ +package taboolib.expansion.migration + +import java.io.File +import java.net.JarURLConnection +import java.util.jar.JarFile + +/** + * 从 classpath 目录中扫描 SQL 迁移脚本。 + * + * @property files SQL 文件迁移配置 + */ +class MigrationResourceScanner( + val files: MigrationFiles, +) { + + /** + * 读取、解析并按版本排序迁移脚本。 + * + * @return 迁移脚本列表 + */ + fun load(): List { + val scripts = collectResourcePaths().map { resourcePath -> + val bytes = files.classLoader.getResourceAsStream(resourcePath)?.use { it.readBytes() } + ?: throw MigrationException("Migration script not found: $resourcePath") + MigrationScript.fromResource(resourcePath, bytes) + }.sortedBy { it.version } + + val duplicate = scripts.groupBy { it.version }.filterValues { it.size > 1 }.keys.firstOrNull() + if (duplicate != null) { + throw MigrationException("Duplicate migration version: V$duplicate") + } + return scripts + } + + /** + * 收集迁移目录下的脚本资源路径。 + * 仅读取目录第一层,避免跨模块误扫子目录。 + * + * @return classpath 资源路径列表 + */ + fun collectResourcePaths(): List { + val normalizedPath = files.path.trim('/').replace('\\', '/') + val resources = linkedSetOf() + val urls = files.classLoader.getResources(normalizedPath) + while (urls.hasMoreElements()) { + val url = urls.nextElement() + when (url.protocol) { + "file" -> collectFileResources(File(url.toURI()), normalizedPath, resources) + "jar" -> collectJarResources(url.openConnection() as JarURLConnection, normalizedPath, resources) + } + } + return resources.filter { it.substringAfterLast('/').matches(MigrationScript.NAME_PATTERN) }.sorted() + } + + /** + * 从文件系统 classpath 目录收集脚本。 + * + * @param directory classpath 对应目录 + * @param normalizedPath 标准化后的迁移目录 + * @param resources 收集到的资源路径 + */ + fun collectFileResources(directory: File, normalizedPath: String, resources: MutableSet) { + val files = directory.listFiles() ?: return + for (file in files) { + if (file.isFile) { + resources += "$normalizedPath/${file.name}" + } + } + } + + /** + * 从 Jar classpath 目录收集脚本。 + * + * @param connection Jar 资源连接 + * @param normalizedPath 标准化后的迁移目录 + * @param resources 收集到的资源路径 + */ + fun collectJarResources(connection: JarURLConnection, normalizedPath: String, resources: MutableSet) { + val prefix = "$normalizedPath/" + val jarFile: JarFile = connection.jarFile + val entries = jarFile.entries() + while (entries.hasMoreElements()) { + val entry = entries.nextElement() + if (!entry.isDirectory && entry.name.startsWith(prefix) && '/' !in entry.name.removePrefix(prefix)) { + resources += entry.name + } + } + } +} diff --git a/module/database/database-ptc-object/src/main/kotlin/taboolib/expansion/migration/MigrationRunner.kt b/module/database/database-ptc-object/src/main/kotlin/taboolib/expansion/migration/MigrationRunner.kt new file mode 100644 index 000000000..ff83dcc2f --- /dev/null +++ b/module/database/database-ptc-object/src/main/kotlin/taboolib/expansion/migration/MigrationRunner.kt @@ -0,0 +1,254 @@ +package taboolib.expansion.migration + +import java.sql.Connection +import java.sql.ResultSet +import javax.sql.DataSource + +/** + * SQL 文件迁移执行器。 + * 负责维护 `_ptc_schema_history`,并在自动建表前执行待应用脚本。 + * + * @property dataSource PTC 容器的数据源 + * @property files SQL 文件迁移配置 + */ +class MigrationRunner( + val dataSource: DataSource, + val files: MigrationFiles, +) { + + /** + * 判断当前库是否还没有业务表。 + * `_ptc_schema_history` 会先创建,因此不计入业务表。 + * + * @return 是否为新库 + */ + fun isFreshDatabase(): Boolean { + dataSource.connection.use { connection -> + prepareHistoryTable(connection) + val tableNames = mutableSetOf() + connection.metaData.getTables(connection.catalog, null, "%", arrayOf("TABLE")).use { resultSet -> + while (resultSet.next()) { + tableNames += resultSet.getString("TABLE_NAME").lowercase() + } + } + tableNames -= "_ptc_schema_history" + tableNames -= "sqlite_sequence" + return tableNames.isEmpty() + } + } + + /** + * 执行所有尚未应用的 SQL 文件迁移。 + */ + fun migrate() { + val scripts = MigrationResourceScanner(files).load() + dataSource.connection.use { connection -> + prepareHistoryTable(connection) + val records = loadRecords(connection) + validateHistory(scripts, records) + baselineConfiguredVersion(connection, scripts, records) + + val appliedVersions = loadRecords(connection).map { it.version }.toSet() + for (script in scripts.filter { it.version !in appliedVersions }) { + applyScript(connection, script) + } + } + } + + /** + * 新库自动建表完成后,把当前所有脚本标记为已执行。 + */ + fun baselineLatest() { + if (!files.baselineOnCreate) { + return + } + val scripts = MigrationResourceScanner(files).load() + if (scripts.isEmpty()) { + return + } + dataSource.connection.use { connection -> + prepareHistoryTable(connection) + val appliedVersions = loadRecords(connection).map { it.version }.toSet() + for (script in scripts.filter { it.version !in appliedVersions }) { + insertRecord( + connection = connection, + script = script, + executionTime = 0, + ) + } + } + } + + /** + * 创建迁移历史表。 + * + * @param connection 数据库连接 + */ + fun prepareHistoryTable(connection: Connection) { + connection.createStatement().use { statement -> + statement.executeUpdate( + "CREATE TABLE IF NOT EXISTS _ptc_schema_history (" + + "installed_rank INT NOT NULL PRIMARY KEY, " + + "version INT NOT NULL, " + + "description VARCHAR(255) NOT NULL, " + + "script VARCHAR(255) NOT NULL, " + + "checksum VARCHAR(64) NOT NULL, " + + "applied_at BIGINT NOT NULL, " + + "execution_time BIGINT NOT NULL)" + ) + } + } + + /** + * 读取已执行迁移记录。 + * + * @param connection 数据库连接 + * @return 迁移历史记录 + */ + fun loadRecords(connection: Connection): List { + connection.createStatement().use { statement -> + statement.executeQuery( + "SELECT version, description, script, checksum, applied_at, execution_time " + + "FROM _ptc_schema_history ORDER BY installed_rank" + ).use { resultSet -> + val records = mutableListOf() + while (resultSet.next()) { + records += resultSet.toMigrationRecord() + } + return records + } + } + } + + /** + * 校验历史记录与当前资源目录是否一致。 + * + * @param scripts 当前资源目录中的迁移脚本 + * @param records 历史表中的迁移记录 + */ + fun validateHistory(scripts: List, records: List) { + val scriptsByVersion = scripts.associateBy { it.version } + for (record in records) { + val script = scriptsByVersion[record.version] + if (script == null) { + if (files.failOnMissingMigration) { + throw MigrationValidationException("Applied migration is missing: V${record.version} ${record.script}") + } + continue + } + if (files.validateChecksum && record.checksum != script.checksum) { + throw MigrationValidationException("Migration checksum changed: V${record.version} ${record.script}") + } + } + } + + /** + * 老库首次接入 SQL 文件迁移时,按配置跳过指定版本及以前的脚本。 + * + * @param connection 数据库连接 + * @param scripts 当前资源目录中的迁移脚本 + * @param records 历史表中的迁移记录 + */ + fun baselineConfiguredVersion(connection: Connection, scripts: List, records: List) { + val baselineVersion = files.baselineVersion ?: return + if (records.isNotEmpty()) { + return + } + for (script in scripts.filter { it.version <= baselineVersion }) { + insertRecord( + connection = connection, + script = script, + executionTime = 0, + ) + } + } + + /** + * 在事务中执行单个迁移脚本。 + * 文件内多条语句必须使用显式分段标记,避免按分号误拆 SQL 字符串或函数体。 + * + * @param connection 数据库连接 + * @param script 待执行脚本 + */ + fun applyScript(connection: Connection, script: MigrationScript) { + val previousAutoCommit = connection.autoCommit + val startedAt = System.currentTimeMillis() + connection.autoCommit = false + try { + connection.createStatement().use { statement -> + for (sql in MigrationStatementReader.read(script.sql, files.statementSeparator)) { + statement.execute(sql) + } + } + insertRecord( + connection = connection, + script = script, + executionTime = System.currentTimeMillis() - startedAt, + ) + connection.commit() + } catch (ex: Exception) { + connection.rollback() + throw MigrationException("Failed to apply migration ${script.script}", ex) + } finally { + connection.autoCommit = previousAutoCommit + } + } + + /** + * 写入迁移历史记录。 + * + * @param connection 数据库连接 + * @param script 迁移脚本 + * @param executionTime 执行耗时毫秒 + */ + fun insertRecord(connection: Connection, script: MigrationScript, executionTime: Long) { + connection.prepareStatement( + "INSERT INTO _ptc_schema_history " + + "(installed_rank, version, description, script, checksum, applied_at, execution_time) " + + "VALUES (?, ?, ?, ?, ?, ?, ?)" + ).use { statement -> + statement.setInt(1, nextInstalledRank(connection)) + statement.setInt(2, script.version) + statement.setString(3, script.description) + statement.setString(4, script.script) + statement.setString(5, script.checksum) + statement.setLong(6, System.currentTimeMillis()) + statement.setLong(7, executionTime) + statement.executeUpdate() + } + } + + /** + * 获取下一条历史记录序号。 + * + * @param connection 数据库连接 + * @return 下一条 `installed_rank` + */ + fun nextInstalledRank(connection: Connection): Int { + connection.createStatement().use { statement -> + statement.executeQuery("SELECT MAX(installed_rank) FROM _ptc_schema_history").use { resultSet -> + return if (resultSet.next()) { + resultSet.getInt(1) + 1 + } else { + 1 + } + } + } + } + + /** + * 把 JDBC 结果行转换为迁移记录。 + * + * @return 迁移历史记录 + */ + fun ResultSet.toMigrationRecord(): MigrationRecord { + return MigrationRecord( + version = getInt("version"), + description = getString("description"), + script = getString("script"), + checksum = getString("checksum"), + appliedAt = getLong("applied_at"), + executionTime = getLong("execution_time"), + ) + } +} diff --git a/module/database/database-ptc-object/src/main/kotlin/taboolib/expansion/migration/MigrationScript.kt b/module/database/database-ptc-object/src/main/kotlin/taboolib/expansion/migration/MigrationScript.kt new file mode 100644 index 000000000..8bc5eac1b --- /dev/null +++ b/module/database/database-ptc-object/src/main/kotlin/taboolib/expansion/migration/MigrationScript.kt @@ -0,0 +1,59 @@ +package taboolib.expansion.migration + +import java.security.MessageDigest + +/** + * 单个 SQL 迁移脚本。 + * + * @property version 迁移版本号 + * @property description 文件名中的描述部分 + * @property script 文件名 + * @property checksum SQL 文件原始内容的 SHA-256 + * @property sql SQL 文件正文 + */ +data class MigrationScript( + val version: Int, + val description: String, + val script: String, + val checksum: String, + val sql: String, +) { + + companion object { + + /** + * 迁移脚本命名规则:`V版本__说明.sql`。 + */ + val NAME_PATTERN = Regex("^V(\\d+)__(.+)\\.sql$") + + /** + * 根据资源路径和文件内容创建脚本模型。 + * + * @param resourcePath classpath 资源路径 + * @param bytes SQL 文件原始字节 + * @return 迁移脚本模型 + */ + fun fromResource(resourcePath: String, bytes: ByteArray): MigrationScript { + val fileName = resourcePath.substringAfterLast('/') + val match = NAME_PATTERN.matchEntire(fileName) ?: throw MigrationException("Invalid migration script name: $fileName") + return MigrationScript( + version = match.groupValues[1].toInt(), + description = match.groupValues[2], + script = fileName, + checksum = sha256(bytes), + sql = bytes.toString(Charsets.UTF_8), + ) + } + + /** + * 计算 SQL 文件校验值。 + * + * @param bytes 文件原始字节 + * @return 十六进制 SHA-256 + */ + fun sha256(bytes: ByteArray): String { + val digest = MessageDigest.getInstance("SHA-256").digest(bytes) + return digest.joinToString("") { "%02x".format(it) } + } + } +} diff --git a/module/database/database-ptc-object/src/main/kotlin/taboolib/expansion/migration/MigrationStatementReader.kt b/module/database/database-ptc-object/src/main/kotlin/taboolib/expansion/migration/MigrationStatementReader.kt new file mode 100644 index 000000000..ad5f0915a --- /dev/null +++ b/module/database/database-ptc-object/src/main/kotlin/taboolib/expansion/migration/MigrationStatementReader.kt @@ -0,0 +1,44 @@ +package taboolib.expansion.migration + +/** + * 迁移文件语句读取器。 + * 不解析 SQL 语法,只按独占一行的显式标记切分语句,避免误处理字符串、注释或数据库函数体。 + */ +object MigrationStatementReader { + + /** + * 读取迁移文件中的 SQL 语句块。 + * 没有分段标记时,整个文件作为一条语句执行。 + * + * @param sql SQL 文件正文 + * @param separator 独占一行的语句分段标记 + * @return 待执行 SQL 语句块 + */ + fun read(sql: String, separator: String): List { + val statements = mutableListOf() + val builder = StringBuilder() + for (line in sql.lineSequence()) { + if (line.trim() == separator) { + addStatement(statements, builder) + continue + } + builder.appendLine(line) + } + addStatement(statements, builder) + return statements + } + + /** + * 写入非空 SQL 语句块。 + * + * @param statements 已读取语句列表 + * @param builder 当前语句缓冲区 + */ + fun addStatement(statements: MutableList, builder: StringBuilder) { + val statement = builder.toString().trim() + if (statement.isNotEmpty()) { + statements += statement + } + builder.clear() + } +} diff --git a/module/database/database-ptc-object/src/main/kotlin/taboolib/expansion/migration/MigrationValidationException.kt b/module/database/database-ptc-object/src/main/kotlin/taboolib/expansion/migration/MigrationValidationException.kt new file mode 100644 index 000000000..c59d15c51 --- /dev/null +++ b/module/database/database-ptc-object/src/main/kotlin/taboolib/expansion/migration/MigrationValidationException.kt @@ -0,0 +1,8 @@ +package taboolib.expansion.migration + +/** + * SQL 文件迁移历史校验失败时抛出的异常。 + * + * @param message 校验失败原因 + */ +class MigrationValidationException(message: String) : MigrationException(message) diff --git a/module/database/database-ptc-object/src/main/kotlin/taboolib/expansion/operator/CollectionTableHandler.kt b/module/database/database-ptc-object/src/main/kotlin/taboolib/expansion/operator/CollectionTableHandler.kt index 690cf051a..3e02417cf 100644 --- a/module/database/database-ptc-object/src/main/kotlin/taboolib/expansion/operator/CollectionTableHandler.kt +++ b/module/database/database-ptc-object/src/main/kotlin/taboolib/expansion/operator/CollectionTableHandler.kt @@ -1,18 +1,12 @@ package taboolib.expansion.operator -import taboolib.expansion.CollectionTableInfo -import taboolib.expansion.CustomTypeFactory -import taboolib.expansion.DatabaseList -import taboolib.expansion.DatabaseMap -import taboolib.expansion.DatabaseSet -import taboolib.expansion.IndexedEnum +import taboolib.expansion.* import taboolib.expansion.orm.AnalyzedClass import taboolib.expansion.orm.AnalyzedClassMember import taboolib.module.database.ActionSelect import taboolib.module.database.Filter import taboolib.module.database.asFormattedColumnName import java.sql.Connection -import javax.sql.DataSource /** * Collection 子表处理器(Map/List/Set 存储) diff --git a/module/database/database-ptc-object/src/main/kotlin/taboolib/expansion/operator/ContainerOperatorImpl.kt b/module/database/database-ptc-object/src/main/kotlin/taboolib/expansion/operator/ContainerOperatorImpl.kt index 6d18087f4..d78e2048e 100644 --- a/module/database/database-ptc-object/src/main/kotlin/taboolib/expansion/operator/ContainerOperatorImpl.kt +++ b/module/database/database-ptc-object/src/main/kotlin/taboolib/expansion/operator/ContainerOperatorImpl.kt @@ -1,25 +1,12 @@ package taboolib.expansion.operator import taboolib.common.platform.function.warning -import taboolib.expansion.CollectionTableInfo -import taboolib.expansion.ContainerOperator -import taboolib.expansion.Cursor -import taboolib.expansion.CustomTypeFactory -import taboolib.expansion.DatabaseList -import taboolib.expansion.DatabaseMap -import taboolib.expansion.DatabaseSet -import taboolib.expansion.IndexedEnum -import taboolib.expansion.TransactionContext +import taboolib.expansion.* import taboolib.expansion.orm.AnalyzedClass import taboolib.expansion.orm.AnalyzedClassMember import taboolib.expansion.orm.EntityMapper import taboolib.module.database.* -import taboolib.module.database.asFormattedColumnName -import taboolib.module.database.setupQuoterForHost -import java.sql.Connection -import java.sql.PreparedStatement -import java.sql.ResultSet -import java.sql.SQLException +import java.sql.* import java.sql.Statement import javax.sql.DataSource diff --git a/module/database/database-ptc-object/src/main/kotlin/taboolib/expansion/operator/LinkTableHandler.kt b/module/database/database-ptc-object/src/main/kotlin/taboolib/expansion/operator/LinkTableHandler.kt index b2afcc986..624742e63 100644 --- a/module/database/database-ptc-object/src/main/kotlin/taboolib/expansion/operator/LinkTableHandler.kt +++ b/module/database/database-ptc-object/src/main/kotlin/taboolib/expansion/operator/LinkTableHandler.kt @@ -1,6 +1,5 @@ package taboolib.expansion.operator -import taboolib.expansion.CollectionTableInfo import taboolib.expansion.ContainerOperator import taboolib.expansion.CustomTypeFactory import taboolib.expansion.IndexedEnum diff --git a/module/database/database-ptc-object/src/main/kotlin/taboolib/expansion/orm/AnalyzedClass.kt b/module/database/database-ptc-object/src/main/kotlin/taboolib/expansion/orm/AnalyzedClass.kt index 11b05d426..66785634b 100644 --- a/module/database/database-ptc-object/src/main/kotlin/taboolib/expansion/orm/AnalyzedClass.kt +++ b/module/database/database-ptc-object/src/main/kotlin/taboolib/expansion/orm/AnalyzedClass.kt @@ -2,11 +2,8 @@ package taboolib.expansion.orm import org.tabooproject.reflex.Reflex.Companion.getProperty import taboolib.common.util.t -import taboolib.common5.* import taboolib.expansion.BundleMap -import taboolib.expansion.BundleMapImpl import taboolib.expansion.CustomTypeFactory -import taboolib.expansion.IndexedEnum import java.lang.reflect.Parameter import java.util.concurrent.ConcurrentHashMap diff --git a/module/database/database-ptc-object/src/main/kotlin/taboolib/expansion/orm/AnalyzedClassMember.kt b/module/database/database-ptc-object/src/main/kotlin/taboolib/expansion/orm/AnalyzedClassMember.kt index 77ab2f3cf..4cc5ffcd4 100644 --- a/module/database/database-ptc-object/src/main/kotlin/taboolib/expansion/orm/AnalyzedClassMember.kt +++ b/module/database/database-ptc-object/src/main/kotlin/taboolib/expansion/orm/AnalyzedClassMember.kt @@ -1,21 +1,10 @@ package taboolib.expansion.orm import taboolib.common.reflect.getAnnotationIfPresent -import taboolib.expansion.Alias -import taboolib.expansion.ColumnType -import taboolib.expansion.CustomTypeFactory -import taboolib.expansion.Id -import taboolib.expansion.Ignore -import taboolib.expansion.IndexedEnum -import taboolib.expansion.Key -import taboolib.expansion.Length -import taboolib.expansion.LinkTable -import taboolib.expansion.NotNull -import taboolib.expansion.TableName -import taboolib.expansion.UniqueKey +import taboolib.expansion.* +import taboolib.module.database.ColumnTypePostgreSQL import taboolib.module.database.ColumnTypeSQL import taboolib.module.database.ColumnTypeSQLite -import taboolib.module.database.ColumnTypePostgreSQL import java.lang.reflect.AnnotatedElement import java.lang.reflect.Field import java.lang.reflect.Parameter diff --git a/module/database/database-ptc-object/src/test/kotlin/com/zaxxer/hikari_4_0_3/HikariConfig.kt b/module/database/database-ptc-object/src/test/kotlin/com/zaxxer/hikari_4_0_3/HikariConfig.kt new file mode 100644 index 000000000..134ac11c6 --- /dev/null +++ b/module/database/database-ptc-object/src/test/kotlin/com/zaxxer/hikari_4_0_3/HikariConfig.kt @@ -0,0 +1,7 @@ +package com.zaxxer.hikari_4_0_3 + +/** + * 测试环境使用 database 模块 shadow 产物,字节码会引用重定位后的 HikariConfig。 + * 生产环境由 TabooLib 运行时依赖提供,这里只在 test classpath 代理到原始 Hikari。 + */ +class HikariConfig : com.zaxxer.hikari.HikariConfig() diff --git a/module/database/database-ptc-object/src/test/kotlin/com/zaxxer/hikari_4_0_3/HikariDataSource.kt b/module/database/database-ptc-object/src/test/kotlin/com/zaxxer/hikari_4_0_3/HikariDataSource.kt new file mode 100644 index 000000000..b6ab0ba52 --- /dev/null +++ b/module/database/database-ptc-object/src/test/kotlin/com/zaxxer/hikari_4_0_3/HikariDataSource.kt @@ -0,0 +1,7 @@ +package com.zaxxer.hikari_4_0_3 + +/** + * 测试环境使用 database 模块 shadow 产物,字节码会引用重定位后的 HikariDataSource。 + * 生产环境由 TabooLib 运行时依赖提供,这里只在 test classpath 代理到原始 Hikari。 + */ +class HikariDataSource(config: HikariConfig) : com.zaxxer.hikari.HikariDataSource(config) diff --git a/module/database/database-ptc-object/src/test/kotlin/taboolib/expansion/migration/MigrationRunnerTest.kt b/module/database/database-ptc-object/src/test/kotlin/taboolib/expansion/migration/MigrationRunnerTest.kt new file mode 100644 index 000000000..7a5fe6fe8 --- /dev/null +++ b/module/database/database-ptc-object/src/test/kotlin/taboolib/expansion/migration/MigrationRunnerTest.kt @@ -0,0 +1,69 @@ +package taboolib.expansion.migration + +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertThrows +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.io.TempDir +import taboolib.expansion.createTestDataSource +import java.net.URLClassLoader +import java.nio.file.Path + +class MigrationRunnerTest { + + @TempDir + lateinit var tempDir: Path + + @Test + fun `migrate applies separated statements once`() { + val migrationDirectory = tempDir.resolve(MigrationFiles.DEFAULT_PATH).toFile() + migrationDirectory.mkdirs() + migrationDirectory.resolve("V1__init.sql").writeText( + """ + CREATE TABLE migration_sample (id TEXT PRIMARY KEY, value INT NOT NULL) + -- @ptc:statement + INSERT INTO migration_sample (id, value) VALUES ('a', 1) + """.trimIndent() + ) + + val dataSource = createTestDataSource() + URLClassLoader(arrayOf(tempDir.toUri().toURL()), javaClass.classLoader).use { classLoader -> + val runner = MigrationFiles(classLoader = classLoader).runner(dataSource) + runner.migrate() + runner.migrate() + } + + dataSource.connection.use { connection -> + connection.createStatement().use { statement -> + statement.executeQuery("SELECT COUNT(*) FROM migration_sample").use { resultSet -> + resultSet.next() + assertEquals(1, resultSet.getInt(1)) + } + statement.executeQuery("SELECT COUNT(*) FROM _ptc_schema_history").use { resultSet -> + resultSet.next() + assertEquals(1, resultSet.getInt(1)) + } + } + } + dataSource.close() + } + + @Test + fun `migrate rejects changed checksum`() { + val migrationDirectory = tempDir.resolve(MigrationFiles.DEFAULT_PATH).toFile() + migrationDirectory.mkdirs() + val script = migrationDirectory.resolve("V1__init.sql") + script.writeText("CREATE TABLE checksum_sample (id TEXT PRIMARY KEY)") + + val dataSource = createTestDataSource() + URLClassLoader(arrayOf(tempDir.toUri().toURL()), javaClass.classLoader).use { classLoader -> + val runner = MigrationFiles(classLoader = classLoader).runner(dataSource) + runner.migrate() + script.writeText("CREATE TABLE checksum_sample (id TEXT PRIMARY KEY, value INT)") + + assertThrows(MigrationValidationException::class.java) { + runner.migrate() + } + } + dataSource.close() + } +} diff --git a/module/database/database-ptc-object/src/test/kotlin/taboolib/expansion/migration/PersistentContainerMigrationTest.kt b/module/database/database-ptc-object/src/test/kotlin/taboolib/expansion/migration/PersistentContainerMigrationTest.kt new file mode 100644 index 000000000..8119317ee --- /dev/null +++ b/module/database/database-ptc-object/src/test/kotlin/taboolib/expansion/migration/PersistentContainerMigrationTest.kt @@ -0,0 +1,132 @@ +package taboolib.expansion.migration + +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.io.TempDir +import taboolib.expansion.Id +import taboolib.expansion.persistentContainer +import taboolib.module.configuration.Configuration +import taboolib.module.database.Database +import java.lang.reflect.Proxy +import java.net.URLClassLoader +import java.nio.file.Path +import java.sql.DriverManager + +class PersistentContainerMigrationTest { + + @TempDir + lateinit var tempDir: Path + + @BeforeEach + fun setUp() { + Database.settingsFile = Proxy.newProxyInstance( + Configuration::class.java.classLoader, + arrayOf(Configuration::class.java), + ) { _, method, args -> + when (method.name) { + "contains" -> false + "getBoolean", "getInt", "getLong", "getString" -> args?.getOrNull(1) + "getConfigurationSection", "getFile" -> null + "getReloadGeneration" -> 0 + "saveToString" -> "" + else -> null + } + } as Configuration + } + + @Test + fun `persistent container migrates existing database before binding operators`() { + val databaseFile = tempDir.resolve("existing.db").toFile() + DriverManager.getConnection("jdbc:sqlite:${databaseFile.absolutePath}").use { connection -> + connection.createStatement().use { statement -> + statement.executeUpdate("CREATE TABLE e2e_existing_data (id TEXT PRIMARY KEY)") + statement.executeUpdate("INSERT INTO e2e_existing_data (id) VALUES ('old')") + } + } + val migrationDirectory = tempDir.resolve(MigrationFiles.DEFAULT_PATH).toFile() + migrationDirectory.mkdirs() + migrationDirectory.resolve("V1__add_value.sql").writeText( + """ + ALTER TABLE e2e_existing_data ADD COLUMN value INT DEFAULT 0 + -- @ptc:statement + UPDATE e2e_existing_data SET value = 7 WHERE id = 'old' + """.trimIndent() + ) + + URLClassLoader(arrayOf(tempDir.toUri().toURL()), javaClass.classLoader).use { classLoader -> + val container = persistentContainer(type = databaseFile) { + migrations(classLoader = classLoader) + new("e2e_existing_data") + } + container.close() + } + + DriverManager.getConnection("jdbc:sqlite:${databaseFile.absolutePath}").use { connection -> + connection.createStatement().use { statement -> + statement.executeQuery("SELECT value FROM e2e_existing_data WHERE id = 'old'").use { resultSet -> + assertTrue(resultSet.next()) + assertEquals(7, resultSet.getInt("value")) + } + statement.executeQuery("SELECT COUNT(*) FROM _ptc_schema_history").use { resultSet -> + resultSet.next() + assertEquals(1, resultSet.getInt(1)) + } + } + } + } + + @Test + fun `persistent container baselines fresh database without running historical scripts`() { + val databaseFile = tempDir.resolve("fresh.db").toFile() + val migrationDirectory = tempDir.resolve(MigrationFiles.DEFAULT_PATH).toFile() + migrationDirectory.mkdirs() + migrationDirectory.resolve("V1__historical_change.sql").writeText( + "ALTER TABLE e2e_fresh_data ADD COLUMN historical_value INT" + ) + + URLClassLoader(arrayOf(tempDir.toUri().toURL()), javaClass.classLoader).use { classLoader -> + val firstContainer = persistentContainer(type = databaseFile) { + migrations(classLoader = classLoader) + new("e2e_fresh_data") + } + firstContainer.close() + + val secondContainer = persistentContainer(type = databaseFile) { + migrations(classLoader = classLoader) + new("e2e_fresh_data") + } + secondContainer.close() + } + + DriverManager.getConnection("jdbc:sqlite:${databaseFile.absolutePath}").use { connection -> + connection.createStatement().use { statement -> + statement.executeQuery("PRAGMA table_info(e2e_fresh_data)").use { resultSet -> + val columns = mutableSetOf() + while (resultSet.next()) { + columns += resultSet.getString("name") + } + assertTrue("id" in columns) + assertTrue("value" in columns) + assertTrue("historical_value" !in columns) + } + statement.executeQuery("SELECT COUNT(*) FROM _ptc_schema_history").use { resultSet -> + resultSet.next() + assertEquals(1, resultSet.getInt(1)) + } + } + } + } + +} + +data class ExistingMigrationData( + @Id val id: String, + val value: Int, +) + +data class FreshMigrationData( + @Id val id: String, + val value: Int, +) From 2650e277175b3593ef6f66d1c0897b7a2c4d4c39 Mon Sep 17 00:00:00 2001 From: Micalhl Date: Tue, 23 Jun 2026 19:40:43 +0800 Subject: [PATCH 03/37] =?UTF-8?q?=E6=94=AF=E6=8C=81=2026.2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/main/kotlin/taboolib/module/nms/MinecraftVersion.kt | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/module/bukkit-nms/src/main/kotlin/taboolib/module/nms/MinecraftVersion.kt b/module/bukkit-nms/src/main/kotlin/taboolib/module/nms/MinecraftVersion.kt index a138ff617..57176e16f 100644 --- a/module/bukkit-nms/src/main/kotlin/taboolib/module/nms/MinecraftVersion.kt +++ b/module/bukkit-nms/src/main/kotlin/taboolib/module/nms/MinecraftVersion.kt @@ -39,6 +39,7 @@ object MinecraftVersion { const val V1_20 = 12 const val V1_21 = 13 const val V26_1 = 14 + const val V26_2 = 15 /** * 当前运行的版本(字符版本),例如:v1_8_R3 @@ -102,7 +103,8 @@ object MinecraftVersion { arrayOf("1.19", "1.19.1", "1.19.2", "1.19.3", "1.19.4"), // 11 arrayOf("1.20", "1.20.1", "1.20.2", "!1.20.3", "1.20.4", "!1.20.5", "1.20.6"), // 12 (跳过 1.20.3、1.20.5) NOTICE 从 1.20.5 开始, paper 进行了破坏性修改 arrayOf("!1.21", "1.21.1", "!1.21.2", "1.21.3", "1.21.4", "1.21.5", "!1.21.6", "!1.21.7", "1.21.8", "!1.21.9", "1.21.10", "1.21.11"), // 13 (跳过 1.21、1.21.2、1.21.6、1.21.7 和 1.21.9) - arrayOf("!26.1", "!26.1.1", "26.1.2") // 14 (跳过 26.1、26.1.1) NOTICE 从 26.1 开始, Minecraft 不再被混淆 + arrayOf("!26.1", "!26.1.1", "26.1.2"), // 14 (跳过 26.1、26.1.1) NOTICE 从 26.1 开始, Minecraft 不再被混淆 + arrayOf("26.2") // 15 // @formatter:on ) @@ -131,6 +133,7 @@ object MinecraftVersion { V1_20 -> 12000 V1_21 -> 12100 V26_1 -> 260100 + V26_2 -> 260200 else -> 0 } + minor } From 5a914060ae430493a4b3a595d88643ff80f1ae2d Mon Sep 17 00:00:00 2001 From: Micalhl Date: Tue, 23 Jun 2026 20:27:11 +0800 Subject: [PATCH 04/37] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=20ItemTag?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/main/kotlin/taboolib/module/nms/NMSItemTagImpl2.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/module/bukkit-nms/bukkit-nms-tag/bukkit-nms-tag-modern/src/main/kotlin/taboolib/module/nms/NMSItemTagImpl2.kt b/module/bukkit-nms/bukkit-nms-tag/bukkit-nms-tag-modern/src/main/kotlin/taboolib/module/nms/NMSItemTagImpl2.kt index a7c938f28..fd0a66de6 100644 --- a/module/bukkit-nms/bukkit-nms-tag/bukkit-nms-tag-modern/src/main/kotlin/taboolib/module/nms/NMSItemTagImpl2.kt +++ b/module/bukkit-nms/bukkit-nms-tag/bukkit-nms-tag-modern/src/main/kotlin/taboolib/module/nms/NMSItemTagImpl2.kt @@ -51,7 +51,7 @@ class NMSItemTagImpl2 : NMSItemTag() { return if (onlyCustom) { val originTag = nmsItem.get(DataComponents.CUSTOM_DATA) // java.lang.NoSuchMethodError: 'net.minecraft.nbt.NBTTagCompound net.minecraft.world.item.component.CustomData.copyTag()' - val tag = if (originTag == null) null else dynamic(DynamicOpcode.INVOKEVIRTUAL, "net.minecraft.nbt.CompoundTag#copyTag()net.minecraft.nbt.CompoundTag;", originTag) + val tag = if (originTag == null) null else dynamic(DynamicOpcode.INVOKEVIRTUAL, "net.minecraft.world.item.component.CustomData#copyTag()net.minecraft.nbt.CompoundTag;", originTag) if (tag != null) itemTagToBukkitCopy(tag, true).asCompound() else ItemTag() } else { val tag = nmsItem.toNbt() From 25d44a011b9e9086a13f774e99e0c4c955794d0c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=BB=91?= Date: Wed, 24 Jun 2026 02:39:47 +0800 Subject: [PATCH 05/37] =?UTF-8?q?fix(database):=20=E4=BF=AE=E5=A4=8D=20SQL?= =?UTF-8?q?ite=20=E8=BF=9E=E6=8E=A5=E5=8F=82=E6=95=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- module/database/build.gradle.kts | 4 +- .../taboolib/module/database/Database.kt | 49 +++++----- .../module/database/DatabaseTest.java | 92 +++++++++++++++++++ 3 files changed, 120 insertions(+), 25 deletions(-) create mode 100644 module/database/src/test/java/taboolib/module/database/DatabaseTest.java diff --git a/module/database/build.gradle.kts b/module/database/build.gradle.kts index b7ecf61b4..5f7c7b7cb 100644 --- a/module/database/build.gradle.kts +++ b/module/database/build.gradle.kts @@ -7,10 +7,12 @@ dependencies { compileOnly(project(":common-platform-api")) compileOnly(project(":common-util")) compileOnly(project(":module:basic:basic-configuration")) + testImplementation("com.zaxxer:HikariCP:4.0.3") + testImplementation("org.xerial:sqlite-jdbc:3.42.0.0") } tasks { withType { relocate("com.zaxxer.hikari.", "com.zaxxer.hikari_4_0_3.") } -} \ No newline at end of file +} diff --git a/module/database/src/main/kotlin/taboolib/module/database/Database.kt b/module/database/src/main/kotlin/taboolib/module/database/Database.kt index 8e877fe0b..2219b8799 100644 --- a/module/database/src/main/kotlin/taboolib/module/database/Database.kt +++ b/module/database/src/main/kotlin/taboolib/module/database/Database.kt @@ -53,18 +53,7 @@ object Database { */ fun createDataSourceWithoutConfig(host: Host<*>): DataSource { val config = HikariConfig() - config.jdbcUrl = host.connectionUrl - when (host) { - is HostSQL -> { - config.username = host.user - config.password = host.password - } - is HostPostgreSQL -> { - config.username = host.user - config.password = host.password - } - else -> error("Unsupported host: $host") - } + config.applyHost(host) return HikariDataSource(config) } @@ -73,18 +62,7 @@ object Database { */ fun createHikariConfig(host: Host<*>): HikariConfig { val config = HikariConfig() - config.jdbcUrl = host.connectionUrl - when (host) { - is HostSQL -> { - config.username = host.user - config.password = host.password - } - is HostPostgreSQL -> { - config.username = host.user - config.password = host.password - } - } - config.driverClassName = host.driverClass + config.applyHost(host) config.isAutoCommit = settingsFile.getBoolean("DefaultSettings.AutoCommit", true) config.minimumIdle = settingsFile.getInt("DefaultSettings.MinimumIdle", 1) config.maximumPoolSize = settingsFile.getInt("DefaultSettings.MaximumPoolSize", 10) @@ -103,4 +81,27 @@ object Database { } return config } + + private fun HikariConfig.applyHost(host: Host<*>) { + when (host) { + is HostSQL -> { + jdbcUrl = host.connectionUrl + username = host.user + password = host.password + } + is HostPostgreSQL -> { + jdbcUrl = host.connectionUrl + username = host.user + password = host.password + } + // SQLite 连接初始化:开启 WAL、busy_timeout、synchronous,降低多线程写冲突概率 + // WAL 让写不阻塞读,busy_timeout 让写冲突排队等待而非立刻 SQLITE_BUSY + // SQLite 仍是文件级写锁,应用层仍应串行写库;这里只降低偶发冲突 + is HostSQLite -> { + jdbcUrl = "${host.connectionUrl}?journal_mode=WAL&busy_timeout=5000&synchronous=NORMAL" + } + else -> error("Unsupported host: $host") + } + driverClassName = host.driverClass + } } diff --git a/module/database/src/test/java/taboolib/module/database/DatabaseTest.java b/module/database/src/test/java/taboolib/module/database/DatabaseTest.java new file mode 100644 index 000000000..7801b56c6 --- /dev/null +++ b/module/database/src/test/java/taboolib/module/database/DatabaseTest.java @@ -0,0 +1,92 @@ +package taboolib.module.database; + +import com.zaxxer.hikari.HikariDataSource; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import java.nio.file.Path; +import java.sql.Connection; +import java.sql.ResultSet; +import java.sql.Statement; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.Future; +import java.util.concurrent.TimeUnit; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +public class DatabaseTest { + + @TempDir + Path tempDir; + + @Test + public void createDataSourceWithoutConfigShouldApplySQLiteConnectionParameters() throws Exception { + HostSQLite host = new HostSQLite(tempDir.resolve("database.db").toFile()); + + try (HikariDataSource dataSource = (HikariDataSource) Database.INSTANCE.createDataSourceWithoutConfig(host)) { + try (Connection connection = dataSource.getConnection()) { + assertEquals("wal", readPragma(connection, "journal_mode")); + assertEquals("5000", readPragma(connection, "busy_timeout")); + assertEquals("1", readPragma(connection, "synchronous")); + } + } + } + + @Test + public void createDataSourceWithoutConfigShouldWaitForConcurrentSQLiteWrite() throws Exception { + HostSQLite host = new HostSQLite(tempDir.resolve("concurrent.db").toFile()); + ExecutorService executorService = Executors.newSingleThreadExecutor(); + + try (HikariDataSource dataSource = (HikariDataSource) Database.INSTANCE.createDataSourceWithoutConfig(host)) { + try (Connection connection = dataSource.getConnection()) { + execute(connection, "CREATE TABLE test_data (id INTEGER PRIMARY KEY AUTOINCREMENT, value TEXT)"); + } + + try (Connection first = dataSource.getConnection()) { + first.setAutoCommit(false); + execute(first, "INSERT INTO test_data (value) VALUES ('first')"); + + Future concurrentWrite = executorService.submit(new Runnable() { + @Override + public void run() { + try (Connection second = dataSource.getConnection()) { + execute(second, "INSERT INTO test_data (value) VALUES ('second')"); + } catch (Exception e) { + throw new RuntimeException(e); + } + } + }); + + Thread.sleep(250); + first.commit(); + concurrentWrite.get(2, TimeUnit.SECONDS); + } + + try (Connection connection = dataSource.getConnection()) { + assertEquals("2", readSql(connection, "SELECT COUNT(*) FROM test_data")); + } + } finally { + executorService.shutdownNow(); + } + } + + String readPragma(Connection connection, String name) throws Exception { + return readSql(connection, "PRAGMA " + name); + } + + String readSql(Connection connection, String sql) throws Exception { + try (Statement statement = connection.createStatement()) { + try (ResultSet resultSet = statement.executeQuery(sql)) { + resultSet.next(); + return resultSet.getString(1); + } + } + } + + void execute(Connection connection, String sql) throws Exception { + try (Statement statement = connection.createStatement()) { + statement.execute(sql); + } + } +} From 3630cccb3b32b8e8379e7abc3bef4296ca8030d7 Mon Sep 17 00:00:00 2001 From: zhibei <785740487@qq.com> Date: Mon, 29 Jun 2026 00:03:19 +0800 Subject: [PATCH 06/37] =?UTF-8?q?fix(env):=20=E5=90=AF=E5=8A=A8=E4=B8=BB?= =?UTF-8?q?=E7=BA=BF=E7=A8=8B=E9=A2=84=E7=83=AD=E5=8D=8F=E7=A8=8B=E8=BF=90?= =?UTF-8?q?=E8=A1=8C=E6=97=B6=EF=BC=8C=E4=BF=AE=E5=A4=8D=E5=A4=9A=E6=8F=92?= =?UTF-8?q?=E4=BB=B6=E5=85=B1=E5=AD=98=E6=97=B6=E7=99=BB=E5=BD=95=E5=81=B6?= =?UTF-8?q?=E5=8F=91=E5=B4=A9=E6=BA=83?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 非隔离模式(默认)下,kotlinx-coroutines 被重定位成版本键控的共享包 (kotlinx.coroutines)加载进各插件 PluginClassLoader;因包名跨插件相同, Paper 插件类加载器组使其跨插件共享、首加载者定义。Dispatchers / CoroutineExceptionHandler 的首次初始化走 ServiceLoader,对触发线程 / 类加载器上下文敏感——当首次触发落在 AsyncPlayerPreLogin("User Authenticator")这类敌对线程时(很多插件在登录时做异步取数), 初始化会非确定性失败(NoClassDefFoundError)并永久毒化共享类,导致此后所有 TabooLib 插件的协程全部不可用。仅在多个 TabooLib 插件共存时偶发、极难排查。 修复:RuntimeEnv.init()(插件加载阶段、运行于启动主线程)加载完协程后,立即用 Class.forName(name, true, loader) 强制这些类在主线程完成首次初始化。JVM 保证类只初始化 一次,敌对线程此后只复用、不再触发脆弱的首次初始化。预热为尽力而为,任何失败都被吞掉, 零行为回归;仅在声明了协程(KOTLIN_COROUTINES_VERSION != null)的插件生效, 不改变任何 API / 加载语义,无新增依赖。 --- .../java/taboolib/common/env/RuntimeEnv.java | 55 +++++++++++++++++++ 1 file changed, 55 insertions(+) diff --git a/common-env/src/main/java/taboolib/common/env/RuntimeEnv.java b/common-env/src/main/java/taboolib/common/env/RuntimeEnv.java index ace1283f1..1b7de4f16 100644 --- a/common-env/src/main/java/taboolib/common/env/RuntimeEnv.java +++ b/common-env/src/main/java/taboolib/common/env/RuntimeEnv.java @@ -2,6 +2,7 @@ import org.jetbrains.annotations.NotNull; import org.tabooproject.reflex.ReflexClass; +import taboolib.common.ClassAppender; import taboolib.common.PrimitiveIO; import taboolib.common.PrimitiveSettings; import taboolib.common.TabooLib; @@ -71,9 +72,63 @@ static void init() { } catch (Throwable e) { throw new RuntimeException(e); } + // 在【启动主线程】预热协程运行时,规避其首次初始化落在 AsyncPlayerPreLogin 等敌对线程上导致的 + // 非确定性崩溃(多 TabooLib 插件共存时偶发,详见 warmupKotlinCoroutines)。 + if (!KOTLIN_COROUTINES_VERSION.equals("null")) { + warmupKotlinCoroutines(); + } })); } + /** + * 在【启动主线程】预热(强制初始化)协程运行时中那些「{@code } 经 {@link java.util.ServiceLoader} + * 装配、对首次初始化所在线程 / 类加载器上下文敏感」的类。 + * + *

背景:为什么需要它

+ * 非隔离模式(默认)下,TabooLib 把 kotlinx-coroutines 重定位成版本键控的共享包 + * ({@link PrimitiveSettings#getRelocatedKotlinCoroutinesVersion()},如 {@code kotlin2120x.coroutines1101}) + * 加载进各插件的 PluginClassLoader;由于包名跨插件相同,Paper 的插件类加载器组会让它在多个 TabooLib 插件间 + * 共享 / 首加载者定义。而 {@code Dispatchers} / {@code CoroutineExceptionHandler} 的首次初始化走 ServiceLoader, + * 一旦它**首次**被触发的线程是 Bukkit 的 {@code AsyncPlayerPreLogin}("User Authenticator")这类敌对线程 + * (contextClassLoader 非插件 CL),初始化会非确定性失败({@code NoClassDefFoundError})并**永久毒化该共享类**, + * 使此后所有 TabooLib 插件的协程全部不可用。该问题仅在多个 TabooLib 插件共存时偶发,且极难排查。 + * + *

修复原理

+ * 在插件加载阶段({@link #init()},运行于启动主线程)就用 {@link Class#forName(String, boolean, ClassLoader)} + * 强制这些类完成首次初始化。JVM 保证类只初始化一次,之后任意线程再触碰都直接复用,不会再触发脆弱的首次初始化。 + * 预热为尽力而为:任何失败都被吞掉,绝不影响插件正常加载。 + */ + private static void warmupKotlinCoroutines() { + try { + ClassLoader loader = ClassAppender.getClassLoader(); + // 协程可能以「重定位包」(非隔离模式由 TabooLib 加载) 或「原始包」(隔离模式 / 服务端自带 / 其它来源) 存在; + // 两种包名都尝试,命中哪个就预热哪个,未命中的经 try/catch 无害空转。 + warmupCoroutinePackage(loader, PrimitiveSettings.getRelocatedKotlinCoroutinesVersion()); + warmupCoroutinePackage(loader, KOTLIN_COROUTINES_ID); + PrimitiveIO.debug("协程运行时已在启动线程 [{0}] 预热。", Thread.currentThread().getName()); + } catch (Throwable ignored) { + // 预热为尽力而为,绝不因其失败而中断插件加载。 + } + } + + private static void warmupCoroutinePackage(ClassLoader loader, String pkg) { + // 这些类的 含 ServiceLoader / 对线程敏感,是历史登录崩溃的炸点;强制其在当前(启动主)线程初始化。 + // 类名随协程版本可能有差异,逐个 try/catch(不存在则无害跳过);其中 Dispatchers / CoroutineExceptionHandler 名称稳定。 + String[] classes = { + pkg + ".Dispatchers", + pkg + ".CoroutineExceptionHandler", + pkg + ".internal.MainDispatcherLoader", + pkg + ".CoroutineExceptionHandlerImplKt", + }; + for (String name : classes) { + try { + Class.forName(name, true, loader); + } catch (Throwable ignored) { + // 该类不存在(版本差异)或该包未加载,忽略。 + } + } + } + public int inject(@NotNull ReflexClass clazz) throws Throwable { int total = 0; total += ENV_ASSETS.loadAssets(clazz); From 9d8750579f895e488d21c52d095b7afda2bba76f Mon Sep 17 00:00:00 2001 From: zhibei <785740487@qq.com> Date: Thu, 2 Jul 2026 20:06:29 +0800 Subject: [PATCH 07/37] =?UTF-8?q?fix(env):=20=E5=AE=8C=E5=96=84=E5=8D=8F?= =?UTF-8?q?=E7=A8=8B=E9=A2=84=E7=83=AD=E7=B1=BB=E5=88=97=E8=A1=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 补充当前协程版本内部异常处理实现类,并仅在实际命中类时输出预热调试信息。 --- .../main/java/taboolib/common/env/RuntimeEnv.java | 15 +++++++++++---- 1 file changed, 11 insertions(+), 4 deletions(-) diff --git a/common-env/src/main/java/taboolib/common/env/RuntimeEnv.java b/common-env/src/main/java/taboolib/common/env/RuntimeEnv.java index 1b7de4f16..3ce3c4fbb 100644 --- a/common-env/src/main/java/taboolib/common/env/RuntimeEnv.java +++ b/common-env/src/main/java/taboolib/common/env/RuntimeEnv.java @@ -103,15 +103,18 @@ private static void warmupKotlinCoroutines() { ClassLoader loader = ClassAppender.getClassLoader(); // 协程可能以「重定位包」(非隔离模式由 TabooLib 加载) 或「原始包」(隔离模式 / 服务端自带 / 其它来源) 存在; // 两种包名都尝试,命中哪个就预热哪个,未命中的经 try/catch 无害空转。 - warmupCoroutinePackage(loader, PrimitiveSettings.getRelocatedKotlinCoroutinesVersion()); - warmupCoroutinePackage(loader, KOTLIN_COROUTINES_ID); - PrimitiveIO.debug("协程运行时已在启动线程 [{0}] 预热。", Thread.currentThread().getName()); + int warmed = 0; + warmed += warmupCoroutinePackage(loader, PrimitiveSettings.getRelocatedKotlinCoroutinesVersion()); + warmed += warmupCoroutinePackage(loader, KOTLIN_COROUTINES_ID); + if (warmed > 0) { + PrimitiveIO.debug("协程运行时已在启动线程 [{0}] 预热 {1} 个类。", Thread.currentThread().getName(), warmed); + } } catch (Throwable ignored) { // 预热为尽力而为,绝不因其失败而中断插件加载。 } } - private static void warmupCoroutinePackage(ClassLoader loader, String pkg) { + private static int warmupCoroutinePackage(ClassLoader loader, String pkg) { // 这些类的 含 ServiceLoader / 对线程敏感,是历史登录崩溃的炸点;强制其在当前(启动主)线程初始化。 // 类名随协程版本可能有差异,逐个 try/catch(不存在则无害跳过);其中 Dispatchers / CoroutineExceptionHandler 名称稳定。 String[] classes = { @@ -119,14 +122,18 @@ private static void warmupCoroutinePackage(ClassLoader loader, String pkg) { pkg + ".CoroutineExceptionHandler", pkg + ".internal.MainDispatcherLoader", pkg + ".CoroutineExceptionHandlerImplKt", + pkg + ".internal.CoroutineExceptionHandlerImplKt", }; + int warmed = 0; for (String name : classes) { try { Class.forName(name, true, loader); + warmed++; } catch (Throwable ignored) { // 该类不存在(版本差异)或该包未加载,忽略。 } } + return warmed; } public int inject(@NotNull ReflexClass clazz) throws Throwable { From fa2d0759a88f5a31cc715589a20c23ce3ab55871 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=BB=91?= Date: Sat, 11 Jul 2026 17:31:36 +0800 Subject: [PATCH 08/37] =?UTF-8?q?fix(nms):=20=E8=B7=B3=E8=BF=87=E5=90=88?= =?UTF-8?q?=E6=88=90=20Lambda=20=E7=9A=84=20Spigot=20=E8=AF=91=E5=90=8D?= =?UTF-8?q?=E5=91=8A=E8=AD=A6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - nameInSpigot/nameInMojang 对 isSynthetic 与 Lambda 合成类静默返回 null - 修复 nameInMojang 误写入 spigotNameCache --- .../kotlin/taboolib/module/nms/PacketImpl.kt | 21 ++++++++++++++++++- 1 file changed, 20 insertions(+), 1 deletion(-) diff --git a/module/bukkit-nms/src/main/kotlin/taboolib/module/nms/PacketImpl.kt b/module/bukkit-nms/src/main/kotlin/taboolib/module/nms/PacketImpl.kt index 740480304..de025f20b 100644 --- a/module/bukkit-nms/src/main/kotlin/taboolib/module/nms/PacketImpl.kt +++ b/module/bukkit-nms/src/main/kotlin/taboolib/module/nms/PacketImpl.kt @@ -30,6 +30,11 @@ class PacketImpl(override var source: Any) : Packet() { if (spigotNameCache.containsKey(fullyName)) { return spigotNameCache[fullyName]!!.orNull() } + // JVM 合成 Lambda / 合成类不会出现在 Spigot 映射表中(如配置阶段 UnconfiguredPipelineHandler$Lambda) + if (isUnmappedSyntheticClass()) { + spigotNameCache[fullyName] = Optional.empty() + return null + } val find = MinecraftVersion.paperMapping.classMapMojangToSpigot[fullyName]?.substringAfterLast('.') if (find == null) { warning( @@ -52,6 +57,11 @@ class PacketImpl(override var source: Any) : Packet() { if (mojangNameCache.containsKey(fullyName)) { return mojangNameCache[fullyName]!!.orNull() } + // JVM 合成 Lambda / 合成类不会出现在 Mojang 映射表中 + if (isUnmappedSyntheticClass()) { + mojangNameCache[fullyName] = Optional.empty() + return null + } // 1.16 及以下版本,尝试获取 Spigot 译名 val realFullyName = if (!MinecraftVersion.isUniversal) MinecraftVersion.spigotMapping.classMapSpigotS2F[name] ?: fullyName @@ -65,13 +75,22 @@ class PacketImpl(override var source: Any) : Packet() { """.t() ) } - spigotNameCache[fullyName] = Optional.ofNullable(find) + mojangNameCache[fullyName] = Optional.ofNullable(find) return find } /** 数据包完整名称 */ override var fullyName = source.javaClass.name.toString() + /** + * 判断是否为无需映射的 JVM 合成类(Lambda、匿名类等)。 + * 配置阶段 Netty 会把这类对象送进 Channel,映射表无对应项。 + */ + fun isUnmappedSyntheticClass(): Boolean { + // HotSpot Lambda 类名形如 Outer$Lambda/0x... + return source.javaClass.isSynthetic || fullyName.contains("$\$Lambda") + } + /** 读取字段 */ override fun read(name: String, remap: Boolean): T? { return source.getProperty(name, remap = remap) From 0eb2aa691e26f1566a94b28eff8f580af485ab42 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=BB=91?= Date: Sun, 12 Jul 2026 04:17:29 +0800 Subject: [PATCH 09/37] =?UTF-8?q?fix(nms):=20=E8=A1=A5=E6=89=AB=E5=BB=B6?= =?UTF-8?q?=E8=BF=9F=E6=B3=A8=E5=86=8C=E7=9A=84=E6=95=B0=E6=8D=AE=E5=8C=85?= =?UTF-8?q?=E9=80=9A=E9=81=93?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 在 ServerLoadEvent 后幂等补扫本地服务端通道 - 多 TabooLib 插件共存时仅覆盖主注入器遗漏的通道 --- .../taboolib/module/nms/MeteorInjector.java | 74 ++++++++++++++----- .../taboolib/module/nms/ProtocolHandler.kt | 27 +++++-- 2 files changed, 75 insertions(+), 26 deletions(-) diff --git a/module/bukkit-nms/src/main/java/taboolib/module/nms/MeteorInjector.java b/module/bukkit-nms/src/main/java/taboolib/module/nms/MeteorInjector.java index f4283e6c2..6fb902f71 100644 --- a/module/bukkit-nms/src/main/java/taboolib/module/nms/MeteorInjector.java +++ b/module/bukkit-nms/src/main/java/taboolib/module/nms/MeteorInjector.java @@ -4,6 +4,11 @@ import io.netty.channel.*; import org.bukkit.Bukkit; import org.bukkit.entity.Player; +import org.bukkit.event.EventHandler; +import org.bukkit.event.EventPriority; +import org.bukkit.event.HandlerList; +import org.bukkit.event.Listener; +import org.bukkit.event.server.ServerLoadEvent; import org.bukkit.plugin.Plugin; import org.jetbrains.annotations.NotNull; import org.jetbrains.annotations.Nullable; @@ -43,6 +48,8 @@ public class MeteorInjector implements Closeable { private static final Method GAME_PROFILE_ID = getMethod(GameProfile.class, MinecraftVersion.INSTANCE.getVersionId() > 12108 ? "id" : "getId"); + private static final String IDENTIFIER_PREFIX = "meteor-injector-"; + private final Plugin plugin; private final String identifier; private static int ID = 0; @@ -50,6 +57,7 @@ public class MeteorInjector implements Closeable { private final AtomicBoolean closed = new AtomicBoolean(false); private final Thread thread; + private final Listener serverLoadListener; // Netty channels maintained by ServerConnection; used to attach interceptors as soon as they appear. private final List channels; // Track injected player channels to cleanly remove the handler on shutdown. @@ -100,30 +108,55 @@ public MeteorInjector(@NotNull Plugin plugin) { while (channels.isEmpty()); if (isClosed()) return; - // Typically, Channels on the server side contain only one element, but this is done just to be safe. - synchronized (channels) { - for (ChannelFuture channel : channels) { - channel.channel().pipeline().addFirst(identifier, new ChannelInboundHandlerAdapter() { - - @Override - public void channelRead(ChannelHandlerContext channelHandlerContext, Object o) throws Exception { - try { - if (o instanceof Channel) { - Channel ch = (Channel) o; - new NettyPipelineInjector(ch.pipeline()); - } - } finally { - super.channelRead(channelHandlerContext, o); - } - } - }); - } - } + injectParentHandlers(); Thread.yield(); }, identifier); thread.setDaemon(true); thread.start(); + + // ServerLoadEvent 后补扫一次,捕获插件启用阶段尚未注册的本地通道。 + serverLoadListener = new Listener() { + + @EventHandler(priority = EventPriority.MONITOR) + public void onServerLoad(ServerLoadEvent event) { + HandlerList.unregisterAll(this); + injectParentHandlers(); + } + }; + Bukkit.getPluginManager().registerEvents(serverLoadListener, plugin); + } + + /** + * 为尚未被任意 MeteorInjector 覆盖的服务端父通道添加监听器。 + */ + private void injectParentHandlers() { + if (isClosed()) return; + synchronized (channels) { + // 双重检查:close() 在进入此同步块之前已置位 closed,此处防止延迟扫描与 close() 竞争时重复注入。 + if (isClosed()) return; + // Typically, Channels on the server side contain only one element, but this is done just to be safe. + for (ChannelFuture channel : channels) { + ChannelPipeline pipeline = channel.channel().pipeline(); + if (pipeline.names().stream().anyMatch(name -> name.startsWith(IDENTIFIER_PREFIX))) { + continue; + } + pipeline.addFirst(identifier, new ChannelInboundHandlerAdapter() { + + @Override + public void channelRead(ChannelHandlerContext channelHandlerContext, Object o) throws Exception { + try { + if (o instanceof Channel) { + Channel ch = (Channel) o; + new NettyPipelineInjector(ch.pipeline()); + } + } finally { + super.channelRead(channelHandlerContext, o); + } + } + }); + } + } } /** @@ -169,7 +202,7 @@ public void channelRead(ChannelHandlerContext channelHandlerContext, Object o) t } protected @NotNull String getIdentifier() { - return "meteor-injector-" + plugin.getName(); + return IDENTIFIER_PREFIX + plugin.getName(); } public final boolean isClosed() { @@ -183,6 +216,7 @@ public void close() throws IOException { } thread.interrupt(); + HandlerList.unregisterAll(serverLoadListener); synchronized (channels) { for (ChannelFuture channel : channels) { ChannelPipeline pipeline = channel.channel().pipeline(); diff --git a/module/bukkit-nms/src/main/kotlin/taboolib/module/nms/ProtocolHandler.kt b/module/bukkit-nms/src/main/kotlin/taboolib/module/nms/ProtocolHandler.kt index 3bfd22224..47e4b0e7b 100644 --- a/module/bukkit-nms/src/main/kotlin/taboolib/module/nms/ProtocolHandler.kt +++ b/module/bukkit-nms/src/main/kotlin/taboolib/module/nms/ProtocolHandler.kt @@ -48,11 +48,16 @@ object ProtocolHandler : OpenListener { */ var instance: MeteorInjector? = null + /** + * 主注入器来自旧版插件时,用于补齐其遗漏通道的覆盖实例。 + */ + private var fallbackInstance: MeteorInjector? = null + /** * 当前插件是否已经注入数据包监听器 */ fun isInjected(): Boolean { - return instance != null + return instance != null || fallbackInstance != null } /** @@ -65,7 +70,10 @@ object ProtocolHandler : OpenListener { * 更新 OpenContainer 缓存 */ fun updateContainer() { - containers = getOpenContainers().filter { it.name != pluginId && Exchanges.contains(PACKET_LISTENER + "/plugin/" + it.name) } + val owner = Exchanges()[PACKET_LISTENER] as? String + containers = getOpenContainers().filter { + it.name != pluginId && (it.name == owner || Exchanges.contains(PACKET_LISTENER + "/plugin/" + it.name)) + } } /** @@ -108,6 +116,8 @@ object ProtocolHandler : OpenListener { * 并且更新 OpenContainer 缓存 */ private fun injectPacketListener() { + fallbackInstance?.close() + fallbackInstance = null instance = MeteorInjector(BukkitPlugin.getInstance()) Exchanges[PACKET_LISTENER] = pluginId Exchanges["$PACKET_LISTENER/plugin/$pluginId"] = null @@ -131,6 +141,9 @@ object ProtocolHandler : OpenListener { if (isPacketEventListened()) { Exchanges["$PACKET_LISTENER/plugin/$pluginId"] = true debug("MeteorInjector 已在其他插件中初始化。") + // 为已有主注入器补齐尚未覆盖的晚注册通道。 + fallbackInstance = MeteorInjector(BukkitPlugin.getInstance()) + updateContainer() } } else { injectPacketListener() @@ -145,6 +158,8 @@ object ProtocolHandler : OpenListener { if (TabooLib.isStopped() || !isBukkitServerRunning) { return } + fallbackInstance?.close() + fallbackInstance = null if (instance != null) { // 注销数据包监听器 instance?.close() @@ -164,21 +179,21 @@ object ProtocolHandler : OpenListener { @Awake(LifeCycle.ACTIVE) private fun onActive() { - if (instance != null) { + if (instance != null || fallbackInstance != null) { updateContainer() } } @SubscribeEvent private fun onEnabled(e: PluginEnableEvent) { - if (instance != null) { + if (instance != null || fallbackInstance != null) { updateContainer() } } @SubscribeEvent private fun onDisable(e: PluginDisableEvent) { - if (instance != null) { + if (instance != null || fallbackInstance != null) { updateContainer() } } @@ -223,4 +238,4 @@ object ProtocolHandler : OpenListener { } return OpenResult.failed() } -} \ No newline at end of file +} From 489532f0913465b3cdaea977fe44eba3bd6475ea Mon Sep 17 00:00:00 2001 From: zhibei <785740487@qq.com> Date: Mon, 13 Jul 2026 12:14:21 +0800 Subject: [PATCH 10/37] =?UTF-8?q?fix(core):=20=E4=BF=AE=E5=A4=8D=E5=BC=82?= =?UTF-8?q?=E6=AD=A5=E4=BB=BB=E5=8A=A1=E4=B8=8E=E8=B5=84=E6=BA=90=E7=94=9F?= =?UTF-8?q?=E5=91=BD=E5=91=A8=E6=9C=9F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 确保 Future 异常可观察,消除递归删除死锁并可靠关闭文件监听与加载资源。 --- .../common/env/aether/AetherResolver.java | 52 +++++----- common-legacy-api/build.gradle.kts | 3 +- .../java/taboolib/common5/FileWatcher.java | 97 +++++++++++++------ .../taboolib/common5/FileWatcherTest.kt | 36 +++++++ .../taboolib/common/util/SyncExecutor.kt | 14 ++- .../taboolib/common/util/SyncExecutorTest.kt | 31 ++++++ .../taboolib/common/function/Throttle.kt | 31 ++++-- .../taboolib/common/io/FileDeleteAsync.kt | 60 +++++++----- .../kotlin/taboolib/common/util/Random.kt | 11 ++- .../taboolib/common/function/ThrottleTest.kt | 34 +++++++ .../taboolib/common/io/FileDeleteAsyncTest.kt | 43 ++++++++ .../kotlin/taboolib/common/util/RandomTest.kt | 37 +++++++ .../java/taboolib/common/PrimitiveIO.java | 22 +++-- .../java/taboolib/common/PrimitiveLoader.java | 24 ++++- .../kotlin/taboolib/common/PrimitiveIOTest.kt | 65 +++++++++++++ .../taboolib/common/PrimitiveLoaderTest.kt | 22 +++++ 16 files changed, 472 insertions(+), 110 deletions(-) create mode 100644 common-legacy-api/src/test/kotlin/taboolib/common5/FileWatcherTest.kt create mode 100644 common-platform-api/src/test/kotlin/taboolib/common/util/SyncExecutorTest.kt create mode 100644 common-util/src/test/kotlin/taboolib/common/function/ThrottleTest.kt create mode 100644 common-util/src/test/kotlin/taboolib/common/io/FileDeleteAsyncTest.kt create mode 100644 common-util/src/test/kotlin/taboolib/common/util/RandomTest.kt create mode 100644 common/src/test/kotlin/taboolib/common/PrimitiveIOTest.kt create mode 100644 common/src/test/kotlin/taboolib/common/PrimitiveLoaderTest.kt diff --git a/common-env/src/main/java/taboolib/common/env/aether/AetherResolver.java b/common-env/src/main/java/taboolib/common/env/aether/AetherResolver.java index 9f3769779..f734189b8 100644 --- a/common-env/src/main/java/taboolib/common/env/aether/AetherResolver.java +++ b/common-env/src/main/java/taboolib/common/env/aether/AetherResolver.java @@ -124,30 +124,38 @@ public static AetherResolver of(@NotNull String repository) { String id = file.getParentFile().getParentFile().getPath() + ":" + PrimitiveSettings.IS_ISOLATED_MODE // 区分类加载器 (隔离类加载器或插件类加载器) + ":" + (relocation != null ? relocation.hashCode() : 0); // 区分不同的重定向规则 - if (injectedDependencies.contains(id)) return null; - else injectedDependencies.add(id); - // 如果没有重定向规则,直接注入 - if (relocation == null || relocation.isEmpty()) { - return ClassAppender.addPath(file.toPath(), PrimitiveSettings.IS_ISOLATED_MODE, isExternal); - } else { - // 获取重定向后的文件 - String name = file.getName().substring(0, file.getName().lastIndexOf('.')); - File rel = new File(file.getParentFile(), name + "_r2_" + Math.abs(relocation.hashCode()) + ".jar"); - // 如果文件不存在或者文件大小为 0,就执行重定向逻辑 - if (!rel.exists() || rel.length() == 0) { - try { - // 获取重定向规则 - List rules = relocation.stream().map(JarRelocation::toRelocation).collect(Collectors.toList()); - // 获取临时文件 - File tempSourceFile = PrimitiveIO.copyFile(file, File.createTempFile(file.getName(), ".jar")); - // 运行 - new JarRelocator(tempSourceFile, rel, rules).run(); - } catch (IOException e) { - throw new IllegalStateException(String.format("Unable to relocate %s%n", file), e); + if (!injectedDependencies.add(id)) return null; + try { + // 如果没有重定向规则,直接注入 + if (relocation == null || relocation.isEmpty()) { + return ClassAppender.addPath(file.toPath(), PrimitiveSettings.IS_ISOLATED_MODE, isExternal); + } else { + // 获取重定向后的文件 + String name = file.getName().substring(0, file.getName().lastIndexOf('.')); + File rel = new File(file.getParentFile(), name + "_r2_" + Math.abs(relocation.hashCode()) + ".jar"); + // 如果文件不存在或者文件大小为 0,就执行重定向逻辑 + if (!rel.exists() || rel.length() == 0) { + File tempSourceFile = File.createTempFile(file.getName(), ".jar"); + try { + // 获取重定向规则 + List rules = relocation.stream().map(JarRelocation::toRelocation).collect(Collectors.toList()); + PrimitiveIO.copyFile(file, tempSourceFile); + new JarRelocator(tempSourceFile, rel, rules).run(); + } catch (IOException e) { + throw new IllegalStateException(String.format("Unable to relocate %s%n", file), e); + } finally { + if (!tempSourceFile.delete()) { + tempSourceFile.deleteOnExit(); + } + } } + // 注入重定向后的文件 + return ClassAppender.addPath(rel.toPath(), PrimitiveSettings.IS_ISOLATED_MODE, isExternal); } - // 注入重定向后的文件 - return ClassAppender.addPath(rel.toPath(), PrimitiveSettings.IS_ISOLATED_MODE, isExternal); + } catch (Throwable ex) { + // 注入失败后允许后续调用重试,避免失败状态永久污染缓存。 + injectedDependencies.remove(id); + throw ex; } } } diff --git a/common-legacy-api/build.gradle.kts b/common-legacy-api/build.gradle.kts index d2c35ef5c..e1196e997 100644 --- a/common-legacy-api/build.gradle.kts +++ b/common-legacy-api/build.gradle.kts @@ -1,6 +1,7 @@ dependencies { compileOnly(project(":common")) compileOnly(project(":common-env")) + testImplementation(project(":common")) compileOnly(project(":common-platform-api")) compileOnly(project(":common-util")) -} \ No newline at end of file +} diff --git a/common-legacy-api/src/main/java/taboolib/common5/FileWatcher.java b/common-legacy-api/src/main/java/taboolib/common5/FileWatcher.java index dd927faa4..cc158f14c 100755 --- a/common-legacy-api/src/main/java/taboolib/common5/FileWatcher.java +++ b/common-legacy-api/src/main/java/taboolib/common5/FileWatcher.java @@ -14,6 +14,7 @@ import java.util.concurrent.Executors; import java.util.concurrent.ScheduledExecutorService; import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicBoolean; import java.util.function.Consumer; /** @@ -50,6 +51,11 @@ public class FileWatcher { */ private final WatchService watchService; + /** + * 监听器是否已经释放 + */ + private final AtomicBoolean released = new AtomicBoolean(false); + public FileWatcher(int interval) { WatchService ws; try { @@ -64,29 +70,41 @@ public FileWatcher(int interval) { this.watchService = ws; if (this.watchService != null) { this.executorService.scheduleAtFixedRate(() -> { - WatchKey key; - while ((key = watchService.poll()) != null) { - WatchKey finalKey = key; - key.pollEvents().forEach(event -> { - if (event.context() instanceof Path) { - Path changedPath = (Path) event.context(); - // 通过 WatchKey 获取监听的目录,构建完整路径 - Path watchedPath = (Path) finalKey.watchable(); - Path fullChangedPath = watchedPath.resolve(changedPath); + try { + WatchKey key; + while ((key = watchService.poll()) != null) { + WatchKey finalKey = key; + key.pollEvents().forEach(event -> { + if (event.context() instanceof Path) { + Path changedPath = (Path) event.context(); + // 通过 WatchKey 获取监听的目录,构建完整路径 + Path watchedPath = (Path) finalKey.watchable(); + Path fullChangedPath = watchedPath.resolve(changedPath).toAbsolutePath().normalize(); + fileListenerMap.forEach((file, listener) -> { + try { + listener.handleEvent(fullChangedPath); + } catch (Throwable ex) { + ex.printStackTrace(); + } + }); + } + }); + if (!key.reset()) { fileListenerMap.forEach((file, listener) -> { - try { - listener.handleEvent(fullChangedPath); - } catch (Throwable ex) { - ex.printStackTrace(); + if (listener.watchKey == finalKey) { + fileListenerMap.remove(file, listener); } }); } - }); - key.reset(); + } + } catch (ClosedWatchServiceException ignored) { + // 正常释放时关闭 WatchService,会终止后续轮询 } }, 1000, interval, TimeUnit.MILLISECONDS); // 注册关闭回调 TabooLib.registerLifeCycleTask(LifeCycle.DISABLE, 0, this::release); + } else { + this.executorService.shutdownNow(); } } @@ -108,14 +126,19 @@ public void addSimpleListener(File file, Consumer runnable) { * @param runImmediately 是否在添加监听器时立即执行一次 */ public void addSimpleListener(File file, Consumer runnable, boolean runImmediately) { - if (watchService == null) { + if (watchService == null || released.get()) { return; } if (runImmediately) { runnable.accept(file); } try { - fileListenerMap.put(file, new FileListener(file, runnable, this)); + File canonicalFile = file.getCanonicalFile(); + FileListener listener = new FileListener(canonicalFile, runnable, this); + FileListener previous = fileListenerMap.put(canonicalFile, listener); + if (previous != null) { + previous.cancel(); + } } catch (IOException e) { throw new RuntimeException(e); } @@ -127,7 +150,13 @@ public void addSimpleListener(File file, Consumer runnable, boolean runImm * @param file 要移除监听的文件 */ public void removeListener(File file) { - FileListener listener = fileListenerMap.remove(file); + File canonicalFile; + try { + canonicalFile = file.getCanonicalFile(); + } catch (IOException ignored) { + canonicalFile = file.getAbsoluteFile(); + } + FileListener listener = fileListenerMap.remove(canonicalFile); if (listener != null) { listener.cancel(); } @@ -137,8 +166,18 @@ public void removeListener(File file) { * 释放资源 */ public void release() { - executorService.shutdown(); + if (!released.compareAndSet(false, true)) { + return; + } fileListenerMap.values().forEach(FileListener::cancel); + fileListenerMap.clear(); + if (watchService != null) { + try { + watchService.close(); + } catch (IOException ignored) { + } + } + executorService.shutdownNow(); } /** @@ -170,29 +209,25 @@ static class FileListener { } public void handleEvent(Path fullChangedPath) { + Path watchedFile = file.toPath().toAbsolutePath().normalize(); + Path changedFile = fullChangedPath.toAbsolutePath().normalize(); // 监听目录 if (file.isDirectory()) { - try { - // 使用 relativize 检查路径关系,更加准确 - file.toPath().relativize(fullChangedPath); - callback.accept(fullChangedPath.toFile()); - } catch (IllegalArgumentException ignored) { - // 如果不是子路径,会抛出异常,直接忽略 + if (changedFile.startsWith(watchedFile)) { + callback.accept(changedFile.toFile()); } } - // 监听文件 - else if (isSameFile(fullChangedPath, file.toPath())) { - callback.accept(fullChangedPath.toFile()); + // 监听文件。删除事件发生时目标文件已不存在,Files.isSameFile 会失败, + // 因此先比较规范化路径,再用 isSameFile 兼容符号链接。 + else if (changedFile.equals(watchedFile) || isSameFile(changedFile, watchedFile)) { + callback.accept(changedFile.toFile()); } } public boolean isSameFile(Path path1, Path path2) { try { - // 使用 Files.isSameFile() 判断两个路径是否指向同一个文件 - // 该方法会考虑符号链接等情况 return Files.isSameFile(path1, path2); } catch (IOException e) { - // 如果出现 IO 异常则返回 false return false; } } diff --git a/common-legacy-api/src/test/kotlin/taboolib/common5/FileWatcherTest.kt b/common-legacy-api/src/test/kotlin/taboolib/common5/FileWatcherTest.kt new file mode 100644 index 000000000..e82a11bf5 --- /dev/null +++ b/common-legacy-api/src/test/kotlin/taboolib/common5/FileWatcherTest.kt @@ -0,0 +1,36 @@ +package taboolib.common5 + +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.io.TempDir +import java.nio.file.Files +import java.nio.file.Path +import java.util.concurrent.CountDownLatch +import java.util.concurrent.TimeUnit + +class FileWatcherTest { + + @TempDir + lateinit var tempDirectory: Path + + @Test + fun `file deletion is reported and watcher can be released repeatedly`() { + val file = Files.write(tempDirectory.resolve("watched.txt"), byteArrayOf(1)).toFile() + val deleted = CountDownLatch(1) + val watcher = FileWatcher(20) + try { + watcher.addSimpleListener(file, { changed -> + if (changed.absoluteFile == file.absoluteFile && !changed.exists()) { + deleted.countDown() + } + }) + Files.delete(file.toPath()) + + assertTrue(deleted.await(5, TimeUnit.SECONDS)) + } finally { + watcher.release() + watcher.release() + FileWatcher.INSTANCE.release() + } + } +} diff --git a/common-platform-api/src/main/kotlin/taboolib/common/util/SyncExecutor.kt b/common-platform-api/src/main/kotlin/taboolib/common/util/SyncExecutor.kt index 232b4212a..b2c09789f 100644 --- a/common-platform-api/src/main/kotlin/taboolib/common/util/SyncExecutor.kt +++ b/common-platform-api/src/main/kotlin/taboolib/common/util/SyncExecutor.kt @@ -4,6 +4,14 @@ import taboolib.common.platform.function.isPrimaryThread import taboolib.common.platform.function.submit import java.util.concurrent.CompletableFuture +internal fun CompletableFuture.completeWith(func: () -> T) { + try { + complete(func()) + } catch (ex: Throwable) { + completeExceptionally(ex) + } +} + /** * 在异步线程执行一个同步任务,并等待其完成 * @@ -15,7 +23,7 @@ fun sync(func: () -> T): T { error("Cannot run sync task in main thread.") } val future = CompletableFuture() - submit { future.complete(func()) } + submit { future.completeWith(func) } return future.join() } @@ -30,6 +38,6 @@ fun runSync(func: () -> T): T { return func() } val future = CompletableFuture() - submit { future.complete(func()) } + submit { future.completeWith(func) } return future.join() -} \ No newline at end of file +} diff --git a/common-platform-api/src/test/kotlin/taboolib/common/util/SyncExecutorTest.kt b/common-platform-api/src/test/kotlin/taboolib/common/util/SyncExecutorTest.kt new file mode 100644 index 000000000..a5ba8493e --- /dev/null +++ b/common-platform-api/src/test/kotlin/taboolib/common/util/SyncExecutorTest.kt @@ -0,0 +1,31 @@ +package taboolib.common.util + +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertSame +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.assertThrows +import java.util.concurrent.CompletableFuture +import java.util.concurrent.CompletionException + +class SyncExecutorTest { + + @Test + fun `completeWith completes successful result`() { + val future = CompletableFuture() + + future.completeWith { 42 } + + assertEquals(42, future.join()) + } + + @Test + fun `completeWith propagates task failure`() { + val future = CompletableFuture() + val failure = IllegalStateException("boom") + + future.completeWith { throw failure } + + val thrown = assertThrows { future.join() } + assertSame(failure, thrown.cause) + } +} diff --git a/common-util/src/main/kotlin/taboolib/common/function/Throttle.kt b/common-util/src/main/kotlin/taboolib/common/function/Throttle.kt index 5e05ede51..d395fbbb6 100644 --- a/common-util/src/main/kotlin/taboolib/common/function/Throttle.kt +++ b/common-util/src/main/kotlin/taboolib/common/function/Throttle.kt @@ -2,6 +2,7 @@ package taboolib.common.function import java.util.concurrent.ConcurrentHashMap import java.util.concurrent.CopyOnWriteArrayList +import java.util.concurrent.atomic.AtomicLong abstract class ThrottleFunction( val keyType: Class, @@ -22,11 +23,16 @@ abstract class ThrottleFunction( */ open fun canExecute(key: K, delay: Long = this.delay): Boolean { val currentTime = System.currentTimeMillis() - val lastExecuteTime = throttleMap.getOrDefault(key, 0L) - return if (currentTime - lastExecuteTime >= delay) { - throttleMap[key] = currentTime - true - } else false + var allowed = false + throttleMap.compute(key) { _, lastExecuteTime -> + if (lastExecuteTime == null || delay <= 0 || currentTime < lastExecuteTime || currentTime - lastExecuteTime >= delay) { + allowed = true + currentTime + } else { + lastExecuteTime + } + } + return allowed } /** @@ -56,7 +62,7 @@ abstract class ThrottleFunction( val action: () -> Unit, ) : ThrottleFunction(Unit::class.java, delay) { - private var lastExecuteTime = 0L + private val lastExecuteTime = AtomicLong(Long.MIN_VALUE) fun canExecute(delay: Long = this.delay): Boolean { return canExecute(Unit, delay) @@ -64,10 +70,15 @@ abstract class ThrottleFunction( override fun canExecute(key: Unit, delay: Long): Boolean { val currentTime = System.currentTimeMillis() - return if (currentTime - lastExecuteTime >= delay) { - lastExecuteTime = currentTime - true - } else false + while (true) { + val last = lastExecuteTime.get() + if (last != Long.MIN_VALUE && delay > 0 && currentTime >= last && currentTime - last < delay) { + return false + } + if (lastExecuteTime.compareAndSet(last, currentTime)) { + return true + } + } } /** diff --git a/common-util/src/main/kotlin/taboolib/common/io/FileDeleteAsync.kt b/common-util/src/main/kotlin/taboolib/common/io/FileDeleteAsync.kt index 5c9b770eb..feac06513 100644 --- a/common-util/src/main/kotlin/taboolib/common/io/FileDeleteAsync.kt +++ b/common-util/src/main/kotlin/taboolib/common/io/FileDeleteAsync.kt @@ -1,11 +1,16 @@ package taboolib.common.io import java.io.File -import java.util.concurrent.Executors +import java.io.IOException +import java.nio.file.FileVisitResult +import java.nio.file.Files +import java.nio.file.LinkOption +import java.nio.file.Path +import java.nio.file.SimpleFileVisitor +import java.nio.file.attribute.BasicFileAttributes +import java.util.concurrent.CompletableFuture import java.util.concurrent.Future -private val executor = Executors.newFixedThreadPool(Runtime.getRuntime().availableProcessors())!! - /** * Delete the directory and all its contents asynchronously.
* if you need to wait for the deletion to complete, pass in a set here and use Set#forEach(Future<*>::get) @@ -15,27 +20,32 @@ private val executor = Executors.newFixedThreadPool(Runtime.getRuntime().availab * @author Kylepoops */ fun File.deepDeleteAsync(await: Boolean = false, futures: MutableSet>? = null) { - // first submit the task and get the future - val future = executor.submit { - if (this.exists()) { - if (this.isDirectory) { - listFiles()?.let { files -> - // Construct another future set here - // Because we need all the subdirectories and files to be deleted before this directory is deleted - val thisFutures = mutableSetOf>() - // Pass the future set to this, so we can store all the future - files.forEach { it.deepDeleteAsync(futures = thisFutures) } - // Wait for all the subdirectories and files to be deleted - thisFutures.forEach(Future<*>::get) - } + // Traverse the whole tree in one asynchronous task. Submitting child tasks and waiting for them + // from the same bounded executor can exhaust every worker and deadlock on sufficiently deep trees. + val future = CompletableFuture.runAsync { deleteTree(toPath()) } + futures?.add(future) + if (await) { + future.get() + } +} + +private fun deleteTree(root: Path) { + if (!Files.exists(root, LinkOption.NOFOLLOW_LINKS)) { + return + } + Files.walkFileTree(root, object : SimpleFileVisitor() { + + override fun visitFile(file: Path, attrs: BasicFileAttributes): FileVisitResult { + Files.deleteIfExists(file) + return FileVisitResult.CONTINUE + } + + override fun postVisitDirectory(dir: Path, exc: IOException?): FileVisitResult { + if (exc != null) { + throw exc } - // Finally, delete this file or directory - this.delete() + Files.deleteIfExists(dir) + return FileVisitResult.CONTINUE } - } - // Add the future to the future set - futures?.add(future) - // Wait the task to finish before returning if await is true - // It shouldn't be called inside this function - if (await) future.get() -} \ No newline at end of file + }) +} diff --git a/common-util/src/main/kotlin/taboolib/common/util/Random.kt b/common-util/src/main/kotlin/taboolib/common/util/Random.kt index 8a4aa190b..4c02e42f1 100644 --- a/common-util/src/main/kotlin/taboolib/common/util/Random.kt +++ b/common-util/src/main/kotlin/taboolib/common/util/Random.kt @@ -18,7 +18,7 @@ fun random(): Random { * @param v 0-1 */ fun random(v: Double): Boolean { - return ThreadLocalRandom.current().nextDouble() <= v + return ThreadLocalRandom.current().nextDouble() < v } /** @@ -37,9 +37,12 @@ fun random(v: Int): Int { * @param num2 最大值 */ fun random(num1: Int, num2: Int): Int { - val min = min(num1, num2) - val max = max(num1, num2) - return ThreadLocalRandom.current().nextInt(min, max + 1) + val min = min(num1, num2).toLong() + val max = max(num1, num2).toLong() + if (min == max) { + return min.toInt() + } + return ThreadLocalRandom.current().nextLong(min, max + 1).toInt() } /** diff --git a/common-util/src/test/kotlin/taboolib/common/function/ThrottleTest.kt b/common-util/src/test/kotlin/taboolib/common/function/ThrottleTest.kt new file mode 100644 index 000000000..efc5e7744 --- /dev/null +++ b/common-util/src/test/kotlin/taboolib/common/function/ThrottleTest.kt @@ -0,0 +1,34 @@ +package taboolib.common.function + +import org.junit.jupiter.api.Assertions.assertFalse +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test + +class ThrottleTest { + + @Test + fun `first invocation is allowed for any delay`() { + val throttle = throttle(Long.MAX_VALUE) + + assertTrue(throttle.canExecute()) + assertFalse(throttle.canExecute()) + } + + @Test + fun `non-positive delay never suppresses invocation`() { + val throttle = throttle(0) + + repeat(10) { + assertTrue(throttle.canExecute()) + } + } + + @Test + fun `keyed throttle treats each key independently`() { + val throttle = throttle(Long.MAX_VALUE) + + assertTrue(throttle.canExecute("first")) + assertFalse(throttle.canExecute("first")) + assertTrue(throttle.canExecute("second")) + } +} diff --git a/common-util/src/test/kotlin/taboolib/common/io/FileDeleteAsyncTest.kt b/common-util/src/test/kotlin/taboolib/common/io/FileDeleteAsyncTest.kt new file mode 100644 index 000000000..6acfd696f --- /dev/null +++ b/common-util/src/test/kotlin/taboolib/common/io/FileDeleteAsyncTest.kt @@ -0,0 +1,43 @@ +package taboolib.common.io + +import org.junit.jupiter.api.Assertions.assertFalse +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.io.TempDir +import java.nio.file.Files +import java.nio.file.Path +import java.util.Collections +import java.util.concurrent.Future +import java.util.concurrent.TimeUnit + +class FileDeleteAsyncTest { + + @TempDir + lateinit var tempDirectory: Path + + @Test + fun `large directory tree completes without executor starvation`() { + val root = Files.createDirectory(tempDirectory.resolve("root")) + val branchCount = maxOf(16, Runtime.getRuntime().availableProcessors() * 2) + repeat(branchCount) { index -> + val branch = Files.createDirectory(root.resolve("branch-$index")) + Files.write(branch.resolve("value.txt"), index.toString().toByteArray()) + } + val futures = Collections.synchronizedSet(mutableSetOf>()) + + root.toFile().deepDeleteAsync(futures = futures) + + futures.single().get(10, TimeUnit.SECONDS) + assertFalse(Files.exists(root)) + } + + @Test + fun `missing path is a successful no-op`() { + val missing = tempDirectory.resolve("missing").toFile() + val futures = mutableSetOf>() + + missing.deepDeleteAsync(futures = futures) + + futures.single().get(5, TimeUnit.SECONDS) + assertFalse(missing.exists()) + } +} diff --git a/common-util/src/test/kotlin/taboolib/common/util/RandomTest.kt b/common-util/src/test/kotlin/taboolib/common/util/RandomTest.kt new file mode 100644 index 000000000..c5ca2cfe7 --- /dev/null +++ b/common-util/src/test/kotlin/taboolib/common/util/RandomTest.kt @@ -0,0 +1,37 @@ +package taboolib.common.util + +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertFalse +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test + +class RandomTest { + + @Test + fun `zero probability is always false`() { + repeat(10_000) { + assertFalse(random(0.0)) + } + } + + @Test + fun `probability at least one is always true`() { + repeat(100) { + assertTrue(random(1.0)) + assertTrue(random(Double.POSITIVE_INFINITY)) + } + } + + @Test + fun `inclusive int range supports full integer domain`() { + repeat(10_000) { + val value = random(Int.MIN_VALUE, Int.MAX_VALUE) + assertTrue(value in Int.MIN_VALUE..Int.MAX_VALUE) + } + } + + @Test + fun `equal maximum bounds return maximum value`() { + assertEquals(Int.MAX_VALUE, random(Int.MAX_VALUE, Int.MAX_VALUE)) + } +} diff --git a/common/src/main/java/taboolib/common/PrimitiveIO.java b/common/src/main/java/taboolib/common/PrimitiveIO.java index 3d7308072..3bd4fbe6e 100644 --- a/common/src/main/java/taboolib/common/PrimitiveIO.java +++ b/common/src/main/java/taboolib/common/PrimitiveIO.java @@ -225,16 +225,20 @@ public static File copyFile(File from, File to) { * @param url 地址 * @param out 目标文件 */ - @SuppressWarnings("StatementWithEmptyBody") public static void downloadFile(URL url, File out) throws IOException { - out.getParentFile().mkdirs(); - InputStream ins = url.openStream(); - OutputStream outs = Files.newOutputStream(out.toPath()); - byte[] buffer = new byte[BUFFER_SIZE]; - for (int len; (len = ins.read(buffer)) > 0; outs.write(buffer, 0, len)) - ; - outs.close(); - ins.close(); + File parent = out.getParentFile(); + if (parent != null) { + parent.mkdirs(); + } + try (InputStream input = url.openStream(); OutputStream output = Files.newOutputStream(out.toPath())) { + byte[] buffer = new byte[BUFFER_SIZE]; + int length; + while ((length = input.read(buffer)) != -1) { + if (length > 0) { + output.write(buffer, 0, length); + } + } + } } public static String getRunningFileName() { diff --git a/common/src/main/java/taboolib/common/PrimitiveLoader.java b/common/src/main/java/taboolib/common/PrimitiveLoader.java index 0c85b27ee..6ba6dcb04 100644 --- a/common/src/main/java/taboolib/common/PrimitiveLoader.java +++ b/common/src/main/java/taboolib/common/PrimitiveLoader.java @@ -242,12 +242,18 @@ static void loadFile(File file, boolean isIsolated, boolean isExternal, List 0 && buildNumberNodes.getLength() > 0) { @@ -343,6 +353,10 @@ static void generateSha1File(File jarFile, File shaFile) { } } + static boolean shouldRelocate(File jar, boolean forceRelocate) { + return !jar.exists() || jar.length() == 0 || (IS_FORCE_DOWNLOAD_IN_DEV_MODE && IS_DEV_MODE) || forceRelocate; + } + static int deepHashCode(List array) { int result = 1; for (String[] element : array) { diff --git a/common/src/test/kotlin/taboolib/common/PrimitiveIOTest.kt b/common/src/test/kotlin/taboolib/common/PrimitiveIOTest.kt new file mode 100644 index 000000000..3c47ee7d5 --- /dev/null +++ b/common/src/test/kotlin/taboolib/common/PrimitiveIOTest.kt @@ -0,0 +1,65 @@ +package taboolib.common + +import org.junit.jupiter.api.Assertions.assertArrayEquals +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.assertThrows +import org.junit.jupiter.api.io.TempDir +import java.io.ByteArrayInputStream +import java.io.IOException +import java.net.URL +import java.net.URLConnection +import java.net.URLStreamHandler +import java.nio.file.Files +import java.nio.file.Path + +class PrimitiveIOTest { + + @TempDir + lateinit var tempDirectory: Path + + @Test + fun `download closes input after success`() { + val content = "taboolib".toByteArray() + val input = TrackingInputStream(content) + val target = tempDirectory.resolve("download.bin").toFile() + + PrimitiveIO.downloadFile(memoryUrl(input), target) + + assertTrue(input.closed) + assertArrayEquals(content, Files.readAllBytes(target.toPath())) + } + + @Test + fun `download closes input when output cannot be opened`() { + val input = TrackingInputStream("taboolib".toByteArray()) + val targetDirectory = Files.createDirectory(tempDirectory.resolve("target")).toFile() + + assertThrows { + PrimitiveIO.downloadFile(memoryUrl(input), targetDirectory) + } + assertTrue(input.closed) + } + + private fun memoryUrl(input: TrackingInputStream): URL { + return URL(null, "memory://download", object : URLStreamHandler() { + override fun openConnection(url: URL): URLConnection { + return object : URLConnection(url) { + override fun connect() = Unit + override fun getInputStream() = input + } + } + }) + } + + private class TrackingInputStream(content: ByteArray) : ByteArrayInputStream(content) { + + var closed = false + private set + + override fun close() { + closed = true + super.close() + } + } +} diff --git a/common/src/test/kotlin/taboolib/common/PrimitiveLoaderTest.kt b/common/src/test/kotlin/taboolib/common/PrimitiveLoaderTest.kt new file mode 100644 index 000000000..cde0dddf5 --- /dev/null +++ b/common/src/test/kotlin/taboolib/common/PrimitiveLoaderTest.kt @@ -0,0 +1,22 @@ +package taboolib.common + +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.io.TempDir +import java.nio.file.Files +import java.nio.file.Path + +class PrimitiveLoaderTest { + + @TempDir + lateinit var tempDirectory: Path + + @Test + fun `missing and empty relocation targets are regenerated`() { + val missing = tempDirectory.resolve("missing.jar").toFile() + val empty = Files.createFile(tempDirectory.resolve("empty.jar")).toFile() + + assertTrue(PrimitiveLoader.shouldRelocate(missing, false)) + assertTrue(PrimitiveLoader.shouldRelocate(empty, false)) + } +} From 5247654396db9ddfcd155e1ecbdba13aa2bf24eb Mon Sep 17 00:00:00 2001 From: zhibei <785740487@qq.com> Date: Mon, 13 Jul 2026 12:24:14 +0800 Subject: [PATCH 11/37] =?UTF-8?q?fix(submit-chain):=20=E5=AE=8C=E5=96=84?= =?UTF-8?q?=E5=BC=82=E5=B8=B8=E4=B8=8E=E5=8F=96=E6=B6=88=E4=BC=A0=E6=92=AD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 确保链任务和重复调度在失败或取消时终止,避免 Future 与协程永久挂起。 --- .../basic/basic-submit-chain/build.gradle.kts | 4 +- .../expansion/AsynchronousRepeatChain.kt | 15 +--- .../main/kotlin/taboolib/expansion/Chain.kt | 26 +++++- .../taboolib/expansion/RepeatChainable.kt | 49 ++++++++++- .../expansion/SynchronousRepeatChain.kt | 15 +--- .../kotlin/taboolib/expansion/ChainTest.kt | 53 ++++++++++++ .../taboolib/expansion/RepeatChainTest.kt | 83 +++++++++++++++++++ 7 files changed, 216 insertions(+), 29 deletions(-) create mode 100644 module/basic/basic-submit-chain/src/test/kotlin/taboolib/expansion/ChainTest.kt create mode 100644 module/basic/basic-submit-chain/src/test/kotlin/taboolib/expansion/RepeatChainTest.kt diff --git a/module/basic/basic-submit-chain/build.gradle.kts b/module/basic/basic-submit-chain/build.gradle.kts index c8e773f95..b034acf15 100644 --- a/module/basic/basic-submit-chain/build.gradle.kts +++ b/module/basic/basic-submit-chain/build.gradle.kts @@ -1,4 +1,6 @@ dependencies { compileOnly(project(":common")) compileOnly(project(":common-platform-api")) -} \ No newline at end of file + testImplementation(project(":common")) + testImplementation(project(":common-platform-api")) +} diff --git a/module/basic/basic-submit-chain/src/main/kotlin/taboolib/expansion/AsynchronousRepeatChain.kt b/module/basic/basic-submit-chain/src/main/kotlin/taboolib/expansion/AsynchronousRepeatChain.kt index 0ef1866ac..14345a67d 100644 --- a/module/basic/basic-submit-chain/src/main/kotlin/taboolib/expansion/AsynchronousRepeatChain.kt +++ b/module/basic/basic-submit-chain/src/main/kotlin/taboolib/expansion/AsynchronousRepeatChain.kt @@ -1,8 +1,6 @@ package taboolib.expansion import taboolib.common.platform.function.submit -import kotlin.coroutines.resume -import kotlin.coroutines.suspendCoroutine class AsynchronousRepeatChain( override val block: Cancellable.() -> T, @@ -12,15 +10,8 @@ class AsynchronousRepeatChain( ) : RepeatChainable { override suspend fun execute(): T { - return suspendCoroutine { cont -> - val cancellable = Cancellable() - submit(async = true, period = period, now = now, delay = delay) { - val result = cancellable.call(block) - if (cancellable.cancelled) { - cancel() - cont.resume(result) - } - } + return executeRepeat(block) { executor -> + submit(async = true, period = period, now = now, delay = delay, executor = executor) } } -} \ No newline at end of file +} diff --git a/module/basic/basic-submit-chain/src/main/kotlin/taboolib/expansion/Chain.kt b/module/basic/basic-submit-chain/src/main/kotlin/taboolib/expansion/Chain.kt index 99d397829..c836e9982 100644 --- a/module/basic/basic-submit-chain/src/main/kotlin/taboolib/expansion/Chain.kt +++ b/module/basic/basic-submit-chain/src/main/kotlin/taboolib/expansion/Chain.kt @@ -70,10 +70,30 @@ open class Chain(val chain: suspend Chain.() -> R) { } fun run(type: DispatcherType): CompletableFuture { + return run( + when (type) { + SYNC -> SyncDispatcher + ASYNC -> AsyncDispatcher + } + ) + } + + internal fun run(dispatcher: CoroutineDispatcher): CompletableFuture { val future = CompletableFuture() - when (type) { - SYNC -> CoroutineScope(SyncDispatcher).launch { future.complete(chain(this@Chain)) } - ASYNC -> CoroutineScope(AsyncDispatcher).launch { future.complete(chain(this@Chain)) } + val task = CoroutineScope(dispatcher).async { + future.complete(chain(this@Chain)) + } + task.invokeOnCompletion { cause -> + when (cause) { + null -> Unit + is CancellationException -> future.cancel(false) + else -> future.completeExceptionally(cause) + } + } + future.whenComplete { _, _ -> + if (future.isCancelled) { + task.cancel() + } } return future } diff --git a/module/basic/basic-submit-chain/src/main/kotlin/taboolib/expansion/RepeatChainable.kt b/module/basic/basic-submit-chain/src/main/kotlin/taboolib/expansion/RepeatChainable.kt index 2ab656283..4ff9f7eb6 100644 --- a/module/basic/basic-submit-chain/src/main/kotlin/taboolib/expansion/RepeatChainable.kt +++ b/module/basic/basic-submit-chain/src/main/kotlin/taboolib/expansion/RepeatChainable.kt @@ -1,8 +1,55 @@ package taboolib.expansion +import kotlinx.coroutines.suspendCancellableCoroutine +import taboolib.common.platform.service.PlatformExecutor +import java.util.concurrent.atomic.AtomicBoolean +import java.util.concurrent.atomic.AtomicReference +import kotlin.coroutines.resume +import kotlin.coroutines.resumeWithException + interface RepeatChainable { val block: Cancellable.() -> T suspend fun execute(): T -} \ No newline at end of file +} + +internal suspend fun executeRepeat( + block: Cancellable.() -> T, + submitTask: (PlatformExecutor.PlatformTask.() -> Unit) -> PlatformExecutor.PlatformTask, +): T { + return suspendCancellableCoroutine { continuation -> + val taskReference = AtomicReference() + val completed = AtomicBoolean(false) + val cancellable = Cancellable() + continuation.invokeOnCancellation { + completed.set(true) + taskReference.get()?.cancel() + } + val task = try { + submitTask { + try { + val result = cancellable.call(block) + if (cancellable.cancelled && completed.compareAndSet(false, true)) { + cancel() + continuation.resume(result) + } + } catch (ex: Throwable) { + cancel() + if (completed.compareAndSet(false, true)) { + continuation.resumeWithException(ex) + } + } + } + } catch (ex: Throwable) { + if (completed.compareAndSet(false, true)) { + continuation.resumeWithException(ex) + } + return@suspendCancellableCoroutine + } + taskReference.set(task) + if (completed.get()) { + task.cancel() + } + } +} diff --git a/module/basic/basic-submit-chain/src/main/kotlin/taboolib/expansion/SynchronousRepeatChain.kt b/module/basic/basic-submit-chain/src/main/kotlin/taboolib/expansion/SynchronousRepeatChain.kt index edd8fab28..84e44c42a 100644 --- a/module/basic/basic-submit-chain/src/main/kotlin/taboolib/expansion/SynchronousRepeatChain.kt +++ b/module/basic/basic-submit-chain/src/main/kotlin/taboolib/expansion/SynchronousRepeatChain.kt @@ -1,8 +1,6 @@ package taboolib.expansion import taboolib.common.platform.function.submit -import kotlin.coroutines.resume -import kotlin.coroutines.suspendCoroutine class SynchronousRepeatChain( override val block: Cancellable.() -> T, @@ -12,15 +10,8 @@ class SynchronousRepeatChain( ) : RepeatChainable { override suspend fun execute(): T { - return suspendCoroutine { cont -> - val cancellable = Cancellable() - submit(period = period, now = now, delay = delay) { - val result = cancellable.call(block) - if (cancellable.cancelled) { - cont.resume(result) - cancel() - } - } + return executeRepeat(block) { executor -> + submit(period = period, now = now, delay = delay, executor = executor) } } -} \ No newline at end of file +} diff --git a/module/basic/basic-submit-chain/src/test/kotlin/taboolib/expansion/ChainTest.kt b/module/basic/basic-submit-chain/src/test/kotlin/taboolib/expansion/ChainTest.kt new file mode 100644 index 000000000..6dd1f25fb --- /dev/null +++ b/module/basic/basic-submit-chain/src/test/kotlin/taboolib/expansion/ChainTest.kt @@ -0,0 +1,53 @@ +package taboolib.expansion + +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.awaitCancellation +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.withTimeout +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertSame +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.assertThrows +import java.util.concurrent.CompletionException + +class ChainTest { + + @Test + fun `successful chain completes future`() { + val future = Chain { 42 }.run(Dispatchers.Unconfined) + + assertEquals(42, future.join()) + } + + @Test + fun `failed chain completes future exceptionally`() { + val failure = IllegalStateException("boom") + val future = Chain { throw failure }.run(Dispatchers.Unconfined) + + val thrown = assertThrows { future.join() } + assertSame(failure, thrown.cause) + } + + @Test + fun `future cancellation cancels running chain`() = runBlocking { + val started = CompletableDeferred() + val stopped = CompletableDeferred() + val future = Chain { + try { + started.complete(Unit) + awaitCancellation() + } finally { + stopped.complete(Unit) + } + }.run(Dispatchers.Default) + + started.await() + assertTrue(future.cancel(false)) + withTimeout(5_000) { + stopped.await() + } + assertTrue(future.isCancelled) + } +} diff --git a/module/basic/basic-submit-chain/src/test/kotlin/taboolib/expansion/RepeatChainTest.kt b/module/basic/basic-submit-chain/src/test/kotlin/taboolib/expansion/RepeatChainTest.kt new file mode 100644 index 000000000..30d10e0da --- /dev/null +++ b/module/basic/basic-submit-chain/src/test/kotlin/taboolib/expansion/RepeatChainTest.kt @@ -0,0 +1,83 @@ +package taboolib.expansion + +import kotlinx.coroutines.CoroutineStart +import kotlinx.coroutines.cancelAndJoin +import kotlinx.coroutines.launch +import kotlinx.coroutines.runBlocking +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.assertThrows +import taboolib.common.platform.service.PlatformExecutor + +class RepeatChainTest { + + @Test + fun `repeat chain resumes when block cancels itself`() = runBlocking { + val task = TestTask() + + val result = executeRepeat({ + cancel() + 42 + }) { executor -> + task.executor() + task + } + + assertEquals(42, result) + assertTrue(task.cancelled) + } + + @Test + fun `repeat chain propagates callback failure`() { + val failure = IllegalStateException("boom") + val task = TestTask() + + val thrown = assertThrows { + runBlocking { + executeRepeat({ throw failure }) { executor -> + task.executor() + task + } + } + } + + assertTrue(thrown === failure || thrown.cause === failure) + assertTrue(task.cancelled) + } + + @Test + fun `coroutine cancellation cancels scheduled task`() = runBlocking { + val task = TestTask() + val job = launch(start = CoroutineStart.UNDISPATCHED) { + executeRepeat({ Unit }) { task } + } + + job.cancelAndJoin() + + assertTrue(task.cancelled) + } + + @Test + fun `scheduler submission failure is propagated`() { + val failure = IllegalStateException("scheduler unavailable") + + val thrown = assertThrows { + runBlocking { + executeRepeat({ Unit }) { throw failure } + } + } + + assertTrue(thrown === failure || thrown.cause === failure) + } + + private class TestTask : PlatformExecutor.PlatformTask { + + var cancelled = false + private set + + override fun cancel() { + cancelled = true + } + } +} From 8bcae925c1b5c82dc840f140c072ab9c653c14e6 Mon Sep 17 00:00:00 2001 From: zhibei <785740487@qq.com> Date: Mon, 13 Jul 2026 12:48:33 +0800 Subject: [PATCH 12/37] =?UTF-8?q?fix(kether):=20=E4=BF=AE=E5=A4=8D?= =?UTF-8?q?=E5=BC=82=E6=AD=A5=E4=B8=8A=E4=B8=8B=E6=96=87=E5=AE=8C=E6=88=90?= =?UTF-8?q?=E4=B8=8E=E7=BA=BF=E7=A8=8B=E5=88=87=E6=8D=A2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 确保动作异常与取消能够结束上下文并回收运行任务,同时序列化远程读取并将计分板更新切回平台线程。 --- .../minecraft-kether/build.gradle.kts | 2 + .../library/kether/AbstractQuestContext.java | 158 ++++++++++--- .../module/kether/RemoteQuestReader.kt | 11 + .../action/game/bukkit/ActionScoreboard.kt | 91 +++++++- .../kether/AbstractQuestContextTest.java | 219 ++++++++++++++++++ .../module/kether/RemoteQuestReaderTest.kt | 64 +++++ 6 files changed, 507 insertions(+), 38 deletions(-) create mode 100644 module/minecraft/minecraft-kether/src/test/java/taboolib/library/kether/AbstractQuestContextTest.java create mode 100644 module/minecraft/minecraft-kether/src/test/kotlin/taboolib/module/kether/RemoteQuestReaderTest.kt diff --git a/module/minecraft/minecraft-kether/build.gradle.kts b/module/minecraft/minecraft-kether/build.gradle.kts index d4fcec6c9..417e96359 100644 --- a/module/minecraft/minecraft-kether/build.gradle.kts +++ b/module/minecraft/minecraft-kether/build.gradle.kts @@ -4,8 +4,10 @@ import com.github.jengelman.gradle.plugins.shadow.tasks.ShadowJar dependencies { compileOnly(project(":common")) + testImplementation(project(":common")) compileOnly(project(":common-env")) compileOnly(project(":common-util")) + testImplementation(project(":common-util")) compileOnly(project(":common-legacy-api")) compileOnly(project(":common-platform-api")) compileOnly(project(":module:minecraft:minecraft-chat")) diff --git a/module/minecraft/minecraft-kether/src/main/java/taboolib/library/kether/AbstractQuestContext.java b/module/minecraft/minecraft-kether/src/main/java/taboolib/library/kether/AbstractQuestContext.java index a786f2f45..fac45b22e 100644 --- a/module/minecraft/minecraft-kether/src/main/java/taboolib/library/kether/AbstractQuestContext.java +++ b/module/minecraft/minecraft-kether/src/main/java/taboolib/library/kether/AbstractQuestContext.java @@ -4,6 +4,7 @@ import org.jetbrains.annotations.NotNull; import java.util.*; +import java.util.concurrent.CancellationException; import java.util.concurrent.CompletableFuture; import java.util.concurrent.CompletionException; import java.util.concurrent.Executor; @@ -15,8 +16,8 @@ public abstract class AbstractQuestContext> im protected final Frame rootFrame; protected final Quest quest; protected final QuestExecutor executor; - protected ExitStatus exitStatus; - protected CompletableFuture future; + protected volatile ExitStatus exitStatus; + protected volatile CompletableFuture future; protected AbstractQuestContext(QuestService service, Quest quest, String playerIdentifier) { this.service = service; @@ -61,18 +62,31 @@ public Frame rootFrame() { } @Override - public CompletableFuture runActions() { + public synchronized CompletableFuture runActions() { Preconditions.checkState(future == null, "already running"); - return future = rootFrame.run().thenApply(o -> { - if (this.exitStatus == null) { - this.exitStatus = ExitStatus.success(); + CompletableFuture frameFuture = rootFrame.run(); + CompletableFuture contextFuture = new CompletableFuture<>(); + frameFuture.whenComplete((result, ex) -> { + if (ex != null) { + completeFailure(contextFuture, ex); + } else { + if (this.exitStatus == null) { + this.exitStatus = ExitStatus.success(); + } + contextFuture.complete(result); + } + }); + contextFuture.whenComplete((result, ex) -> { + if (contextFuture.isCancelled()) { + frameFuture.cancel(false); } - return o; }); + this.future = contextFuture; + return contextFuture; } @Override - public void terminate() { + public synchronized void terminate() { this.rootFrame.close(); if (future != null) { future.completeExceptionally(new QuestCloseException()); @@ -80,6 +94,18 @@ public void terminate() { } } + private static void completeFailure(CompletableFuture future, Throwable throwable) { + Throwable cause = throwable; + while (cause instanceof CompletionException && cause.getCause() != null) { + cause = cause.getCause(); + } + if (cause instanceof CancellationException) { + future.cancel(false); + } else { + future.completeExceptionally(cause); + } + } + public static class QuestExecutor implements Executor { private final AbstractQuestContext questContext; @@ -104,7 +130,7 @@ public static abstract class AbstractFrame implements Frame { protected final List frames; protected final VarTable varTable; protected final QuestContext questContext; - protected CompletableFuture future; + protected volatile CompletableFuture future; protected final Deque closeables = new LinkedBlockingDeque<>(); public AbstractFrame(Frame parent, List frames, VarTable varTable, QuestContext questContext) { @@ -162,12 +188,14 @@ public T addClosable(T closeable) { @Override public void close() { - if (this.future == null) return; + CompletableFuture runningFuture = this.future; + if (runningFuture == null) return; + this.future = null; for (Frame frame : this.frames) { frame.close(); } this.cleanup(); - this.future = null; + runningFuture.completeExceptionally(new QuestCloseException()); } @Override @@ -191,6 +219,7 @@ public static class SimpleNamedFrame extends AbstractFrame { private final String name; private Quest.Block block, next; private int sp = -1, np = -1; + private volatile CompletableFuture runningAction; public SimpleNamedFrame(Frame parent, List frames, VarTable varTable, String name, QuestContext questContext) { super(parent, frames, varTable, questContext); @@ -235,36 +264,100 @@ public void setNext(@NotNull Quest.Block block) { np = 0; } + @Override + public synchronized void close() { + CompletableFuture actionFuture = this.runningAction; + this.runningAction = null; + super.close(); + if (actionFuture != null) { + actionFuture.cancel(false); + } + } + @Override @SuppressWarnings("unchecked") - public CompletableFuture run() { + public synchronized CompletableFuture run() { Preconditions.checkState(this.future == null, "already running"); varTable.initialize(this); future = new CompletableFuture<>(); - process(future); - return (CompletableFuture) future; + CompletableFuture resultFuture = future; + resultFuture.whenComplete((result, ex) -> { + if (resultFuture.isCancelled()) { + this.close(); + } + }); + process(null); + return (CompletableFuture) resultFuture; } - @SuppressWarnings("unchecked") - private void process(CompletableFuture future) { + private synchronized void process(CompletableFuture previousFuture) { + CompletableFuture resultFuture = this.future; + if (resultFuture == null || resultFuture.isDone()) { + return; + } while (!context().getExitStatus().isPresent()) { this.cleanup(); this.frames.removeIf(Frame::isDone); Optional> optional = nextAction(); - if (optional.isPresent()) { - ParsedAction action = optional.get(); - CompletableFuture newFuture = action.process(this); - if (!newFuture.isDone()) { - newFuture.thenRun(() -> this.process(newFuture)); - return; - } else { - future = newFuture; - } - } else { - ((CompletableFuture) this.future).complete(future != null && future.isDone() ? future.join() : null); + if (!optional.isPresent()) { + completeResult(resultFuture, previousFuture); + return; + } + ParsedAction action = optional.get(); + CompletableFuture actionFuture; + try { + actionFuture = Objects.requireNonNull(action.process(this), "Quest action returned null future: " + action); + } catch (Throwable ex) { + fail(resultFuture, ex); return; } + this.runningAction = actionFuture; + if (!actionFuture.isDone()) { + actionFuture.whenComplete((result, ex) -> resume(resultFuture, actionFuture, ex)); + return; + } + this.runningAction = null; + if (actionFuture.isCancelled()) { + resultFuture.cancel(false); + return; + } + try { + actionFuture.join(); + } catch (Throwable ex) { + fail(resultFuture, ex); + return; + } + previousFuture = actionFuture; } + this.cleanup(); + this.frames.removeIf(Frame::isDone); + completeResult(resultFuture, previousFuture); + } + + private synchronized void resume(CompletableFuture resultFuture, CompletableFuture actionFuture, Throwable throwable) { + if (this.runningAction == actionFuture) { + this.runningAction = null; + } + if (this.future != resultFuture || resultFuture.isDone()) { + return; + } + if (throwable != null) { + fail(resultFuture, throwable); + } else { + process(actionFuture); + } + } + + private void fail(CompletableFuture resultFuture, Throwable throwable) { + this.cleanup(); + this.frames.removeIf(Frame::isDone); + completeFailure(resultFuture, throwable); + } + + @SuppressWarnings("unchecked") + private void completeResult(CompletableFuture resultFuture, CompletableFuture previousFuture) { + Object result = previousFuture != null ? previousFuture.getNow(null) : null; + ((CompletableFuture) resultFuture).complete(result); } private Optional> nextAction() { @@ -309,10 +402,17 @@ public void setNext(@NotNull Quest.Block block) { @Override @SuppressWarnings("unchecked") - public CompletableFuture run() { + public synchronized CompletableFuture run() { Preconditions.checkState(this.future == null, "already running"); this.varTable.initialize(this); - return (CompletableFuture) (this.future = this.action.process(this)); + try { + this.future = Objects.requireNonNull(this.action.process(this), "Quest action returned null future: " + action); + } catch (Throwable ex) { + CompletableFuture failed = new CompletableFuture<>(); + completeFailure(failed, ex); + this.future = failed; + } + return (CompletableFuture) this.future; } } diff --git a/module/minecraft/minecraft-kether/src/main/kotlin/taboolib/module/kether/RemoteQuestReader.kt b/module/minecraft/minecraft-kether/src/main/kotlin/taboolib/module/kether/RemoteQuestReader.kt index 92fe7aca4..d2529dc4d 100644 --- a/module/minecraft/minecraft-kether/src/main/kotlin/taboolib/module/kether/RemoteQuestReader.kt +++ b/module/minecraft/minecraft-kether/src/main/kotlin/taboolib/module/kether/RemoteQuestReader.kt @@ -12,44 +12,54 @@ import taboolib.library.kether.QuestReader @Suppress("UNCHECKED_CAST") class RemoteQuestReader(val remote: OpenContainer, val source: Any) : QuestReader { + @Synchronized override fun peek(): Char { return source.invokeMethod("peek", remap = false)!! } + @Synchronized override fun peek(n: Int): Char { return peekIntMethod[source].invoke(source, n) as Char } + @Synchronized override fun getIndex(): Int { return source.invokeMethod("getIndex", remap = false)!! } + @Synchronized override fun getMark(): Int { return source.invokeMethod("getMark", remap = false)!! } + @Synchronized override fun hasNext(): Boolean { return source.invokeMethod("hasNext", remap = false)!! } + @Synchronized override fun nextToken(): String { return source.invokeMethod("nextToken", remap = false)!! } + @Synchronized override fun mark() { source.invokeMethod("mark", remap = false) } + @Synchronized override fun reset() { source.invokeMethod("reset", remap = false) } + @Synchronized override fun nextAction(): ParsedAction { val action = source.invokeMethod("nextAction", remap = false)!! val questAction = RemoteQuestAction(remote, action.getProperty("action", remap = false)!!) return ParsedAction(questAction, action.getProperty>("properties", remap = false)!!) } + @Synchronized override fun nextAction(namespace: String?): ParsedAction { return try { val action = nextActionStringMethod[source].invoke(source, namespace)!! @@ -60,6 +70,7 @@ class RemoteQuestReader(val remote: OpenContainer, val source: Any) : QuestReade } } + @Synchronized override fun expect(value: String) { expectMethod[source].invoke(source, value) } diff --git a/module/minecraft/minecraft-kether/src/main/kotlin/taboolib/module/kether/action/game/bukkit/ActionScoreboard.kt b/module/minecraft/minecraft-kether/src/main/kotlin/taboolib/module/kether/action/game/bukkit/ActionScoreboard.kt index c6fee83f5..7264c67c8 100644 --- a/module/minecraft/minecraft-kether/src/main/kotlin/taboolib/module/kether/action/game/bukkit/ActionScoreboard.kt +++ b/module/minecraft/minecraft-kether/src/main/kotlin/taboolib/module/kether/action/game/bukkit/ActionScoreboard.kt @@ -4,9 +4,14 @@ import org.bukkit.entity.Player import taboolib.common.Inject import taboolib.common.platform.Platform import taboolib.common.platform.PlatformSide +import taboolib.common.platform.function.submit import taboolib.common.util.asList import taboolib.module.kether.* import taboolib.module.nms.sendScoreboard +import java.util.concurrent.CancellationException +import java.util.concurrent.CompletableFuture +import java.util.concurrent.CompletionException +import java.util.concurrent.atomic.AtomicReference @Inject @PlatformSide(Platform.BUKKIT) @@ -15,16 +20,84 @@ object ActionScoreboard { @KetherParser(["scoreboard"]) fun actionScoreboard() = scriptParser { val value = it.nextParsedAction() - actionNow { - run(value).thenAccept { o -> - val viewer = player().cast() - if (o == null) { - viewer.sendScoreboard() - } else { - val body = if (o is Collection<*> || o is Array<*>) o.asList() else o.toString().trimIndent().lines() - viewer.sendScoreboard(body[0], *body.filterIndexed { index, _ -> index > 0 }.toTypedArray()) + actionTake { + val viewer = player().cast() + val result = CompletableFuture() + val updateFuture = AtomicReference?>() + val contentFuture = run(value) + contentFuture.whenComplete { content, ex -> + if (ex != null) { + completeFailure(result, ex) + } else if (!result.isDone) { + val scoreboardFuture = updateScoreboard(viewer, content) + updateFuture.set(scoreboardFuture) + if (result.isCancelled) { + scoreboardFuture.cancel(false) + } else { + scoreboardFuture.whenComplete { _, updateEx -> + if (updateEx != null) { + completeFailure(result, updateEx) + } else { + result.complete(null) + } + } + } } } + result.whenComplete { _, _ -> + if (result.isCancelled) { + contentFuture.cancel(false) + updateFuture.get()?.cancel(false) + } + } + result + } + } + + private fun completeFailure(future: CompletableFuture<*>, throwable: Throwable) { + var cause = throwable + while (cause is CompletionException) { + val nested = cause.cause ?: break + cause = nested + } + if (cause is CancellationException) { + future.cancel(false) + } else { + future.completeExceptionally(cause) + } + } + + private fun updateScoreboard(viewer: Player, content: Any?): CompletableFuture { + val future = CompletableFuture() + try { + val task = submit { + if (future.isCancelled) { + return@submit + } + try { + val body = when (content) { + null -> emptyList() + is Collection<*>, is Array<*> -> content.asList() + else -> content.toString().trimIndent().lines() + } + if (body.isEmpty()) { + viewer.sendScoreboard() + } else { + viewer.sendScoreboard(body.first(), *body.drop(1).toTypedArray()) + } + future.complete(null) + } catch (ex: Throwable) { + future.completeExceptionally(ex) + } + } + future.whenComplete { _, _ -> + if (future.isCancelled) { + task.cancel() + } + } + } catch (ex: Throwable) { + future.completeExceptionally(ex) } + return future } -} \ No newline at end of file +} diff --git a/module/minecraft/minecraft-kether/src/test/java/taboolib/library/kether/AbstractQuestContextTest.java b/module/minecraft/minecraft-kether/src/test/java/taboolib/library/kether/AbstractQuestContextTest.java new file mode 100644 index 000000000..934e19ce9 --- /dev/null +++ b/module/minecraft/minecraft-kether/src/test/java/taboolib/library/kether/AbstractQuestContextTest.java @@ -0,0 +1,219 @@ +package taboolib.library.kether; + +import org.jetbrains.annotations.NotNull; +import org.junit.jupiter.api.Test; + +import java.util.Arrays; +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CompletionException; +import java.util.concurrent.Executor; +import java.util.concurrent.atomic.AtomicInteger; + +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertSame; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class AbstractQuestContextTest { + + @Test + void asynchronousActionFailureCompletesContextExceptionally() { + CompletableFuture actionFuture = new CompletableFuture<>(); + IllegalStateException failure = new IllegalStateException("boom"); + TestQuestContext context = context(action(frame -> actionFuture)); + + CompletableFuture result = context.runActions(); + actionFuture.completeExceptionally(failure); + + CompletionException thrown = assertThrows(CompletionException.class, result::join); + assertSame(failure, thrown.getCause()); + } + + @Test + void completedExceptionalActionDoesNotEscapeRunActions() { + IllegalStateException failure = new IllegalStateException("boom"); + CompletableFuture failed = new CompletableFuture<>(); + failed.completeExceptionally(failure); + TestQuestContext context = context(action(frame -> failed)); + + CompletableFuture result = assertDoesNotThrow(context::runActions); + + CompletionException thrown = assertThrows(CompletionException.class, result::join); + assertSame(failure, thrown.getCause()); + } + + @Test + void synchronousActionFailureStopsFollowingActions() { + IllegalStateException failure = new IllegalStateException("boom"); + AtomicInteger followingRuns = new AtomicInteger(); + TestQuestContext context = context( + action(frame -> { + throw failure; + }), + action(frame -> { + followingRuns.incrementAndGet(); + return CompletableFuture.completedFuture(null); + }) + ); + + CompletableFuture result = assertDoesNotThrow(context::runActions); + + CompletionException thrown = assertThrows(CompletionException.class, result::join); + assertSame(failure, thrown.getCause()); + assertEquals(0, followingRuns.get()); + } + + @Test + void actionFrameConvertsSynchronousFailureToFuture() { + IllegalStateException failure = new IllegalStateException("boom"); + TestQuestContext context = context(); + QuestContext.Frame frame = context.rootFrame().newFrame(action(ignored -> { + throw failure; + })); + + CompletableFuture result = assertDoesNotThrow(() -> { + return frame.run(); + }); + + CompletionException thrown = assertThrows(CompletionException.class, result::join); + assertSame(failure, thrown.getCause()); + } + + @Test + void exitStatusCompletesWithLastActionValue() { + AtomicInteger closes = new AtomicInteger(); + TestQuestContext context = context(action(frame -> { + frame.addClosable(closes::incrementAndGet); + frame.context().setExitStatus(ExitStatus.success()); + return CompletableFuture.completedFuture(7); + })); + + assertEquals(7, context.runActions().join()); + assertEquals(1, closes.get()); + } + + @Test + void cancellingContextCancelsRunningAction() { + CompletableFuture actionFuture = new CompletableFuture<>(); + TestQuestContext context = context(action(frame -> actionFuture)); + CompletableFuture result = context.runActions(); + + assertTrue(result.cancel(false)); + + assertTrue(actionFuture.isCancelled()); + assertTrue(result.isCancelled()); + } + + @Test + void terminatingContextClosesFrameAndRunningAction() { + CompletableFuture actionFuture = new CompletableFuture<>(); + TestQuestContext context = context(action(frame -> actionFuture)); + CompletableFuture result = context.runActions(); + + context.terminate(); + + assertTrue(actionFuture.isCancelled()); + assertTrue(result.isCompletedExceptionally()); + assertFalse(result.isCancelled()); + } + + @SafeVarargs + private final TestQuestContext context(ParsedAction... actions) { + return new TestQuestContext(new TestQuest(Arrays.asList(actions))); + } + + private ParsedAction action(ActionProcessor processor) { + return new ParsedAction<>(new QuestAction() { + @Override + public CompletableFuture process(@NotNull QuestContext.Frame frame) { + return processor.process(frame); + } + }); + } + + private interface ActionProcessor { + + CompletableFuture process(QuestContext.Frame frame); + } + + private static class TestQuestContext extends AbstractQuestContext { + + TestQuestContext(Quest quest) { + super(null, quest, "test"); + } + + @Override + protected Executor createExecutor() { + return Runnable::run; + } + } + + private static class TestQuest implements Quest { + + private final Map blocks; + + TestQuest(List> actions) { + Map values = new LinkedHashMap<>(); + values.put(QuestContext.BASE_BLOCK, new TestBlock(QuestContext.BASE_BLOCK, actions)); + this.blocks = Collections.unmodifiableMap(values); + } + + @Override + public String getId() { + return "test"; + } + + @Override + public Optional getBlock(@NotNull String label) { + return Optional.ofNullable(blocks.get(label)); + } + + @Override + public Map getBlocks() { + return blocks; + } + + @Override + public Optional blockOf(@NotNull ParsedAction action) { + return blocks.values().stream().filter(block -> block.indexOf(action) >= 0).findFirst(); + } + } + + private static class TestBlock implements Quest.Block { + + private final String label; + private final List> actions; + + TestBlock(String label, List> actions) { + this.label = label; + this.actions = actions; + } + + @Override + public String getLabel() { + return label; + } + + @Override + public List> getActions() { + return actions; + } + + @Override + public int indexOf(@NotNull ParsedAction action) { + return actions.indexOf(action); + } + + @Override + public Optional> get(int index) { + return index >= 0 && index < actions.size() ? Optional.of(actions.get(index)) : Optional.empty(); + } + } +} diff --git a/module/minecraft/minecraft-kether/src/test/kotlin/taboolib/module/kether/RemoteQuestReaderTest.kt b/module/minecraft/minecraft-kether/src/test/kotlin/taboolib/module/kether/RemoteQuestReaderTest.kt new file mode 100644 index 000000000..caf844ff1 --- /dev/null +++ b/module/minecraft/minecraft-kether/src/test/kotlin/taboolib/module/kether/RemoteQuestReaderTest.kt @@ -0,0 +1,64 @@ +package taboolib.module.kether + +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Test +import taboolib.common.OpenContainer +import taboolib.common.OpenResult +import java.util.concurrent.CountDownLatch +import java.util.concurrent.Executors +import java.util.concurrent.TimeUnit +import java.util.concurrent.atomic.AtomicInteger +import java.util.concurrent.locks.LockSupport + +class RemoteQuestReaderTest { + + @Test + fun `reader operations are serialized per remote source`() { + val source = ConcurrentReaderSource() + val reader = RemoteQuestReader(TestContainer, source) + val executor = Executors.newFixedThreadPool(8) + val start = CountDownLatch(1) + try { + val tasks = List(32) { + executor.submit { + start.await() + reader.nextToken() + } + } + start.countDown() + tasks.forEach { future -> + assertEquals("token", future.get(5, TimeUnit.SECONDS)) + } + } finally { + executor.shutdownNow() + } + + assertEquals(1, source.maxConcurrentCalls.get()) + } + + private class ConcurrentReaderSource { + + private val activeCalls = AtomicInteger() + val maxConcurrentCalls = AtomicInteger() + + fun nextToken(): String { + val active = activeCalls.incrementAndGet() + maxConcurrentCalls.updateAndGet { current -> maxOf(current, active) } + try { + LockSupport.parkNanos(TimeUnit.MILLISECONDS.toNanos(2)) + return "token" + } finally { + activeCalls.decrementAndGet() + } + } + } + + private object TestContainer : OpenContainer { + + override fun isValid() = true + + override fun getName() = "test" + + override fun call(name: String, args: Array) = OpenResult.failed() + } +} From 335ebf7814cdf59aaa1551e892c706f421300cf7 Mon Sep 17 00:00:00 2001 From: zhibei <785740487@qq.com> Date: Mon, 13 Jul 2026 13:10:06 +0800 Subject: [PATCH 13/37] =?UTF-8?q?fix(common):=20=E4=BF=AE=E5=A4=8D?= =?UTF-8?q?=E5=85=B1=E4=BA=AB=E5=9F=BA=E7=A1=80=E7=BB=84=E4=BB=B6=E5=B9=B6?= =?UTF-8?q?=E5=8F=91=E7=AB=9E=E6=80=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 保证事件监听、类访问器缓存与延迟集合并发安全,并让命令树只构建一次且隔离每次执行结果。 --- .../platform/command/CommandRegister.kt | 40 ++++--- .../platform/command/component/CommandBase.kt | 35 ++++-- .../CommandRegistrationConcurrencyTest.kt | 100 +++++++++++++++++ .../common/inject/ClassVisitorHandler.java | 102 ++++++++++-------- .../taboolib/common/event/InternalEventBus.kt | 8 +- .../event/InternalEventBusConcurrencyTest.kt | 47 ++++++++ .../ClassVisitorHandlerConcurrencyTest.kt | 57 ++++++++++ 7 files changed, 322 insertions(+), 67 deletions(-) create mode 100644 common-platform-api/src/test/kotlin/taboolib/common/platform/command/CommandRegistrationConcurrencyTest.kt create mode 100644 common-util/src/test/kotlin/taboolib/common/event/InternalEventBusConcurrencyTest.kt create mode 100644 common-util/src/test/kotlin/taboolib/common/inject/ClassVisitorHandlerConcurrencyTest.kt diff --git a/common-platform-api/src/main/kotlin/taboolib/common/platform/command/CommandRegister.kt b/common-platform-api/src/main/kotlin/taboolib/common/platform/command/CommandRegister.kt index 3c6eb4056..e17f0e843 100644 --- a/common-platform-api/src/main/kotlin/taboolib/common/platform/command/CommandRegister.kt +++ b/common-platform-api/src/main/kotlin/taboolib/common/platform/command/CommandRegister.kt @@ -4,6 +4,26 @@ import taboolib.common.platform.ProxyCommandSender import taboolib.common.platform.command.component.CommandBase import taboolib.common.platform.function.registerCommand +internal data class CommandHandlers(val executor: CommandExecutor, val completer: CommandCompleter) + +internal fun createCommandHandlers(newParser: Boolean, commandBuilder: CommandBase.() -> Unit): CommandHandlers { + val commandBase = CommandBase().also(commandBuilder) + return CommandHandlers( + executor = object : CommandExecutor { + + override fun execute(sender: ProxyCommandSender, command: CommandStructure, name: String, args: Array): Boolean { + return commandBase.execute(CommandContext(sender, command, name, commandBase, newParser, args)) + } + }, + completer = object : CommandCompleter { + + override fun execute(sender: ProxyCommandSender, command: CommandStructure, name: String, args: Array): List? { + return commandBase.suggest(CommandContext(sender, command, name, commandBase, newParser, args)) + } + } + ) +} + /** * 注册一个命令 * @@ -29,25 +49,13 @@ fun command( newParser: Boolean = false, commandBuilder: CommandBase.() -> Unit, ) { + val handlers = createCommandHandlers(newParser, commandBuilder) registerCommand( // 创建命令结构 CommandStructure(name, aliases, description, usage, permission, permissionMessage, permissionDefault, permissionChildren, newParser), - // 创建执行器 - object : CommandExecutor { - - override fun execute(sender: ProxyCommandSender, command: CommandStructure, name: String, args: Array): Boolean { - val commandBase = CommandBase().also(commandBuilder) - return commandBase.execute(CommandContext(sender, command, name, commandBase, newParser, args)) - } - }, - // 创建补全器 - object : CommandCompleter { - - override fun execute(sender: ProxyCommandSender, command: CommandStructure, name: String, args: Array): List? { - val commandBase = CommandBase().also(commandBuilder) - return commandBase.suggest(CommandContext(sender, command, name, commandBase, newParser, args)) - } - }, + // 复用注册阶段构建的命令树 + handlers.executor, + handlers.completer, // 传入原始命令构建器 commandBuilder ) diff --git a/common-platform-api/src/main/kotlin/taboolib/common/platform/command/component/CommandBase.kt b/common-platform-api/src/main/kotlin/taboolib/common/platform/command/component/CommandBase.kt index 82a9abe38..0174d2624 100644 --- a/common-platform-api/src/main/kotlin/taboolib/common/platform/command/component/CommandBase.kt +++ b/common-platform-api/src/main/kotlin/taboolib/common/platform/command/component/CommandBase.kt @@ -6,11 +6,12 @@ import taboolib.common.platform.command.CommandContext import taboolib.common.platform.service.PlatformCommand import taboolib.common.util.subList import taboolib.common.util.t +import java.util.ArrayDeque @Suppress("DuplicatedCode") class CommandBase : CommandComponent(-1, false) { - internal var result = true + private val resultStack = ThreadLocal.withInitial { ArrayDeque() } internal var commandIncorrectSender: CommandUnknownNotify<*> = CommandUnknownNotify(ProxyCommandSender::class.java) { sender, _, _, _ -> @@ -65,7 +66,19 @@ class CommandBase : CommandComponent(-1, false) { } fun execute(context: CommandContext<*>): Boolean { - result = true + val results = resultStack.get() + results.addLast(true) + return try { + executeInternal(context) + } finally { + results.removeLast() + if (results.isEmpty()) { + resultStack.remove() + } + } + } + + private fun executeInternal(context: CommandContext<*>): Boolean { // 空参数是一种特殊的状态,指的是玩家输入根命令且不附带任何参数,例如 [/test] 而不是 [/test ] if (context.realArgs.isEmpty()) { // 获取下级节点 @@ -84,7 +97,7 @@ class CommandBase : CommandComponent(-1, false) { } else { commandExecutor!!.exec(this, context, "") } - result + currentResult() } else { commandIncorrectCommand.exec(context, -1, 1) false @@ -117,7 +130,7 @@ class CommandBase : CommandComponent(-1, false) { } else { find.commandExecutor!!.exec(this, context, context.self()) } - result + currentResult() } else { commandIncorrectCommand.exec(context, cur + 1, 1) false @@ -174,7 +187,17 @@ class CommandBase : CommandComponent(-1, false) { this.commandIncorrectCommand = CommandUnknownNotify(ProxyCommandSender::class.java, function) } + private fun currentResult(): Boolean { + return resultStack.get().peekLast() ?: true + } + fun setResult(value: Boolean) { - result = value + val results = resultStack.get() + if (results.isEmpty()) { + resultStack.remove() + return + } + results.removeLast() + results.addLast(value) } -} \ No newline at end of file +} diff --git a/common-platform-api/src/test/kotlin/taboolib/common/platform/command/CommandRegistrationConcurrencyTest.kt b/common-platform-api/src/test/kotlin/taboolib/common/platform/command/CommandRegistrationConcurrencyTest.kt new file mode 100644 index 000000000..33937ec15 --- /dev/null +++ b/common-platform-api/src/test/kotlin/taboolib/common/platform/command/CommandRegistrationConcurrencyTest.kt @@ -0,0 +1,100 @@ +package taboolib.common.platform.command + +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertFalse +import org.junit.jupiter.api.Assertions.assertSame +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test +import taboolib.common.platform.ProxyCommandSender +import taboolib.common.platform.command.component.CommandBase +import java.util.concurrent.CopyOnWriteArrayList +import java.util.concurrent.CountDownLatch +import java.util.concurrent.Executors +import java.util.concurrent.TimeUnit +import java.util.concurrent.atomic.AtomicInteger + +class CommandRegistrationConcurrencyTest { + + @Test + fun `command tree is built once and reused by executions`() { + val builds = AtomicInteger() + val commandBases = CopyOnWriteArrayList() + val handlers = createCommandHandlers(false) { + builds.incrementAndGet() + execute(ProxyCommandSender::class.java) { _, context, _ -> + commandBases += context.commandCompound + } + dynamic("value") { + suggestionUncheck { _, context -> + commandBases += context.commandCompound + listOf("value") + } + } + } + val command = command() + val sender = TestSender("sender") + + assertTrue(handlers.executor.execute(sender, command, command.name, emptyArray())) + assertEquals(listOf("value"), handlers.completer.execute(sender, command, command.name, arrayOf(""))) + + assertEquals(1, builds.get()) + assertEquals(2, commandBases.size) + assertSame(commandBases.first(), commandBases.last()) + } + + @Test + fun `concurrent executions keep independent result state`() { + val falseResultSet = CountDownLatch(1) + val trueResultSet = CountDownLatch(1) + val handlers = createCommandHandlers(false) { + execute(ProxyCommandSender::class.java) { sender, context, _ -> + if (sender.name == "false") { + context.commandCompound.setResult(false) + falseResultSet.countDown() + assertTrue(trueResultSet.await(5, TimeUnit.SECONDS)) + } else { + assertTrue(falseResultSet.await(5, TimeUnit.SECONDS)) + context.commandCompound.setResult(true) + trueResultSet.countDown() + } + } + } + val command = command() + val executor = Executors.newFixedThreadPool(2) + try { + val falseFuture = executor.submit { + handlers.executor.execute(TestSender("false"), command, command.name, emptyArray()) + } + val trueFuture = executor.submit { + handlers.executor.execute(TestSender("true"), command, command.name, emptyArray()) + } + + assertFalse(falseFuture.get(10, TimeUnit.SECONDS)) + assertTrue(trueFuture.get(10, TimeUnit.SECONDS)) + } finally { + trueResultSet.countDown() + executor.shutdownNow() + executor.awaitTermination(5, TimeUnit.SECONDS) + } + } + + private fun command(): CommandStructure { + return CommandStructure("test", emptyList(), "", "", "", "", PermissionDefault.OP, emptyMap(), false) + } + + private class TestSender(override val name: String) : ProxyCommandSender { + + override val origin: Any + get() = this + + override var isOp = false + + override fun isOnline() = true + + override fun sendMessage(message: String) = Unit + + override fun performCommand(command: String) = true + + override fun hasPermission(permission: String) = true + } +} diff --git a/common-util/src/main/java/taboolib/common/inject/ClassVisitorHandler.java b/common-util/src/main/java/taboolib/common/inject/ClassVisitorHandler.java index 6133fc3a8..d7e3684b6 100644 --- a/common-util/src/main/java/taboolib/common/inject/ClassVisitorHandler.java +++ b/common-util/src/main/java/taboolib/common/inject/ClassVisitorHandler.java @@ -13,6 +13,9 @@ import taboolib.common.platform.DelayTo; import java.util.*; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.ConcurrentSkipListMap; +import java.util.function.Supplier; import java.util.stream.Collectors; /** @@ -25,9 +28,9 @@ @SuppressWarnings("CallToPrintStackTrace") public class ClassVisitorHandler { - private static final NavigableMap propertyMap = Collections.synchronizedNavigableMap(new TreeMap<>()); - private static final Map> delayedClasses = Collections.synchronizedMap(new HashMap<>()); - private static Set classes = null; + private static final NavigableMap propertyMap = new ConcurrentSkipListMap<>(); + private static final Map> delayedClasses = new ConcurrentHashMap<>(); + private static volatile Set classes = null; /** * 初始化函数 @@ -49,43 +52,59 @@ static void init() { * 获取能够被 ClassVisitor 访问到的所有类 */ public static Set getClasses() { - if (classes == null) { - long time = TabooLib.execution(() -> { - // 获取所有类 - // 这里会首次触发 runningClassMapInJar 的初始化 - Map allClasses = ProjectScannerKt.getRunningClassMap(); - // 第一阶段:基于类名快速过滤(不触发反序列化) - long phase1Start = System.currentTimeMillis(); - List> candidates = allClasses.entrySet().parallelStream() - .filter(entry -> { - String key = entry.getKey(); - // 排除非本项目 && 排除第三方库 && 排除匿名内部类 - return isProjectClass(key) && !isLibraryClass(key) && !isAnonymousInnerClass(key); - }) - .collect(Collectors.toList()); - long phase1Time = System.currentTimeMillis() - phase1Start; - PrimitiveIO.debug("ClassVisitor 第一阶段过滤: {0} -> {1} 个候选类,用时 {2} 毫秒。", allClasses.size(), candidates.size(), phase1Time); - // 第二阶段:并行检查注解和平台条件(会触发反序列化,但只针对候选类) - long phase2Start = System.currentTimeMillis(); - classes = candidates.parallelStream() - .filter(entry -> { - String key = entry.getKey(); - ReflexClass value = entry.getValue(); - // 排除属于 TabooLib 但没有 Inject 注解的类 - if (isTabooLibClass(key) && !value.getStructure().isAnnotationPresent(Inject.class)) { - return false; - } - // 检测有效平台 & 条件注解 - return checkPlatform(value) && checkRequires(value); - }) - .map(Map.Entry::getValue) - .collect(Collectors.toCollection(LinkedHashSet::new)); - long phase2Time = System.currentTimeMillis() - phase2Start; - PrimitiveIO.debug("ClassVisitor 第二阶段过滤: {0} -> {1} 个有效类,用时 {2} 毫秒。", candidates.size(), classes.size(), phase2Time); - }); - PrimitiveIO.debug("ClassVisitor 总用时 {0} 毫秒。", time); + return getOrInitializeClasses(ClassVisitorHandler::scanClasses); + } + + static Set getOrInitializeClasses(Supplier> initializer) { + Set current = classes; + if (current == null) { + synchronized (ClassVisitorHandler.class) { + current = classes; + if (current == null) { + Set initialized = Objects.requireNonNull(initializer.get(), "Class initializer returned null"); + current = Collections.unmodifiableSet(new LinkedHashSet<>(initialized)); + classes = current; + } + } } - return classes; + return current; + } + + private static Set scanClasses() { + long startTime = System.currentTimeMillis(); + // 获取所有类 + // 这里会首次触发 runningClassMapInJar 的初始化 + Map allClasses = ProjectScannerKt.getRunningClassMap(); + // 第一阶段:基于类名快速过滤(不触发反序列化) + long phase1Start = System.currentTimeMillis(); + List> candidates = allClasses.entrySet().parallelStream() + .filter(entry -> { + String key = entry.getKey(); + // 排除非本项目 && 排除第三方库 && 排除匿名内部类 + return isProjectClass(key) && !isLibraryClass(key) && !isAnonymousInnerClass(key); + }) + .collect(Collectors.toList()); + long phase1Time = System.currentTimeMillis() - phase1Start; + PrimitiveIO.debug("ClassVisitor 第一阶段过滤: {0} -> {1} 个候选类,用时 {2} 毫秒。", allClasses.size(), candidates.size(), phase1Time); + // 第二阶段:并行检查注解和平台条件(会触发反序列化,但只针对候选类) + long phase2Start = System.currentTimeMillis(); + Set filteredClasses = candidates.parallelStream() + .filter(entry -> { + String key = entry.getKey(); + ReflexClass value = entry.getValue(); + // 排除属于 TabooLib 但没有 Inject 注解的类 + if (isTabooLibClass(key) && !value.getStructure().isAnnotationPresent(Inject.class)) { + return false; + } + // 检测有效平台 & 条件注解 + return checkPlatform(value) && checkRequires(value); + }) + .map(Map.Entry::getValue) + .collect(Collectors.toCollection(LinkedHashSet::new)); + long phase2Time = System.currentTimeMillis() - phase2Start; + PrimitiveIO.debug("ClassVisitor 第二阶段过滤: {0} -> {1} 个有效类,用时 {2} 毫秒。", candidates.size(), filteredClasses.size(), phase2Time); + PrimitiveIO.debug("ClassVisitor 总用时 {0} 毫秒。", System.currentTimeMillis() - startTime); + return filteredClasses; } /** @@ -263,7 +282,7 @@ public static void injectAll(@NotNull ReflexClass clazz) { public static void injectAll(@NotNull LifeCycle lifeCycle) { long startTime = System.currentTimeMillis(); // 处理延迟注入的类 - final Set delayedForThisCycle = delayedClasses.get(lifeCycle); + final Set delayedForThisCycle = delayedClasses.remove(lifeCycle); if (delayedForThisCycle != null) { final List cyclesUtilNow = Arrays.stream(LifeCycle.values()).filter(cycle -> cycle.ordinal() < lifeCycle.ordinal()).collect(Collectors.toList()); for (final LifeCycle cycle : cyclesUtilNow) { @@ -273,7 +292,6 @@ public static void injectAll(@NotNull LifeCycle lifeCycle) { } } } - delayedClasses.remove(lifeCycle); } // 处理正常的类注入 Set allClasses = getClasses(); @@ -348,7 +366,7 @@ public static void inject(@NotNull ReflexClass clazz, @NotNull VisitorGroup grou if (lifeCycle != null && clazz.getStructure().isAnnotationPresent(DelayTo.class) && !isDelayTo) { final LifeCycle delayTo = clazz.getStructure().getAnnotation(DelayTo.class).getEnum("value", LifeCycle.CONST); if (delayTo.ordinal() > lifeCycle.ordinal()) { - delayedClasses.computeIfAbsent(delayTo, k -> Collections.synchronizedSet(new HashSet<>())).add(clazz); + delayedClasses.computeIfAbsent(delayTo, k -> ConcurrentHashMap.newKeySet()).add(clazz); return; } } diff --git a/common-util/src/main/kotlin/taboolib/common/event/InternalEventBus.kt b/common-util/src/main/kotlin/taboolib/common/event/InternalEventBus.kt index 12df1f83e..abdbd7419 100644 --- a/common-util/src/main/kotlin/taboolib/common/event/InternalEventBus.kt +++ b/common-util/src/main/kotlin/taboolib/common/event/InternalEventBus.kt @@ -46,10 +46,10 @@ interface InternalEventBus { var impl = object : InternalEventBus { /** 已注册的监听器 */ - val registeredListeners = ConcurrentHashMap, MutableMap>>() + val registeredListeners = ConcurrentHashMap, ConcurrentSkipListMap>>() override fun isListening(cls: Class<*>): Boolean { - return registeredListeners.containsKey(cls) && registeredListeners[cls]!!.any { it.value.isNotEmpty() } + return registeredListeners[cls]?.values?.any { it.isNotEmpty() } == true } override fun call(event: T) { @@ -66,7 +66,9 @@ interface InternalEventBus { @Suppress("UNCHECKED_CAST") override fun listen(cls: Class, priority: Int, ignoreCancelled: Boolean, listener: (event: T) -> Unit): InternalListener { val registeredListener = RegisteredListener(cls, priority, ignoreCancelled, listener as (Any) -> Unit) - registeredListeners.getOrPut(cls) { ConcurrentSkipListMap() }.getOrPut(priority) { CopyOnWriteArrayList() }.add(registeredListener) + registeredListeners.computeIfAbsent(cls) { ConcurrentSkipListMap() } + .computeIfAbsent(priority) { CopyOnWriteArrayList() } + .add(registeredListener) return registeredListener } diff --git a/common-util/src/test/kotlin/taboolib/common/event/InternalEventBusConcurrencyTest.kt b/common-util/src/test/kotlin/taboolib/common/event/InternalEventBusConcurrencyTest.kt new file mode 100644 index 000000000..bfddbd344 --- /dev/null +++ b/common-util/src/test/kotlin/taboolib/common/event/InternalEventBusConcurrencyTest.kt @@ -0,0 +1,47 @@ +package taboolib.common.event + +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Test +import java.util.concurrent.CompletableFuture +import java.util.concurrent.CopyOnWriteArrayList +import java.util.concurrent.CyclicBarrier +import java.util.concurrent.Executors +import java.util.concurrent.TimeUnit +import java.util.concurrent.atomic.AtomicInteger + +class InternalEventBusConcurrencyTest { + + private class TestEvent : InternalEvent() + + @Test + fun `concurrent listeners at the same priority are not lost`() { + val threadCount = 24 + val executor = Executors.newFixedThreadPool(threadCount) + val registered = CopyOnWriteArrayList() + try { + repeat(50) { round -> + val barrier = CyclicBarrier(threadCount) + val calls = AtomicInteger() + val futures = (0 until threadCount).map { + CompletableFuture.supplyAsync({ + barrier.await(5, TimeUnit.SECONDS) + InternalEventBus.listen(TestEvent::class.java, Int.MIN_VALUE + round, false) { + calls.incrementAndGet() + } + }, executor) + } + futures.forEach { registered += it.get(10, TimeUnit.SECONDS) } + + InternalEventBus.call(TestEvent()) + + assertEquals(threadCount, calls.get(), "round $round lost registered listeners") + registered.forEach(InternalListener::cancel) + registered.clear() + } + } finally { + registered.forEach(InternalListener::cancel) + executor.shutdownNow() + executor.awaitTermination(5, TimeUnit.SECONDS) + } + } +} diff --git a/common-util/src/test/kotlin/taboolib/common/inject/ClassVisitorHandlerConcurrencyTest.kt b/common-util/src/test/kotlin/taboolib/common/inject/ClassVisitorHandlerConcurrencyTest.kt new file mode 100644 index 000000000..3cbe56dbc --- /dev/null +++ b/common-util/src/test/kotlin/taboolib/common/inject/ClassVisitorHandlerConcurrencyTest.kt @@ -0,0 +1,57 @@ +package taboolib.common.inject + +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertSame +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test +import org.tabooproject.reflex.ReflexClass +import java.util.concurrent.CountDownLatch +import java.util.concurrent.Executors +import java.util.concurrent.TimeUnit +import java.util.concurrent.atomic.AtomicInteger + +class ClassVisitorHandlerConcurrencyTest { + + @Test + fun `class set is initialized once and safely published`() { + val classesField = ClassVisitorHandler::class.java.getDeclaredField("classes").also { it.isAccessible = true } + val previous = classesField.get(null) + classesField.set(null, null) + + val threadCount = 16 + val ready = CountDownLatch(threadCount) + val start = CountDownLatch(1) + val initializerStarted = CountDownLatch(1) + val releaseInitializer = CountDownLatch(1) + val initializerCalls = AtomicInteger() + val executor = Executors.newFixedThreadPool(threadCount) + try { + val futures = (0 until threadCount).map { + executor.submit> { + ready.countDown() + assertTrue(start.await(5, TimeUnit.SECONDS)) + ClassVisitorHandler.getOrInitializeClasses { + initializerCalls.incrementAndGet() + initializerStarted.countDown() + assertTrue(releaseInitializer.await(5, TimeUnit.SECONDS)) + emptySet() + } + } + } + + assertTrue(ready.await(5, TimeUnit.SECONDS)) + start.countDown() + assertTrue(initializerStarted.await(5, TimeUnit.SECONDS)) + releaseInitializer.countDown() + + val results = futures.map { it.get(10, TimeUnit.SECONDS) } + assertEquals(1, initializerCalls.get()) + results.drop(1).forEach { assertSame(results.first(), it) } + } finally { + releaseInitializer.countDown() + executor.shutdownNow() + executor.awaitTermination(5, TimeUnit.SECONDS) + classesField.set(null, previous) + } + } +} From 2161a1ec7d8b5f3036e71add818794c0ea17de20 Mon Sep 17 00:00:00 2001 From: zhibei <785740487@qq.com> Date: Mon, 13 Jul 2026 14:30:20 +0800 Subject: [PATCH 14/37] =?UTF-8?q?fix(database):=20=E5=AE=8C=E5=96=84?= =?UTF-8?q?=E6=95=B0=E6=8D=AE=E5=BA=93=E4=B8=8E=20Redis=20=E8=B5=84?= =?UTF-8?q?=E6=BA=90=E7=94=9F=E5=91=BD=E5=91=A8=E6=9C=9F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 确保数据源、连接、客户端和线程资源在启动失败、重连及并发关服场景下可靠释放,并补充相关回归测试。 --- .../kotlin/taboolib/expansion/AlkaidRedis.kt | 57 +++ .../expansion/ClusterRedisConnection.kt | 30 +- .../expansion/ClusterRedisConnector.kt | 14 +- .../expansion/SingleRedisConnection.kt | 111 +++-- .../expansion/SingleRedisConnector.kt | 9 +- .../expansion/RedisConnectionRegistryTest.kt | 49 ++ .../database-lettuce-redis/build.gradle.kts | 2 + .../expansion/LettuceClusterRedisClient.kt | 459 ++++++++++++------ .../kotlin/taboolib/expansion/LettuceRedis.kt | 87 +++- .../taboolib/expansion/LettuceRedisClient.kt | 417 +++++++++++----- .../LettuceRedisResourceRegistryTest.kt | 137 ++++++ .../expansion/RedisDatabaseHandler.kt | 61 ++- .../database/database-player/build.gradle.kts | 8 + .../kotlin/taboolib/expansion/Database.kt | 62 ++- .../com/zaxxer/hikari_4_0_3/HikariConfig.kt | 7 + .../zaxxer/hikari_4_0_3/HikariDataSource.kt | 7 + .../expansion/DatabaseLifecycleTest.kt | 75 +++ 17 files changed, 1254 insertions(+), 338 deletions(-) create mode 100644 module/database/database-alkaid-redis/src/test/kotlin/taboolib/expansion/RedisConnectionRegistryTest.kt create mode 100644 module/database/database-lettuce-redis/src/test/kotlin/taboolib/expansion/LettuceRedisResourceRegistryTest.kt create mode 100644 module/database/database-player/src/test/kotlin/com/zaxxer/hikari_4_0_3/HikariConfig.kt create mode 100644 module/database/database-player/src/test/kotlin/com/zaxxer/hikari_4_0_3/HikariDataSource.kt create mode 100644 module/database/database-player/src/test/kotlin/taboolib/expansion/DatabaseLifecycleTest.kt diff --git a/module/database/database-alkaid-redis/src/main/kotlin/taboolib/expansion/AlkaidRedis.kt b/module/database/database-alkaid-redis/src/main/kotlin/taboolib/expansion/AlkaidRedis.kt index ad735dc68..44a43410f 100644 --- a/module/database/database-alkaid-redis/src/main/kotlin/taboolib/expansion/AlkaidRedis.kt +++ b/module/database/database-alkaid-redis/src/main/kotlin/taboolib/expansion/AlkaidRedis.kt @@ -16,8 +16,50 @@ package taboolib.expansion import taboolib.common.Inject +import taboolib.common.LifeCycle import taboolib.common.env.RuntimeDependencies import taboolib.common.env.RuntimeDependency +import taboolib.common.platform.Awake +import java.io.Closeable +import java.util.concurrent.ConcurrentHashMap +import java.util.concurrent.atomic.AtomicBoolean + +internal class RedisConnectionRegistry { + + private val closed = AtomicBoolean(false) + private val connections = ConcurrentHashMap.newKeySet() + + fun register(connection: T): T { + if (closed.get()) { + runCatching { connection.close() } + return connection + } + connections += connection + if (closed.get() && connections.remove(connection)) { + runCatching { connection.close() } + } + return connection + } + + fun unregister(connection: Closeable) { + connections.remove(connection) + } + + fun closeAll() { + if (!closed.compareAndSet(false, true)) { + return + } + connections.toList().forEach { connection -> + if (connections.remove(connection)) { + runCatching { connection.close() } + } + } + } + + internal fun size(): Int { + return connections.size + } +} @Inject @RuntimeDependencies( @@ -52,6 +94,21 @@ import taboolib.common.env.RuntimeDependency ) object AlkaidRedis { + private val connections = RedisConnectionRegistry() + + internal fun register(connection: T): T { + return connections.register(connection) + } + + internal fun unregister(connection: Closeable) { + connections.unregister(connection) + } + + @Awake(LifeCycle.DISABLE) + internal fun stop() { + connections.closeAll() + } + /** * 创建 Redis 连接器 * diff --git a/module/database/database-alkaid-redis/src/main/kotlin/taboolib/expansion/ClusterRedisConnection.kt b/module/database/database-alkaid-redis/src/main/kotlin/taboolib/expansion/ClusterRedisConnection.kt index 86279cc08..2890e1252 100644 --- a/module/database/database-alkaid-redis/src/main/kotlin/taboolib/expansion/ClusterRedisConnection.kt +++ b/module/database/database-alkaid-redis/src/main/kotlin/taboolib/expansion/ClusterRedisConnection.kt @@ -16,9 +16,6 @@ package taboolib.expansion import redis.clients.jedis.JedisPubSub -import taboolib.common.Inject -import taboolib.common.LifeCycle -import taboolib.common.platform.Awake import taboolib.module.configuration.Configuration import taboolib.module.configuration.Type import java.io.Closeable @@ -26,20 +23,16 @@ import java.util.concurrent.CopyOnWriteArrayList import java.util.concurrent.ExecutorService import java.util.concurrent.Executors import java.util.concurrent.TimeUnit +import java.util.concurrent.atomic.AtomicBoolean class ClusterRedisConnection(val connector: ClusterRedisConnector) : Closeable, IRedisConnection { + private val closed = AtomicBoolean(false) + private val subscriptions = CopyOnWriteArrayList() private val service: ExecutorService = Executors.newCachedThreadPool() - @Inject - internal companion object { - - val resources = CopyOnWriteArrayList() - - @Awake(LifeCycle.DISABLE) - private fun onDisable() { - resources.forEach { runCatching { it.close() } } - } + init { + AlkaidRedis.register(this) } override fun eval(script: String, keys: List, args: List): Any? { @@ -51,9 +44,14 @@ class ClusterRedisConnection(val connector: ClusterRedisConnector) : Closeable, } override fun close() { - connector.close() - service.shutdown() - service.awaitTermination(30, TimeUnit.SECONDS) + if (!closed.compareAndSet(false, true)) { + return + } + subscriptions.forEach { runCatching { it.close() } } + subscriptions.clear() + service.shutdownNow() + runCatching { connector.close() } + AlkaidRedis.unregister(this) } override fun set(key: String, value: String?) { @@ -123,7 +121,7 @@ class ClusterRedisConnection(val connector: ClusterRedisConnector) : Closeable, return object : JedisPubSub() { init { - resources.add(Closeable { + subscriptions.add(Closeable { if (patternMode) { punsubscribe() } else { diff --git a/module/database/database-alkaid-redis/src/main/kotlin/taboolib/expansion/ClusterRedisConnector.kt b/module/database/database-alkaid-redis/src/main/kotlin/taboolib/expansion/ClusterRedisConnector.kt index 308a17d27..bd9944b6d 100644 --- a/module/database/database-alkaid-redis/src/main/kotlin/taboolib/expansion/ClusterRedisConnector.kt +++ b/module/database/database-alkaid-redis/src/main/kotlin/taboolib/expansion/ClusterRedisConnector.kt @@ -32,25 +32,37 @@ class ClusterRedisConnector : Closeable { var clientName: String = "default" lateinit var cluster: JedisCluster + private var active = false val nodes: LinkedHashSet = linkedSetOf() val genericObjectPoolConfig = GenericObjectPoolConfig() + @Synchronized fun build(): ClusterRedisConnector { + if (active) { + cluster.close() + } genericObjectPoolConfig.maxTotal = connect cluster = if (auth != null && pass != null) { JedisCluster(nodes, timeout, timeout, maxAttempts, auth, pass, clientName, genericObjectPoolConfig) } else { JedisCluster(nodes, timeout, timeout, genericObjectPoolConfig) } + active = true + AlkaidRedis.register(this) return this } /** * 关闭连接 */ + @Synchronized override fun close() { - cluster.close() + if (active) { + active = false + cluster.close() + } + AlkaidRedis.unregister(this) } /** diff --git a/module/database/database-alkaid-redis/src/main/kotlin/taboolib/expansion/SingleRedisConnection.kt b/module/database/database-alkaid-redis/src/main/kotlin/taboolib/expansion/SingleRedisConnection.kt index 7bc12d4e9..56e118363 100644 --- a/module/database/database-alkaid-redis/src/main/kotlin/taboolib/expansion/SingleRedisConnection.kt +++ b/module/database/database-alkaid-redis/src/main/kotlin/taboolib/expansion/SingleRedisConnection.kt @@ -19,42 +19,54 @@ import redis.clients.jedis.Jedis import redis.clients.jedis.JedisPool import redis.clients.jedis.JedisPubSub import redis.clients.jedis.exceptions.JedisConnectionException -import taboolib.common.Inject -import taboolib.common.LifeCycle import taboolib.common.PrimitiveIO -import taboolib.common.platform.Awake import taboolib.module.configuration.Configuration import taboolib.module.configuration.Type import java.io.Closeable import java.util.concurrent.CopyOnWriteArrayList import java.util.concurrent.ExecutorService import java.util.concurrent.Executors +import java.util.concurrent.ScheduledExecutorService import java.util.concurrent.TimeUnit +import java.util.concurrent.atomic.AtomicBoolean -class SingleRedisConnection(internal var pool: JedisPool, internal val connector: SingleRedisConnector): Closeable, IRedisConnection { +class SingleRedisConnection(@Volatile internal var pool: JedisPool, internal val connector: SingleRedisConnector): Closeable, IRedisConnection { + private val closed = AtomicBoolean(false) + private val subscriptions = CopyOnWriteArrayList() private val service: ExecutorService = Executors.newCachedThreadPool() + private val reconnectService: ScheduledExecutorService = Executors.newSingleThreadScheduledExecutor() - private fun exec(loop: Boolean = false, func: (Jedis) -> T): T { + init { + AlkaidRedis.register(this) + } + + private fun exec(func: (Jedis) -> T): T { + check(!closed.get()) { "Redis connection is closed" } + val currentPool = pool return try { - pool.resource.use { func(it) } + currentPool.resource.use { func(it) } } catch (ex: JedisConnectionException) { PrimitiveIO.error("Redis connection failed: ${ex.message}") - // 如果是循环模式则等待一段时间 - if (loop) { - Thread.sleep(connector.reconnectDelay) - } - // 重连 - pool = connector.connect().pool!! - // 重新执行 - if (loop) { - exec(true, func) - } else { - pool.resource.use { func(it) } - } + reconnect(currentPool).resource.use { func(it) } } } + @Synchronized + private fun reconnect(failedPool: JedisPool): JedisPool { + check(!closed.get()) { "Redis connection is closed" } + if (pool !== failedPool) { + return pool + } + val connectorPool = connector.pool + if (connectorPool != null && connectorPool !== failedPool) { + pool = connectorPool + return connectorPool + } + connector.connect() + return connector.pool!!.also { pool = it } + } + override fun eval(script: String, keys: List, args: List): Any? { return exec { it.eval(script, keys, args) @@ -71,7 +83,19 @@ class SingleRedisConnection(internal var pool: JedisPool, internal val connector * 关闭连接 */ override fun close() { - pool.destroy() + if (!closed.compareAndSet(false, true)) { + return + } + subscriptions.forEach { runCatching { it.close() } } + subscriptions.clear() + reconnectService.shutdownNow() + service.shutdownNow() + runCatching { + synchronized(this) { + pool.close() + } + } + AlkaidRedis.unregister(this) } /** @@ -151,17 +175,35 @@ class SingleRedisConnection(internal var pool: JedisPool, internal val connector * @param func 信息处理函数 */ override fun subscribe(vararg channel: String, patternMode: Boolean, func: RedisMessage.() -> Unit) { - service.submit { - try { - exec(true) { jedis -> - if (patternMode) { - jedis.psubscribe(createPubSub(true, func), *channel) - } else { - jedis.subscribe(createPubSub(false, func), *channel) + submitSubscription(channel, patternMode, createPubSub(patternMode, func)) + } + + private fun submitSubscription(channel: Array, patternMode: Boolean, pubSub: JedisPubSub) { + if (closed.get()) { + return + } + runCatching { + service.submit { + try { + exec { jedis -> + if (patternMode) { + jedis.psubscribe(pubSub, *channel) + } else { + jedis.subscribe(pubSub, *channel) + } + } + } catch (ex: Throwable) { + if (!closed.get()) { + PrimitiveIO.error("Redis subscription failed: ${ex.message}") + runCatching { + reconnectService.schedule( + { submitSubscription(channel, patternMode, pubSub) }, + connector.reconnectDelay, + TimeUnit.MILLISECONDS + ) + } } } - } catch (ex: Throwable) { - ex.printStackTrace() } } } @@ -170,7 +212,7 @@ class SingleRedisConnection(internal var pool: JedisPool, internal val connector return object : JedisPubSub() { init { - resources.add(Closeable { + subscriptions.add(Closeable { if (patternMode) { punsubscribe() } else { @@ -324,15 +366,4 @@ class SingleRedisConnection(internal var pool: JedisPool, internal val connector override fun type(key: String): String { return exec { it.type(key) } } - - @Inject - internal companion object { - - val resources = CopyOnWriteArrayList() - - @Awake(LifeCycle.DISABLE) - private fun onDisable() { - resources.forEach { runCatching { it.close() } } - } - } } diff --git a/module/database/database-alkaid-redis/src/main/kotlin/taboolib/expansion/SingleRedisConnector.kt b/module/database/database-alkaid-redis/src/main/kotlin/taboolib/expansion/SingleRedisConnector.kt index dbcf60c47..489cb7138 100644 --- a/module/database/database-alkaid-redis/src/main/kotlin/taboolib/expansion/SingleRedisConnector.kt +++ b/module/database/database-alkaid-redis/src/main/kotlin/taboolib/expansion/SingleRedisConnector.kt @@ -37,22 +37,29 @@ class SingleRedisConnector: Closeable { * * @return [SingleRedisConnector] */ + @Synchronized fun connect(): SingleRedisConnector { config.maxTotal = connect + val previousPool = pool pool = when { auth != null && pass != null -> JedisPool(config, host, port, timeout, auth, pass) auth != null -> JedisPool(config, host, port, timeout, auth, null) pass != null -> JedisPool(config, host, port, timeout, pass) else -> JedisPool(config, host, port, timeout) } + previousPool?.close() + AlkaidRedis.register(this) return this } /** * 关闭连接 */ + @Synchronized override fun close() { - pool?.destroy() + pool?.close() + pool = null + AlkaidRedis.unregister(this) } /** diff --git a/module/database/database-alkaid-redis/src/test/kotlin/taboolib/expansion/RedisConnectionRegistryTest.kt b/module/database/database-alkaid-redis/src/test/kotlin/taboolib/expansion/RedisConnectionRegistryTest.kt new file mode 100644 index 000000000..1cc28993a --- /dev/null +++ b/module/database/database-alkaid-redis/src/test/kotlin/taboolib/expansion/RedisConnectionRegistryTest.kt @@ -0,0 +1,49 @@ +package taboolib.expansion + +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Test +import java.io.Closeable +import java.util.concurrent.atomic.AtomicInteger + +class RedisConnectionRegistryTest { + + @Test + fun `registered connections close exactly once`() { + val registry = RedisConnectionRegistry() + val closeCount = AtomicInteger() + val connection = Closeable { closeCount.incrementAndGet() } + + registry.register(connection) + registry.register(connection) + registry.closeAll() + registry.closeAll() + + assertEquals(1, closeCount.get()) + assertEquals(0, registry.size()) + } + + @Test + fun `unregistered connection remains caller owned`() { + val registry = RedisConnectionRegistry() + val closeCount = AtomicInteger() + val connection = Closeable { closeCount.incrementAndGet() } + + registry.register(connection) + registry.unregister(connection) + registry.closeAll() + + assertEquals(0, closeCount.get()) + } + + @Test + fun `connection registered after shutdown closes immediately`() { + val registry = RedisConnectionRegistry() + val closeCount = AtomicInteger() + + registry.closeAll() + registry.register(Closeable { closeCount.incrementAndGet() }) + + assertEquals(1, closeCount.get()) + assertEquals(0, registry.size()) + } +} diff --git a/module/database/database-lettuce-redis/build.gradle.kts b/module/database/database-lettuce-redis/build.gradle.kts index 8d35c1a79..298ec264d 100644 --- a/module/database/database-lettuce-redis/build.gradle.kts +++ b/module/database/database-lettuce-redis/build.gradle.kts @@ -7,12 +7,14 @@ dependencies { // 使用 api 传递依赖 api("io.lettuce:lettuce-core:7.2.1.RELEASE") compileOnly("org.apache.commons:commons-pool2:2.12.1") + testImplementation("org.apache.commons:commons-pool2:2.12.1") compileOnly(project(":common")) compileOnly(project(":common-env")) compileOnly(project(":common-util")) compileOnly(project(":common-platform-api")) compileOnly(project(":module:basic:basic-configuration")) + testImplementation(project(":module:basic:basic-configuration")) } tasks { diff --git a/module/database/database-lettuce-redis/src/main/kotlin/taboolib/expansion/LettuceClusterRedisClient.kt b/module/database/database-lettuce-redis/src/main/kotlin/taboolib/expansion/LettuceClusterRedisClient.kt index a6a28a0d5..20cdb936a 100644 --- a/module/database/database-lettuce-redis/src/main/kotlin/taboolib/expansion/LettuceClusterRedisClient.kt +++ b/module/database/database-lettuce-redis/src/main/kotlin/taboolib/expansion/LettuceClusterRedisClient.kt @@ -24,198 +24,365 @@ import taboolib.expansion.lettuce.IRedisChannel import taboolib.expansion.lettuce.IRedisClient import taboolib.expansion.lettuce.cluster.IRedisClusterCommand import taboolib.expansion.lettuce.cluster.IRedisClusterPubSub +import java.util.concurrent.CancellationException import java.util.concurrent.CompletableFuture +import java.util.concurrent.atomic.AtomicBoolean +import java.util.concurrent.atomic.AtomicReference import kotlin.collections.plusAssign import kotlin.time.toJavaDuration @Suppress("DuplicatedCode") -class LettuceClusterRedisClient(val redisConfig: LettuceRedisConfig): IRedisClient, IRedisChannel, IRedisClusterCommand, IRedisClusterPubSub { +class LettuceClusterRedisClient(val redisConfig: LettuceRedisConfig): IRedisClient, IRedisChannel, IRedisClusterCommand, IRedisClusterPubSub, LettuceRedisResource { + @Volatile lateinit var client: RedisClusterClient + @Volatile lateinit var pool: GenericObjectPool> + + @Volatile lateinit var asyncPool: BoundedAsyncPool> + @Volatile lateinit var pubSubConnection: StatefulRedisClusterPubSubConnection + + @Volatile lateinit var resources: DefaultClientResources + private val startup = AtomicReference?>() + private val stopped = AtomicBoolean(false) + private val shutdownStarted = AtomicBoolean(false) + private val lifecycleLock = Any() + @OptIn(ExperimentalStdlibApi::class) override fun start(autoRelease: Boolean): CompletableFuture { val completableFuture = CompletableFuture() - val resource = DefaultClientResources.builder() - - if (redisConfig.ioThreadPoolSize != 0) { - resource.ioThreadPoolSize(redisConfig.ioThreadPoolSize) + if (!startup.compareAndSet(null, completableFuture)) { + val existingStartup = startup.get()!! + if (stopped.get() && !existingStartup.isCompletedExceptionally && !existingStartup.isCancelled) { + return CompletableFuture().also { + it.completeExceptionally(CancellationException("Redis cluster client is stopped")) + } + } + return existingStartup } - if (redisConfig.computationThreadPoolSize != 0) { - resource.computationThreadPoolSize(redisConfig.computationThreadPoolSize) + if (stopped.get()) { + completableFuture.completeExceptionally(CancellationException("Redis cluster client is stopped")) + return completableFuture } + try { + val resource = DefaultClientResources.builder() + if (redisConfig.ioThreadPoolSize != 0) { + resource.ioThreadPoolSize(redisConfig.ioThreadPoolSize) + } + if (redisConfig.computationThreadPoolSize != 0) { + resource.computationThreadPoolSize(redisConfig.computationThreadPoolSize) + } - val cluster = redisConfig.cluster - - val uris = cluster.nodes.map { - it.redisURIBuilder().build() - } - val clientOptions = ClusterClientOptions.builder() + val cluster = redisConfig.cluster + val uris = cluster.nodes.map { it.redisURIBuilder().build() } + val clientOptions = ClusterClientOptions.builder() + if (redisConfig.ssl) { + clientOptions.sslOptions(redisConfig.sslOptions) + } - if (redisConfig.ssl) { - clientOptions.sslOptions(redisConfig.sslOptions) - } + val topologyRefreshOptions = ClusterTopologyRefreshOptions.builder() + .enablePeriodicRefresh(cluster.enablePeriodicRefresh) + .refreshTriggersReconnectAttempts(cluster.refreshTriggersReconnectAttempts) + .dynamicRefreshSources(cluster.dynamicRefreshSources) + .closeStaleConnections(cluster.closeStaleConnections) + + // Lettuce 7.0+ 默认启用所有自适应触发器,需要禁用未配置的触发器 + val configuredTriggers = cluster.enableAdaptiveRefreshTrigger.toSet() + if (configuredTriggers.isEmpty()) { + topologyRefreshOptions.disableAllAdaptiveRefreshTriggers() + } else { + val triggersToDisable = ClusterTopologyRefreshOptions.RefreshTrigger.values() + .filter { it !in configuredTriggers } + .toTypedArray() + if (triggersToDisable.isNotEmpty()) { + topologyRefreshOptions.disableAdaptiveRefreshTrigger(*triggersToDisable) + } + } - val topologyRefreshOptions = ClusterTopologyRefreshOptions.builder() - .enablePeriodicRefresh(cluster.enablePeriodicRefresh) - .refreshTriggersReconnectAttempts(cluster.refreshTriggersReconnectAttempts) - .dynamicRefreshSources(cluster.dynamicRefreshSources) - .closeStaleConnections(cluster.closeStaleConnections) - - // Lettuce 7.0+ 默认启用所有自适应触发器,需要禁用未配置的触发器 - val configuredTriggers = cluster.enableAdaptiveRefreshTrigger.toSet() - if (configuredTriggers.isEmpty()) { - // 如果未配置任何触发器,禁用所有 - topologyRefreshOptions.disableAllAdaptiveRefreshTriggers() - } else { - // 禁用未配置的触发器 - val triggersToDisable = ClusterTopologyRefreshOptions.RefreshTrigger.values() - .filter { it !in configuredTriggers } - .toTypedArray() - if (triggersToDisable.isNotEmpty()) { - topologyRefreshOptions.disableAdaptiveRefreshTrigger(*triggersToDisable) + cluster.adaptiveRefreshTriggersTimeout?.toJavaDuration()?.let { + topologyRefreshOptions.adaptiveRefreshTriggersTimeout(it) + } + cluster.refreshPeriod?.toJavaDuration()?.let { + topologyRefreshOptions.refreshPeriod(it) + } + clientOptions + .topologyRefreshOptions(topologyRefreshOptions.build()) + .autoReconnect(redisConfig.autoReconnect) + .maxRedirects(cluster.maxRedirects) + .validateClusterNodeMembership(cluster.validateClusterNodeMembership) + .pingBeforeActivateConnection(redisConfig.pingBeforeActivateConnection) + + val newResources = resource.build() + val newClient = try { + RedisClusterClient.create(newResources, uris).apply { + setOptions(clientOptions.build()) + } + } catch (ex: Throwable) { + newResources.shutdown() + throw ex + } + synchronized(lifecycleLock) { + resources = newResources + client = newClient + } + if (stopped.get()) { + closeResources() + return completableFuture } - } - cluster.adaptiveRefreshTriggersTimeout?.toJavaDuration()?.let { topologyRefreshOptions.adaptiveRefreshTriggersTimeout(it) } - cluster.refreshPeriod?.toJavaDuration()?.let { topologyRefreshOptions.refreshPeriod(it) } - clientOptions - .topologyRefreshOptions(topologyRefreshOptions.build()) - .autoReconnect(redisConfig.autoReconnect) - .maxRedirects(cluster.maxRedirects) - .validateClusterNodeMembership(cluster.validateClusterNodeMembership) - .pingBeforeActivateConnection(redisConfig.pingBeforeActivateConnection) - - resources = resource.build() - client = RedisClusterClient.create(resources, uris) - client.setOptions(clientOptions.build()) - - // 连接 pub/sub 通道 - pubSubConnection = client.connectPubSub() - // 连接同步 - pool = ConnectionPoolSupport.createGenericObjectPool( - { client.connect().apply { - if (redisConfig.enableSlaves) { - val slaves = redisConfig.slaves - readFrom = slaves.readFrom + // 异步连接 pub/sub 通道,避免 start() 阻塞调用线程 + val pubSubReady = client.connectPubSubAsync(StringCodec.UTF8).thenAccept { + pubSubConnection = it + if (stopped.get()) { + it.closeAsync() } - } }, - redisConfig.pool.clusterPoolConfig() - ) - // 连接异步 - AsyncConnectionPoolSupport.createBoundedObjectPoolAsync( - { client.connectAsync(StringCodec.UTF8).whenComplete { v, _ -> - if (redisConfig.enableSlaves) { - val slaves = redisConfig.slaves - v.readFrom = slaves.readFrom + }.toCompletableFuture() + // 连接同步 + pool = ConnectionPoolSupport.createGenericObjectPool( + { + client.connect().apply { + if (redisConfig.enableSlaves) { + readFrom = redisConfig.slaves.readFrom + } + } + }, + redisConfig.pool.clusterPoolConfig() + ) + if (stopped.get()) { + pool.close() + } + // 连接异步 + val poolReady = AsyncConnectionPoolSupport.createBoundedObjectPoolAsync( + { + client.connectAsync(StringCodec.UTF8).whenComplete { value, _ -> + if (redisConfig.enableSlaves) { + value.readFrom = redisConfig.slaves.readFrom + } + } + }, + redisConfig.asyncPool.poolConfig() + ).thenAccept { + asyncPool = it + if (stopped.get()) { + it.closeAsync() } - } }, - redisConfig.asyncPool.poolConfig() - ).thenAccept { - asyncPool = it - completableFuture.complete(null) - } - if (autoRelease) { - LettuceRedis.clusterClients += this + }.toCompletableFuture() + coordinateStart(completableFuture, autoRelease, pubSubReady, poolReady) + } catch (ex: Throwable) { + failStart(completableFuture, ex) } return completableFuture } @OptIn(ExperimentalStdlibApi::class) override fun startSync(autoRelease: Boolean) { - val resource = DefaultClientResources.builder() - - if (redisConfig.ioThreadPoolSize != 0) { - resource.ioThreadPoolSize(redisConfig.ioThreadPoolSize) + val completableFuture = CompletableFuture() + if (!startup.compareAndSet(null, completableFuture)) { + val existingStartup = startup.get()!! + check(!stopped.get() && existingStartup.isDone && !existingStartup.isCompletedExceptionally && !existingStartup.isCancelled) { + "Redis cluster client is already starting or failed to start" + } + return } - if (redisConfig.computationThreadPoolSize != 0) { - resource.computationThreadPoolSize(redisConfig.computationThreadPoolSize) + if (stopped.get()) { + val error = IllegalStateException("Redis cluster client is stopped") + completableFuture.completeExceptionally(error) + throw error } + try { + val resource = DefaultClientResources.builder() + if (redisConfig.ioThreadPoolSize != 0) { + resource.ioThreadPoolSize(redisConfig.ioThreadPoolSize) + } + if (redisConfig.computationThreadPoolSize != 0) { + resource.computationThreadPoolSize(redisConfig.computationThreadPoolSize) + } - val cluster = redisConfig.cluster + val cluster = redisConfig.cluster + val uris = cluster.nodes.map { it.redisURIBuilder().build() } + val clientOptions = ClusterClientOptions.builder() + if (redisConfig.ssl) { + clientOptions.sslOptions(redisConfig.sslOptions) + } - val uris = cluster.nodes.map { - it.redisURIBuilder().build() - } - val clientOptions = ClusterClientOptions.builder() + val topologyRefreshOptions = ClusterTopologyRefreshOptions.builder() + .enablePeriodicRefresh(cluster.enablePeriodicRefresh) + .refreshTriggersReconnectAttempts(cluster.refreshTriggersReconnectAttempts) + .dynamicRefreshSources(cluster.dynamicRefreshSources) + .closeStaleConnections(cluster.closeStaleConnections) + + // Lettuce 7.0+ 默认启用所有自适应触发器,需要禁用未配置的触发器 + val configuredTriggers = cluster.enableAdaptiveRefreshTrigger.toSet() + if (configuredTriggers.isEmpty()) { + topologyRefreshOptions.disableAllAdaptiveRefreshTriggers() + } else { + val triggersToDisable = ClusterTopologyRefreshOptions.RefreshTrigger.values() + .filter { it !in configuredTriggers } + .toTypedArray() + if (triggersToDisable.isNotEmpty()) { + topologyRefreshOptions.disableAdaptiveRefreshTrigger(*triggersToDisable) + } + } - if (redisConfig.ssl) { - clientOptions.sslOptions(redisConfig.sslOptions) - } + cluster.adaptiveRefreshTriggersTimeout?.toJavaDuration()?.let { + topologyRefreshOptions.adaptiveRefreshTriggersTimeout(it) + } + cluster.refreshPeriod?.toJavaDuration()?.let { + topologyRefreshOptions.refreshPeriod(it) + } + clientOptions + .topologyRefreshOptions(topologyRefreshOptions.build()) + .autoReconnect(redisConfig.autoReconnect) + .maxRedirects(cluster.maxRedirects) + .validateClusterNodeMembership(cluster.validateClusterNodeMembership) + .pingBeforeActivateConnection(redisConfig.pingBeforeActivateConnection) + + val newResources = resource.build() + val newClient = try { + RedisClusterClient.create(newResources, uris).apply { + setOptions(clientOptions.build()) + } + } catch (ex: Throwable) { + newResources.shutdown() + throw ex + } + synchronized(lifecycleLock) { + resources = newResources + client = newClient + } + if (stopped.get()) { + closeResources() + error("Redis cluster client was stopped during startup") + } - val topologyRefreshOptions = ClusterTopologyRefreshOptions.builder() - .enablePeriodicRefresh(cluster.enablePeriodicRefresh) - .refreshTriggersReconnectAttempts(cluster.refreshTriggersReconnectAttempts) - .dynamicRefreshSources(cluster.dynamicRefreshSources) - .closeStaleConnections(cluster.closeStaleConnections) - - // Lettuce 7.0+ 默认启用所有自适应触发器,需要禁用未配置的触发器 - val configuredTriggers = cluster.enableAdaptiveRefreshTrigger.toSet() - if (configuredTriggers.isEmpty()) { - // 如果未配置任何触发器,禁用所有 - topologyRefreshOptions.disableAllAdaptiveRefreshTriggers() - } else { - // 禁用未配置的触发器 - val triggersToDisable = ClusterTopologyRefreshOptions.RefreshTrigger.values() - .filter { it !in configuredTriggers } - .toTypedArray() - if (triggersToDisable.isNotEmpty()) { - topologyRefreshOptions.disableAdaptiveRefreshTrigger(*triggersToDisable) + // 连接 pub/sub 通道 + pubSubConnection = client.connectPubSub() + if (stopped.get()) { + pubSubConnection.closeAsync() + error("Redis cluster client was stopped during startup") + } + // 连接同步 + pool = ConnectionPoolSupport.createGenericObjectPool( + { + client.connect().apply { + if (redisConfig.enableSlaves) { + readFrom = redisConfig.slaves.readFrom + } + } + }, + redisConfig.pool.clusterPoolConfig() + ) + if (stopped.get()) { + pool.close() + error("Redis cluster client was stopped during startup") } + // 连接异步(同步方式创建) + asyncPool = AsyncConnectionPoolSupport.createBoundedObjectPool( + { + client.connectAsync(StringCodec.UTF8).whenComplete { value, _ -> + if (redisConfig.enableSlaves) { + value.readFrom = redisConfig.slaves.readFrom + } + } + }, + redisConfig.asyncPool.poolConfig() + ) + if (stopped.get()) { + asyncPool.closeAsync() + error("Redis cluster client was stopped during startup") + } + completeStart(completableFuture, autoRelease) + } catch (ex: Throwable) { + failStart(completableFuture, ex) + throw ex } + } - cluster.adaptiveRefreshTriggersTimeout?.toJavaDuration()?.let { topologyRefreshOptions.adaptiveRefreshTriggersTimeout(it) } - cluster.refreshPeriod?.toJavaDuration()?.let { topologyRefreshOptions.refreshPeriod(it) } - clientOptions - .topologyRefreshOptions(topologyRefreshOptions.build()) - .autoReconnect(redisConfig.autoReconnect) - .maxRedirects(cluster.maxRedirects) - .validateClusterNodeMembership(cluster.validateClusterNodeMembership) - .pingBeforeActivateConnection(redisConfig.pingBeforeActivateConnection) - - resources = resource.build() - client = RedisClusterClient.create(resources, uris) - client.setOptions(clientOptions.build()) - - // 连接 pub/sub 通道 - pubSubConnection = client.connectPubSub() - // 连接同步 - pool = ConnectionPoolSupport.createGenericObjectPool( - { client.connect().apply { - if (redisConfig.enableSlaves) { - val slaves = redisConfig.slaves - readFrom = slaves.readFrom - } - } }, - redisConfig.pool.clusterPoolConfig() - ) - // 连接异步(同步方式创建) - asyncPool = AsyncConnectionPoolSupport.createBoundedObjectPool( - { client.connectAsync(StringCodec.UTF8).whenComplete { v, _ -> - if (redisConfig.enableSlaves) { - val slaves = redisConfig.slaves - v.readFrom = slaves.readFrom - } - } }, - redisConfig.asyncPool.poolConfig() + override fun stop() { + if (!stopped.compareAndSet(false, true)) { + return + } + LettuceRedis.unregister(this) + startup.get()?.completeExceptionally(CancellationException("Redis cluster client is stopped")) + closeResources() + } + + private fun coordinateStart( + completableFuture: CompletableFuture, + autoRelease: Boolean, + vararg stages: CompletableFuture<*> + ) { + val coordinator = AsyncStartupCoordinator( + stages.size, + onSuccess = { completeStart(completableFuture, autoRelease) }, + onFailure = { failStart(completableFuture, it) }, + onSettled = { if (stopped.get()) closeResources() }, ) + stages.forEach { stage -> + stage.whenComplete { _, error -> coordinator.complete(error) } + } + } + + private fun completeStart(completableFuture: CompletableFuture, autoRelease: Boolean) { + if (stopped.get()) { + completableFuture.completeExceptionally(CancellationException("Redis cluster client is stopped")) + closeResources() + return + } if (autoRelease) { - LettuceRedis.clusterClients += this + LettuceRedis.register(this) } + if (stopped.get()) { + LettuceRedis.unregister(this) + completableFuture.completeExceptionally(CancellationException("Redis cluster client is stopped")) + closeResources() + return + } + completableFuture.complete(null) } - override fun stop() { - pubSubConnection.close() - asyncPool.close() - pool.close() - client.shutdown() - resources.shutdown() + private fun failStart(completableFuture: CompletableFuture, error: Throwable) { + stopped.set(true) + LettuceRedis.unregister(this) + completableFuture.completeExceptionally(error) + closeResources() + } + + private fun closeResources() { + val (clientToClose, resourcesToClose) = synchronized(lifecycleLock) { + val currentClient = if (::client.isInitialized) client else null + val currentResources = if (::resources.isInitialized) resources else null + currentClient to currentResources + } + if (clientToClose == null || !shutdownStarted.compareAndSet(false, true)) { + return + } + val closing = ArrayList>() + if (::pubSubConnection.isInitialized) { + runCatching { closing += pubSubConnection.closeAsync() } + } + if (::asyncPool.isInitialized) { + runCatching { closing += asyncPool.closeAsync() } + } + if (::pool.isInitialized) { + runCatching { pool.close() } + } + val connectionsClosed = if (closing.isEmpty()) { + CompletableFuture.completedFuture(null) + } else { + CompletableFuture.allOf(*closing.toTypedArray()) + } + connectionsClosed.handle { _, _ -> null }.thenCompose { + clientToClose.shutdownAsync() + }.whenComplete { _, _ -> + runCatching { resourcesToClose?.shutdown() } + } } override fun useCommands(block: (RedisClusterCommands) -> T): T? { diff --git a/module/database/database-lettuce-redis/src/main/kotlin/taboolib/expansion/LettuceRedis.kt b/module/database/database-lettuce-redis/src/main/kotlin/taboolib/expansion/LettuceRedis.kt index dcb3768a7..12a5a4c39 100644 --- a/module/database/database-lettuce-redis/src/main/kotlin/taboolib/expansion/LettuceRedis.kt +++ b/module/database/database-lettuce-redis/src/main/kotlin/taboolib/expansion/LettuceRedis.kt @@ -5,6 +5,75 @@ import taboolib.common.LifeCycle import taboolib.common.env.RuntimeDependencies import taboolib.common.env.RuntimeDependency import taboolib.common.platform.Awake +import java.util.concurrent.ConcurrentHashMap +import java.util.concurrent.atomic.AtomicBoolean +import java.util.concurrent.atomic.AtomicInteger + +internal fun interface LettuceRedisResource { + + fun stop() +} + +internal class AsyncStartupCoordinator( + stageCount: Int, + private val onSuccess: () -> Unit, + private val onFailure: (Throwable) -> Unit, + private val onSettled: () -> Unit, +) { + + private val remaining = AtomicInteger(stageCount) + private val failed = AtomicBoolean(false) + + init { + require(stageCount > 0) { "stageCount must be positive" } + } + + fun complete(error: Throwable?) { + if (error != null && failed.compareAndSet(false, true)) { + onFailure(error) + } + onSettled() + if (remaining.decrementAndGet() == 0 && !failed.get()) { + onSuccess() + } + } +} + +internal class LettuceRedisResourceRegistry { + + private val closed = AtomicBoolean(false) + private val resources = ConcurrentHashMap.newKeySet() + + fun register(resource: LettuceRedisResource) { + if (closed.get()) { + runCatching { resource.stop() } + return + } + resources += resource + if (closed.get() && resources.remove(resource)) { + runCatching { resource.stop() } + } + } + + fun unregister(resource: LettuceRedisResource) { + resources.remove(resource) + } + + fun closeAll() { + if (!closed.compareAndSet(false, true)) { + return + } + resources.toList().forEach { resource -> + if (resources.remove(resource)) { + runCatching { resource.stop() } + } + } + } + + internal fun size(): Int { + return resources.size + } +} @Inject @RuntimeDependencies( @@ -107,16 +176,18 @@ import taboolib.common.platform.Awake ) object LettuceRedis { - internal val clients = mutableListOf() - internal val clusterClients = mutableListOf() + private val resources = LettuceRedisResourceRegistry() + + internal fun register(resource: LettuceRedisResource) { + resources.register(resource) + } + + internal fun unregister(resource: LettuceRedisResource) { + resources.unregister(resource) + } @Awake(LifeCycle.DISABLE) internal fun stop() { - clients.forEach { - it.stop() - } - clusterClients.forEach { - it.stop() - } + resources.closeAll() } } \ No newline at end of file diff --git a/module/database/database-lettuce-redis/src/main/kotlin/taboolib/expansion/LettuceRedisClient.kt b/module/database/database-lettuce-redis/src/main/kotlin/taboolib/expansion/LettuceRedisClient.kt index 0cad9bd92..01c066e59 100644 --- a/module/database/database-lettuce-redis/src/main/kotlin/taboolib/expansion/LettuceRedisClient.kt +++ b/module/database/database-lettuce-redis/src/main/kotlin/taboolib/expansion/LettuceRedisClient.kt @@ -25,165 +25,352 @@ import taboolib.expansion.lettuce.IRedisChannel import taboolib.expansion.lettuce.IRedisClient import taboolib.expansion.lettuce.IRedisCommand import taboolib.expansion.lettuce.IRedisPubSub +import java.util.concurrent.CancellationException import java.util.concurrent.CompletableFuture +import java.util.concurrent.atomic.AtomicBoolean +import java.util.concurrent.atomic.AtomicReference @Suppress("DuplicatedCode") -class LettuceRedisClient(val redisConfig: LettuceRedisConfig): IRedisClient, IRedisChannel, IRedisCommand, IRedisPubSub { +class LettuceRedisClient(val redisConfig: LettuceRedisConfig): IRedisClient, IRedisChannel, IRedisCommand, IRedisPubSub, LettuceRedisResource { + @Volatile lateinit var client: RedisClient + @Volatile lateinit var pool: GenericObjectPool> + + @Volatile lateinit var asyncPool: BoundedAsyncPool> + @Volatile lateinit var masterReplicaPool: GenericObjectPool> + + @Volatile lateinit var masterAsyncReplicaPool: BoundedAsyncPool> + @Volatile lateinit var pubSubConnection: StatefulRedisPubSubConnection + + @Volatile lateinit var resources: DefaultClientResources var enabledSlaves = false + private val startup = AtomicReference?>() + private val stopped = AtomicBoolean(false) + private val shutdownStarted = AtomicBoolean(false) + private val lifecycleLock = Any() + override fun start(autoRelease: Boolean): CompletableFuture { val completableFuture = CompletableFuture() - val resource = DefaultClientResources.builder() - - if (redisConfig.ioThreadPoolSize != 0) { - resource.ioThreadPoolSize(redisConfig.ioThreadPoolSize) + if (!startup.compareAndSet(null, completableFuture)) { + val existingStartup = startup.get()!! + if (stopped.get() && !existingStartup.isCompletedExceptionally && !existingStartup.isCancelled) { + return CompletableFuture().also { + it.completeExceptionally(CancellationException("Redis client is stopped")) + } + } + return existingStartup } - if (redisConfig.computationThreadPoolSize != 0) { - resource.computationThreadPoolSize(redisConfig.computationThreadPoolSize) + if (stopped.get()) { + completableFuture.completeExceptionally(CancellationException("Redis client is stopped")) + return completableFuture } + try { + val resource = DefaultClientResources.builder() + if (redisConfig.ioThreadPoolSize != 0) { + resource.ioThreadPoolSize(redisConfig.ioThreadPoolSize) + } + if (redisConfig.computationThreadPoolSize != 0) { + resource.computationThreadPoolSize(redisConfig.computationThreadPoolSize) + } - val clientOptions = ClientOptions.builder() - .autoReconnect(redisConfig.autoReconnect) - .pingBeforeActivateConnection(redisConfig.pingBeforeActivateConnection) - - if (redisConfig.ssl) { - clientOptions.sslOptions(redisConfig.sslOptions) - } - val uri = redisConfig.redisURIBuilder().build() + val clientOptions = ClientOptions.builder() + .autoReconnect(redisConfig.autoReconnect) + .pingBeforeActivateConnection(redisConfig.pingBeforeActivateConnection) + if (redisConfig.ssl) { + clientOptions.sslOptions(redisConfig.sslOptions) + } + val uri = redisConfig.redisURIBuilder().build() - resources = resource.build() - client = RedisClient.create(resources, uri).apply { - options = clientOptions.build() - } - // 连接 pub/sub 通道 - pubSubConnection = client.connectPubSub() - - if (redisConfig.enableSlaves) { - enabledSlaves = true - val slaves = redisConfig.slaves - - // 连接同步 - masterReplicaPool = ConnectionPoolSupport.createGenericObjectPool( - { MasterReplica.connect(client, StringCodec.UTF8, uri).apply { - readFrom = slaves.readFrom - } }, - redisConfig.pool.slavesPoolConfig() - ) - // 连接异步 - AsyncConnectionPoolSupport.createBoundedObjectPoolAsync( - { MasterReplica.connectAsync(client, StringCodec.UTF8, uri).whenComplete { v, _ -> - v.readFrom = slaves.readFrom - } }, - redisConfig.asyncPool.poolConfig() - ).thenAccept { - masterAsyncReplicaPool = it - completableFuture.complete(null) + val newResources = resource.build() + val newClient = try { + RedisClient.create(newResources, uri).apply { + options = clientOptions.build() + } + } catch (ex: Throwable) { + newResources.shutdown() + throw ex } - } else { - // 连接同步 - pool = ConnectionPoolSupport.createGenericObjectPool( - { client.connect() }, - redisConfig.pool.poolConfig() - ) - // 连接异步 - AsyncConnectionPoolSupport.createBoundedObjectPoolAsync( - { client.connectAsync(StringCodec.UTF8, uri) }, - redisConfig.asyncPool.poolConfig() - ).thenAccept { - asyncPool = it - completableFuture.complete(null) + synchronized(lifecycleLock) { + resources = newResources + client = newClient } - } - if (autoRelease) { - LettuceRedis.clients += this + if (stopped.get()) { + closeResources() + return completableFuture + } + // 异步连接 pub/sub 通道,避免 start() 阻塞调用线程 + val pubSubReady = client.connectPubSubAsync(StringCodec.UTF8, uri).thenAccept { + pubSubConnection = it + if (stopped.get()) { + it.closeAsync() + } + }.toCompletableFuture() + + if (redisConfig.enableSlaves) { + enabledSlaves = true + val slaves = redisConfig.slaves + // 连接同步 + masterReplicaPool = ConnectionPoolSupport.createGenericObjectPool( + { + MasterReplica.connect(client, StringCodec.UTF8, uri).apply { + readFrom = slaves.readFrom + } + }, + redisConfig.pool.slavesPoolConfig() + ) + if (stopped.get()) { + masterReplicaPool.close() + } + // 连接异步 + val poolReady = AsyncConnectionPoolSupport.createBoundedObjectPoolAsync( + { + MasterReplica.connectAsync(client, StringCodec.UTF8, uri).whenComplete { value, _ -> + value.readFrom = slaves.readFrom + } + }, + redisConfig.asyncPool.poolConfig() + ).thenAccept { + masterAsyncReplicaPool = it + if (stopped.get()) { + it.closeAsync() + } + }.toCompletableFuture() + coordinateStart(completableFuture, autoRelease, pubSubReady, poolReady) + } else { + // 连接同步 + pool = ConnectionPoolSupport.createGenericObjectPool( + { client.connect() }, + redisConfig.pool.poolConfig() + ) + if (stopped.get()) { + pool.close() + } + // 连接异步 + val poolReady = AsyncConnectionPoolSupport.createBoundedObjectPoolAsync( + { client.connectAsync(StringCodec.UTF8, uri) }, + redisConfig.asyncPool.poolConfig() + ).thenAccept { + asyncPool = it + if (stopped.get()) { + it.closeAsync() + } + }.toCompletableFuture() + coordinateStart(completableFuture, autoRelease, pubSubReady, poolReady) + } + } catch (ex: Throwable) { + failStart(completableFuture, ex) } return completableFuture } override fun startSync(autoRelease: Boolean) { - val resource = DefaultClientResources.builder() - - if (redisConfig.ioThreadPoolSize != 0) { - resource.ioThreadPoolSize(redisConfig.ioThreadPoolSize) + val completableFuture = CompletableFuture() + if (!startup.compareAndSet(null, completableFuture)) { + val existingStartup = startup.get()!! + check(!stopped.get() && existingStartup.isDone && !existingStartup.isCompletedExceptionally && !existingStartup.isCancelled) { + "Redis client is already starting or failed to start" + } + return } - if (redisConfig.computationThreadPoolSize != 0) { - resource.computationThreadPoolSize(redisConfig.computationThreadPoolSize) + if (stopped.get()) { + val error = IllegalStateException("Redis client is stopped") + completableFuture.completeExceptionally(error) + throw error } + try { + val resource = DefaultClientResources.builder() + if (redisConfig.ioThreadPoolSize != 0) { + resource.ioThreadPoolSize(redisConfig.ioThreadPoolSize) + } + if (redisConfig.computationThreadPoolSize != 0) { + resource.computationThreadPoolSize(redisConfig.computationThreadPoolSize) + } + + val clientOptions = ClientOptions.builder() + .autoReconnect(redisConfig.autoReconnect) + .pingBeforeActivateConnection(redisConfig.pingBeforeActivateConnection) + if (redisConfig.ssl) { + clientOptions.sslOptions(redisConfig.sslOptions) + } + val uri = redisConfig.redisURIBuilder().build() + + val newResources = resource.build() + val newClient = try { + RedisClient.create(newResources, uri).apply { + options = clientOptions.build() + } + } catch (ex: Throwable) { + newResources.shutdown() + throw ex + } + synchronized(lifecycleLock) { + resources = newResources + client = newClient + } + if (stopped.get()) { + closeResources() + error("Redis client was stopped during startup") + } + // 连接 pub/sub 通道 + pubSubConnection = client.connectPubSub() + if (stopped.get()) { + pubSubConnection.closeAsync() + error("Redis client was stopped during startup") + } - val clientOptions = ClientOptions.builder() - .autoReconnect(redisConfig.autoReconnect) - .pingBeforeActivateConnection(redisConfig.pingBeforeActivateConnection) + if (redisConfig.enableSlaves) { + enabledSlaves = true + val slaves = redisConfig.slaves + // 连接同步 + masterReplicaPool = ConnectionPoolSupport.createGenericObjectPool( + { + MasterReplica.connect(client, StringCodec.UTF8, uri).apply { + readFrom = slaves.readFrom + } + }, + redisConfig.pool.slavesPoolConfig() + ) + if (stopped.get()) { + masterReplicaPool.close() + error("Redis client was stopped during startup") + } + // 连接异步(同步方式创建) + masterAsyncReplicaPool = AsyncConnectionPoolSupport.createBoundedObjectPool( + { + MasterReplica.connectAsync(client, StringCodec.UTF8, uri).whenComplete { value, _ -> + value.readFrom = slaves.readFrom + } + }, + redisConfig.asyncPool.poolConfig() + ) + if (stopped.get()) { + masterAsyncReplicaPool.closeAsync() + error("Redis client was stopped during startup") + } + } else { + // 连接同步 + pool = ConnectionPoolSupport.createGenericObjectPool( + { client.connect() }, + redisConfig.pool.poolConfig() + ) + if (stopped.get()) { + pool.close() + error("Redis client was stopped during startup") + } + // 连接异步(同步方式创建) + asyncPool = AsyncConnectionPoolSupport.createBoundedObjectPool( + { client.connectAsync(StringCodec.UTF8, uri) }, + redisConfig.asyncPool.poolConfig() + ) + if (stopped.get()) { + asyncPool.closeAsync() + error("Redis client was stopped during startup") + } + } + completeStart(completableFuture, autoRelease) + } catch (ex: Throwable) { + failStart(completableFuture, ex) + throw ex + } + } - if (redisConfig.ssl) { - clientOptions.sslOptions(redisConfig.sslOptions) + override fun stop() { + if (!stopped.compareAndSet(false, true)) { + return } - val uri = redisConfig.redisURIBuilder().build() + LettuceRedis.unregister(this) + startup.get()?.completeExceptionally(CancellationException("Redis client is stopped")) + closeResources() + } - resources = resource.build() - client = RedisClient.create(resources, uri).apply { - options = clientOptions.build() + private fun coordinateStart( + completableFuture: CompletableFuture, + autoRelease: Boolean, + vararg stages: CompletableFuture<*> + ) { + val coordinator = AsyncStartupCoordinator( + stages.size, + onSuccess = { completeStart(completableFuture, autoRelease) }, + onFailure = { failStart(completableFuture, it) }, + onSettled = { if (stopped.get()) closeResources() }, + ) + stages.forEach { stage -> + stage.whenComplete { _, error -> coordinator.complete(error) } } - // 连接 pub/sub 通道 - pubSubConnection = client.connectPubSub() - - if (redisConfig.enableSlaves) { - enabledSlaves = true - val slaves = redisConfig.slaves - - // 连接同步 - masterReplicaPool = ConnectionPoolSupport.createGenericObjectPool( - { MasterReplica.connect(client, StringCodec.UTF8, uri).apply { - readFrom = slaves.readFrom - } }, - redisConfig.pool.slavesPoolConfig() - ) - // 连接异步(同步方式创建) - masterAsyncReplicaPool = AsyncConnectionPoolSupport.createBoundedObjectPool( - { MasterReplica.connectAsync(client, StringCodec.UTF8, uri).whenComplete { v, _ -> - v.readFrom = slaves.readFrom - } }, - redisConfig.asyncPool.poolConfig() - ) - } else { - // 连接同步 - pool = ConnectionPoolSupport.createGenericObjectPool( - { client.connect() }, - redisConfig.pool.poolConfig() - ) - // 连接异步(同步方式创建) - asyncPool = AsyncConnectionPoolSupport.createBoundedObjectPool( - { client.connectAsync(StringCodec.UTF8, uri) }, - redisConfig.asyncPool.poolConfig() - ) + } + + private fun completeStart(completableFuture: CompletableFuture, autoRelease: Boolean) { + if (stopped.get()) { + completableFuture.completeExceptionally(CancellationException("Redis client is stopped")) + closeResources() + return } if (autoRelease) { - LettuceRedis.clients += this + LettuceRedis.register(this) } + if (stopped.get()) { + LettuceRedis.unregister(this) + completableFuture.completeExceptionally(CancellationException("Redis client is stopped")) + closeResources() + return + } + completableFuture.complete(null) } - override fun stop() { - pubSubConnection.close() - if (enabledSlaves) { - masterAsyncReplicaPool.close() - masterReplicaPool.close() + private fun failStart(completableFuture: CompletableFuture, error: Throwable) { + stopped.set(true) + LettuceRedis.unregister(this) + completableFuture.completeExceptionally(error) + closeResources() + } + + private fun closeResources() { + val (clientToClose, resourcesToClose) = synchronized(lifecycleLock) { + val currentClient = if (::client.isInitialized) client else null + val currentResources = if (::resources.isInitialized) resources else null + currentClient to currentResources + } + if (clientToClose == null || !shutdownStarted.compareAndSet(false, true)) { + return + } + val closing = ArrayList>() + if (::pubSubConnection.isInitialized) { + runCatching { closing += pubSubConnection.closeAsync() } + } + if (::masterAsyncReplicaPool.isInitialized) { + runCatching { closing += masterAsyncReplicaPool.closeAsync() } + } + if (::asyncPool.isInitialized) { + runCatching { closing += asyncPool.closeAsync() } + } + if (::masterReplicaPool.isInitialized) { + runCatching { masterReplicaPool.close() } + } + if (::pool.isInitialized) { + runCatching { pool.close() } + } + val connectionsClosed = if (closing.isEmpty()) { + CompletableFuture.completedFuture(null) } else { - asyncPool.close() - pool.close() + CompletableFuture.allOf(*closing.toTypedArray()) + } + connectionsClosed.handle { _, _ -> null }.thenCompose { + clientToClose.shutdownAsync() + }.whenComplete { _, _ -> + runCatching { resourcesToClose?.shutdown() } } - client.shutdown() - resources.shutdown() } override fun useCommands(block: (RedisCommands) -> T): T? { diff --git a/module/database/database-lettuce-redis/src/test/kotlin/taboolib/expansion/LettuceRedisResourceRegistryTest.kt b/module/database/database-lettuce-redis/src/test/kotlin/taboolib/expansion/LettuceRedisResourceRegistryTest.kt new file mode 100644 index 000000000..1ec753468 --- /dev/null +++ b/module/database/database-lettuce-redis/src/test/kotlin/taboolib/expansion/LettuceRedisResourceRegistryTest.kt @@ -0,0 +1,137 @@ +package taboolib.expansion + +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertThrows +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test +import taboolib.library.configuration.ConfigurationSection +import java.lang.reflect.Proxy +import java.util.concurrent.atomic.AtomicInteger + +class LettuceRedisResourceRegistryTest { + + @Test + fun `registered clients stop exactly once`() { + val registry = LettuceRedisResourceRegistry() + val stopCount = AtomicInteger() + val resource = LettuceRedisResource { stopCount.incrementAndGet() } + + registry.register(resource) + registry.register(resource) + registry.closeAll() + registry.closeAll() + + assertEquals(1, stopCount.get()) + assertEquals(0, registry.size()) + } + + @Test + fun `unregistered client remains caller managed`() { + val registry = LettuceRedisResourceRegistry() + val stopCount = AtomicInteger() + val resource = LettuceRedisResource { stopCount.incrementAndGet() } + + registry.register(resource) + registry.unregister(resource) + registry.closeAll() + + assertEquals(0, stopCount.get()) + } + + @Test + fun `client registered after shutdown stops immediately`() { + val registry = LettuceRedisResourceRegistry() + val stopCount = AtomicInteger() + + registry.closeAll() + registry.register(LettuceRedisResource { stopCount.incrementAndGet() }) + + assertEquals(1, stopCount.get()) + assertEquals(0, registry.size()) + } + + @Test + fun `startup coordinator reports the first failure immediately`() { + val successCount = AtomicInteger() + val failureCount = AtomicInteger() + val settledCount = AtomicInteger() + val coordinator = AsyncStartupCoordinator( + stageCount = 2, + onSuccess = { successCount.incrementAndGet() }, + onFailure = { failureCount.incrementAndGet() }, + onSettled = { settledCount.incrementAndGet() }, + ) + + coordinator.complete(IllegalStateException("failed")) + + assertEquals(0, successCount.get()) + assertEquals(1, failureCount.get()) + assertEquals(1, settledCount.get()) + + coordinator.complete(null) + + assertEquals(0, successCount.get()) + assertEquals(1, failureCount.get()) + assertEquals(2, settledCount.get()) + } + + @Test + fun `startup coordinator succeeds after every stage settles`() { + val successCount = AtomicInteger() + val failureCount = AtomicInteger() + val coordinator = AsyncStartupCoordinator( + stageCount = 2, + onSuccess = { successCount.incrementAndGet() }, + onFailure = { failureCount.incrementAndGet() }, + onSettled = {}, + ) + + coordinator.complete(null) + assertEquals(0, successCount.get()) + + coordinator.complete(null) + assertEquals(1, successCount.get()) + assertEquals(0, failureCount.get()) + } + + @Test + fun `synchronous start after stop throws`() { + val client = LettuceRedisClient(testConfig()) + client.stop() + + assertThrows(IllegalStateException::class.java) { + client.startSync() + } + } + + @Test + fun `asynchronous start after stop returns failed future`() { + val client = LettuceRedisClient(testConfig()) + client.stop() + + assertTrue(client.start().isCompletedExceptionally) + } + + private fun testConfig(): LettuceRedisConfig { + val configuration = Proxy.newProxyInstance( + ConfigurationSection::class.java.classLoader, + arrayOf(ConfigurationSection::class.java), + ) { _, method, args -> + when (method.name) { + "getString" -> when (args?.getOrNull(0)) { + "host" -> "127.0.0.1" + "timeout" -> "1s" + else -> args?.getOrNull(1) + } + "getInt" -> args?.getOrNull(1) ?: 0 + "getBoolean" -> args?.getOrNull(1) ?: false + "getConfigurationSection" -> null + "getKeys" -> emptySet() + "getStringList", "getEnumList" -> emptyList() + "contains" -> false + else -> null + } + } as ConfigurationSection + return LettuceRedisConfig(configuration) + } +} diff --git a/module/database/database-player-redis/src/main/kotlin/taboolib/expansion/RedisDatabaseHandler.kt b/module/database/database-player-redis/src/main/kotlin/taboolib/expansion/RedisDatabaseHandler.kt index 1fdbb0925..bd66e7148 100644 --- a/module/database/database-player-redis/src/main/kotlin/taboolib/expansion/RedisDatabaseHandler.kt +++ b/module/database/database-player-redis/src/main/kotlin/taboolib/expansion/RedisDatabaseHandler.kt @@ -5,6 +5,7 @@ import taboolib.common.platform.function.getDataFolder import taboolib.common.platform.function.pluginId import taboolib.library.configuration.ConfigurationSection import java.util.concurrent.ConcurrentHashMap +import java.util.concurrent.atomic.AtomicBoolean /** * 创建 Redis 数据管理器 @@ -32,12 +33,14 @@ class RedisDatabaseHandler( clearFlags: Boolean = false, ssl: String? = null, dataFile: String = "data.db", -) { +) : AutoCloseable { val database: Database private var connector: SingleRedisConnector? = null var connection: SingleRedisConnection? = null + private val closed = AtomicBoolean(false) + /** * 玩家Redis数据容器。 * @@ -48,17 +51,24 @@ class RedisDatabaseHandler( val redisDataContainer = ConcurrentHashMap() init { - table = conf.getConfigurationSection("Database")!!.getString("table", pluginId)!! - database = if (conf.getBoolean("enable")) { - buildPlayerDatabase(conf, table, flags, clearFlags, ssl) + val databaseConfig = conf.getConfigurationSection("Database")!! + table = databaseConfig.getString("table", table.ifEmpty { pluginId })!! + database = if (databaseConfig.getBoolean("enable")) { + buildPlayerDatabase(databaseConfig, table, flags, clearFlags, ssl) } else { buildPlayerDatabase(newFile(getDataFolder(), dataFile), table) } - val redis = conf.getConfigurationSection("Redis")!! - if (redis.getBoolean("enable")) { - connector = AlkaidRedis.create().fromConfig(redis) - connection?.close() - connection = connector!!.connect().connection() + try { + val redis = conf.getConfigurationSection("Redis")!! + if (redis.getBoolean("enable")) { + val newConnector = AlkaidRedis.create().fromConfig(redis) + connector = newConnector + connection = newConnector.connect().connection() + } + } catch (ex: Throwable) { + connector?.close() + database.close() + throw ex } } @@ -84,4 +94,37 @@ class RedisDatabaseHandler( redisDataContainer.remove(user) } + /** + * 释放 Redis 连接、连接器以及当前处理器拥有的数据库连接池。 + */ + override fun close() { + if (!closed.compareAndSet(false, true)) { + return + } + redisDataContainer.clear() + val currentConnection = connection + val currentConnector = connector + connection = null + connector = null + + var failure: Throwable? = null + fun closeResource(resource: AutoCloseable?) { + try { + resource?.close() + } catch (ex: Throwable) { + val firstFailure = failure + if (firstFailure == null) { + failure = ex + } else { + firstFailure.addSuppressed(ex) + } + } + } + + closeResource(currentConnection) + closeResource(currentConnector) + closeResource(database) + failure?.let { throw it } + } + } diff --git a/module/database/database-player/build.gradle.kts b/module/database/database-player/build.gradle.kts index 360bd131d..b76633d1d 100644 --- a/module/database/database-player/build.gradle.kts +++ b/module/database/database-player/build.gradle.kts @@ -5,4 +5,12 @@ dependencies { compileOnly(project(":module:database")) compileOnly(project(":module:basic:basic-configuration")) compileOnly("ink.ptms.core:v11701:11701-minimize:universal") + + testImplementation(project(":common")) + testImplementation(project(":common-util")) + testImplementation(project(":common-platform-api")) + testImplementation(project(":module:database")) + testImplementation(project(":module:basic:basic-configuration")) + testImplementation("com.zaxxer:HikariCP:4.0.3") + testImplementation("org.xerial:sqlite-jdbc:3.42.0.0") } \ No newline at end of file diff --git a/module/database/database-player/src/main/kotlin/taboolib/expansion/Database.kt b/module/database/database-player/src/main/kotlin/taboolib/expansion/Database.kt index 369f29f36..35b812949 100644 --- a/module/database/database-player/src/main/kotlin/taboolib/expansion/Database.kt +++ b/module/database/database-player/src/main/kotlin/taboolib/expansion/Database.kt @@ -1,12 +1,25 @@ package taboolib.expansion +import java.util.IdentityHashMap import java.util.concurrent.ConcurrentHashMap +import java.util.concurrent.atomic.AtomicBoolean import javax.sql.DataSource -class Database(val type: Type, val dataSource: DataSource = type.host().createDataSource()) { +class Database(val type: Type, val dataSource: DataSource = createOwnedDataSource(type)) : AutoCloseable { + + val ownsDataSource = takeOwnership(dataSource) + + private val closed = AtomicBoolean(false) + + constructor(type: Type, dataSource: DataSource, ownsDataSource: Boolean) : this(type, markOwnership(dataSource, ownsDataSource)) init { - type.tableVar().createTable(dataSource) + try { + type.tableVar().createTable(dataSource) + } catch (ex: Throwable) { + close() + throw ex + } } /** @@ -113,4 +126,49 @@ class Database(val type: Type, val dataSource: DataSource = type.host().createDa where("user" eq user and ("key" eq key)) } } + + /** + * 关闭由当前实例创建的数据源。 + * + * 外部传入的数据源默认由调用方管理,可通过三参数构造函数显式转移所有权。 + */ + override fun close() { + if (!closed.compareAndSet(false, true) || !ownsDataSource) { + return + } + (dataSource as? AutoCloseable)?.close() + } + + companion object { + + private val ownedDataSources = ThreadLocal.withInitial { IdentityHashMap() } + + private fun createOwnedDataSource(type: Type): DataSource { + return type.host().createDataSource().also { + ownedDataSources.get()[it] = Unit + } + } + + private fun markOwnership(dataSource: DataSource, ownsDataSource: Boolean): DataSource { + val ownership = ownedDataSources.get() + if (ownsDataSource) { + ownership[dataSource] = Unit + } else { + ownership.remove(dataSource) + } + if (ownership.isEmpty()) { + ownedDataSources.remove() + } + return dataSource + } + + private fun takeOwnership(dataSource: DataSource): Boolean { + val ownership = ownedDataSources.get() + val ownsDataSource = ownership.remove(dataSource) != null + if (ownership.isEmpty()) { + ownedDataSources.remove() + } + return ownsDataSource + } + } } \ No newline at end of file diff --git a/module/database/database-player/src/test/kotlin/com/zaxxer/hikari_4_0_3/HikariConfig.kt b/module/database/database-player/src/test/kotlin/com/zaxxer/hikari_4_0_3/HikariConfig.kt new file mode 100644 index 000000000..134ac11c6 --- /dev/null +++ b/module/database/database-player/src/test/kotlin/com/zaxxer/hikari_4_0_3/HikariConfig.kt @@ -0,0 +1,7 @@ +package com.zaxxer.hikari_4_0_3 + +/** + * 测试环境使用 database 模块 shadow 产物,字节码会引用重定位后的 HikariConfig。 + * 生产环境由 TabooLib 运行时依赖提供,这里只在 test classpath 代理到原始 Hikari。 + */ +class HikariConfig : com.zaxxer.hikari.HikariConfig() diff --git a/module/database/database-player/src/test/kotlin/com/zaxxer/hikari_4_0_3/HikariDataSource.kt b/module/database/database-player/src/test/kotlin/com/zaxxer/hikari_4_0_3/HikariDataSource.kt new file mode 100644 index 000000000..b6ab0ba52 --- /dev/null +++ b/module/database/database-player/src/test/kotlin/com/zaxxer/hikari_4_0_3/HikariDataSource.kt @@ -0,0 +1,7 @@ +package com.zaxxer.hikari_4_0_3 + +/** + * 测试环境使用 database 模块 shadow 产物,字节码会引用重定位后的 HikariDataSource。 + * 生产环境由 TabooLib 运行时依赖提供,这里只在 test classpath 代理到原始 Hikari。 + */ +class HikariDataSource(config: HikariConfig) : com.zaxxer.hikari.HikariDataSource(config) diff --git a/module/database/database-player/src/test/kotlin/taboolib/expansion/DatabaseLifecycleTest.kt b/module/database/database-player/src/test/kotlin/taboolib/expansion/DatabaseLifecycleTest.kt new file mode 100644 index 000000000..442cbfb28 --- /dev/null +++ b/module/database/database-player/src/test/kotlin/taboolib/expansion/DatabaseLifecycleTest.kt @@ -0,0 +1,75 @@ +package taboolib.expansion + +import org.junit.jupiter.api.Assertions.assertFalse +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.io.TempDir +import taboolib.module.configuration.Configuration +import java.lang.reflect.Proxy +import java.nio.file.Path +import javax.sql.DataSource + +class DatabaseLifecycleTest { + + @TempDir + lateinit var tempDir: Path + + @BeforeEach + fun setupDatabaseSettings() { + taboolib.module.database.Database.settingsFile = Proxy.newProxyInstance( + Configuration::class.java.classLoader, + arrayOf(Configuration::class.java), + ) { _, method, args -> + when (method.name) { + "contains" -> false + "getBoolean", "getInt", "getLong", "getString" -> args?.getOrNull(1) + "getConfigurationSection", "getFile" -> null + "getReloadGeneration" -> 0 + "saveToString" -> "" + else -> null + } + } as Configuration + } + + @Test + fun `default data source is owned and closed idempotently`() { + val database = Database(TypeSQLite(tempDir.resolve("owned.db").toFile(), "owned_data")) + val dataSource = database.dataSource + + assertTrue(database.ownsDataSource) + database.close() + database.close() + + assertTrue(dataSource.isClosed()) + } + + @Test + fun `injected data source remains caller owned by default`() { + val type = TypeSQLite(tempDir.resolve("borrowed.db").toFile(), "borrowed_data") + val dataSource = type.host().createDataSource(autoRelease = false) + val database = Database(type, dataSource) + + assertFalse(database.ownsDataSource) + database.close() + + assertFalse(dataSource.isClosed()) + (dataSource as AutoCloseable).close() + } + + @Test + fun `injected data source can transfer ownership explicitly`() { + val type = TypeSQLite(tempDir.resolve("transferred.db").toFile(), "transferred_data") + val dataSource = type.host().createDataSource(autoRelease = false) + val database = Database(type, dataSource, ownsDataSource = true) + + assertTrue(database.ownsDataSource) + database.close() + + assertTrue(dataSource.isClosed()) + } + + private fun DataSource.isClosed(): Boolean { + return javaClass.getMethod("isClosed").invoke(this) as Boolean + } +} From 71858391d3d43c1192cead87d57fa66c4db20302 Mon Sep 17 00:00:00 2001 From: zhibei <785740487@qq.com> Date: Mon, 13 Jul 2026 15:13:29 +0800 Subject: [PATCH 15/37] =?UTF-8?q?fix(database):=20=E4=BF=AE=E5=A4=8D?= =?UTF-8?q?=E7=8E=A9=E5=AE=B6=E6=95=B0=E6=8D=AE=E5=B9=B6=E5=8F=91=E5=86=99?= =?UTF-8?q?=E5=85=A5=E4=B8=80=E8=87=B4=E6=80=A7?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 通过复合唯一索引、兼容迁移和原子 upsert 阻止重复记录,并收敛缓存延迟写与调度竞态以确保最新值最终落库。 --- .../database/database-player/build.gradle.kts | 1 + .../taboolib/expansion/DataContainer.kt | 171 +++++++++- .../kotlin/taboolib/expansion/Database.kt | 283 +++++++++++++++- .../main/kotlin/taboolib/expansion/TypeSQL.kt | 8 +- .../kotlin/taboolib/expansion/TypeSQLite.kt | 13 +- .../PlayerDatabaseConsistencyTest.kt | 317 ++++++++++++++++++ 6 files changed, 753 insertions(+), 40 deletions(-) create mode 100644 module/database/database-player/src/test/kotlin/taboolib/expansion/PlayerDatabaseConsistencyTest.kt diff --git a/module/database/database-player/build.gradle.kts b/module/database/database-player/build.gradle.kts index b76633d1d..6d6df4b69 100644 --- a/module/database/database-player/build.gradle.kts +++ b/module/database/database-player/build.gradle.kts @@ -12,5 +12,6 @@ dependencies { testImplementation(project(":module:database")) testImplementation(project(":module:basic:basic-configuration")) testImplementation("com.zaxxer:HikariCP:4.0.3") + testImplementation("org.junit.jupiter:junit-jupiter:5.10.2") testImplementation("org.xerial:sqlite-jdbc:3.42.0.0") } \ No newline at end of file diff --git a/module/database/database-player/src/main/kotlin/taboolib/expansion/DataContainer.kt b/module/database/database-player/src/main/kotlin/taboolib/expansion/DataContainer.kt index 1f6420dc3..b4fa62560 100644 --- a/module/database/database-player/src/main/kotlin/taboolib/expansion/DataContainer.kt +++ b/module/database/database-player/src/main/kotlin/taboolib/expansion/DataContainer.kt @@ -23,6 +23,12 @@ class DataContainer(val user: String, val database: Database) { /** 存储需要更新的键值对及其更新时间 */ val updateMap = ConcurrentHashMap() + private val writeStates = ConcurrentHashMap() + + internal var asyncExecutor: ((() -> Unit) -> Unit) = { task -> + submitAsync { task() } + } + /** * 设置指定键的值并立即保存 * @@ -30,13 +36,8 @@ class DataContainer(val user: String, val database: Database) { * @param value 值 */ operator fun set(key: String, value: Any) { - source[key] = value.toString() - if (value.toString().isEmpty()) { - source.remove(key) - delete(key) - } else { - save(key) - } + val stringValue = value.toString() + updateValue(key, stringValue.takeUnless { it.isEmpty() }, deadline = null, updateSource = true) } /** @@ -48,11 +49,11 @@ class DataContainer(val user: String, val database: Database) { * @param sync 是否同步给内存,要求targetUser为UUID */ fun forcedSet(targetUser: String, key: String, value: Any, sync: Boolean = false) { - database[targetUser, key] = value.toString() - // 因为 targetUser 不一定是UUID + val stringValue = value.toString() + database[targetUser, key] = stringValue if (sync) { - UUID.fromString(targetUser)?.let { - playerDataContainer[it]?.source?.set(key, value.toString()) + runCatching { UUID.fromString(targetUser) }.getOrNull()?.let { uniqueId -> + playerDataContainer[uniqueId]?.set(key, stringValue) } } } @@ -66,8 +67,9 @@ class DataContainer(val user: String, val database: Database) { * @param timeUnit 时间单位 */ fun setDelayed(key: String, value: Any, delay: Long = 3L, timeUnit: TimeUnit = TimeUnit.SECONDS) { - source[key] = value.toString() - updateMap[key] = System.currentTimeMillis() - timeUnit.toMillis(delay) + val stringValue = value.toString() + val deadline = deadlineAfter(timeUnit.toMillis(delay)) + updateValue(key, stringValue.takeUnless { it.isEmpty() }, deadline, updateSource = true) } /** @@ -113,23 +115,136 @@ class DataContainer(val user: String, val database: Database) { * @param key 键 */ fun save(key: String) { - submitAsync { database[user, key] = source[key]!! } + val state = writeStates.computeIfAbsent(key) { WriteState() } + synchronized(state) { + state.revision++ + state.value = source[key] + state.deadline = null + state.ready = true + updateMap.remove(key) + if (state.startIfNeeded()) { + scheduleWrite(key, state) + } + } } /** * 从数据库执行删除指定的键操作 */ fun delete(key: String) { - submitAsync { database.remove(user, key) } + updateValue(key, value = null, deadline = null, updateSource = false) } /** * 检查并更新需要保存的键值对 */ fun checkUpdate() { - updateMap.filterValues { it < System.currentTimeMillis() }.forEach { (t, _) -> - updateMap.remove(t) - save(t) + val currentTime = System.currentTimeMillis() + writeStates.forEach { (key, state) -> + synchronized(state) { + val deadline = state.deadline + if (deadline != null && deadline <= currentTime) { + state.deadline = null + state.ready = true + updateMap.remove(key, deadline) + if (state.startIfNeeded()) { + scheduleWrite(key, state) + } + } + } + } + } + + private fun updateValue(key: String, value: String?, deadline: Long?, updateSource: Boolean) { + val state = writeStates.computeIfAbsent(key) { WriteState() } + synchronized(state) { + if (updateSource) { + if (value == null) { + source.remove(key) + } else { + source[key] = value + } + } + state.revision++ + state.value = value + state.deadline = deadline + state.ready = deadline == null + if (deadline == null) { + updateMap.remove(key) + } else { + updateMap[key] = deadline + } + if (state.startIfNeeded()) { + scheduleWrite(key, state) + } + } + } + + private fun scheduleWrite(key: String, state: WriteState) { + try { + asyncExecutor.invoke { + drainWrites(key, state) + } + } catch (ex: Throwable) { + synchronized(state) { + state.running = false + } + throw ex + } + } + + private fun drainWrites(key: String, state: WriteState) { + while (true) { + val snapshot = synchronized(state) { + if (!state.ready) { + state.running = false + return + } + WriteSnapshot(state.revision, state.value) + } + try { + if (snapshot.value == null) { + database.remove(user, key) + } else { + database[user, key] = snapshot.value + } + } catch (ex: Throwable) { + synchronized(state) { + val hasNewerValue = state.revision != snapshot.revision && state.ready + state.running = false + if (hasNewerValue) { + state.running = true + runCatching { scheduleWrite(key, state) }.exceptionOrNull()?.let(ex::addSuppressed) + } + } + throw ex + } + val shouldContinue = synchronized(state) { + when { + state.revision == snapshot.revision -> { + state.ready = false + state.running = false + false + } + state.ready -> true + else -> { + state.running = false + false + } + } + } + if (!shouldContinue) { + return + } + } + } + + private fun deadlineAfter(delayMillis: Long): Long { + val currentTime = System.currentTimeMillis() + return if (delayMillis > 0 && currentTime > Long.MAX_VALUE - delayMillis) { + Long.MAX_VALUE + } else { + currentTime + delayMillis } } @@ -142,6 +257,26 @@ class DataContainer(val user: String, val database: Database) { return "DataContainer(user='$user', source=$source)" } + private class WriteState { + + var revision = 0L + var value: String? = null + var deadline: Long? = null + var ready = false + var running = false + + fun startIfNeeded(): Boolean { + return if (ready && !running) { + running = true + true + } else { + false + } + } + } + + private data class WriteSnapshot(val revision: Long, val value: String?) + /** * 内部伴生对象,用于定期检查更新 */ diff --git a/module/database/database-player/src/main/kotlin/taboolib/expansion/Database.kt b/module/database/database-player/src/main/kotlin/taboolib/expansion/Database.kt index 35b812949..8c5d6885d 100644 --- a/module/database/database-player/src/main/kotlin/taboolib/expansion/Database.kt +++ b/module/database/database-player/src/main/kotlin/taboolib/expansion/Database.kt @@ -1,6 +1,14 @@ package taboolib.expansion +import taboolib.common.PrimitiveIO +import taboolib.module.database.asFormattedColumnName +import taboolib.module.database.setupQuoterForHost +import java.sql.Connection +import java.sql.SQLException +import java.sql.SQLIntegrityConstraintViolationException import java.util.IdentityHashMap +import java.util.Locale +import java.util.TreeMap import java.util.concurrent.ConcurrentHashMap import java.util.concurrent.atomic.AtomicBoolean import javax.sql.DataSource @@ -13,9 +21,14 @@ class Database(val type: Type, val dataSource: DataSource = createOwnedDataSourc constructor(type: Type, dataSource: DataSource, ownsDataSource: Boolean) : this(type, markOwnership(dataSource, ownsDataSource)) + private val table = type.tableVar() + private val uniqueIndexName = createUniqueIndexName(table.name) + private val migrationLock = migrationLocks.computeIfAbsent("${type.host().connectionUrl}|${table.name}") { Any() } + init { try { - type.tableVar().createTable(dataSource) + table.createTable(dataSource) + ensureUniqueKeyIndex() } catch (ex: Throwable) { close() throw ex @@ -26,7 +39,7 @@ class Database(val type: Type, val dataSource: DataSource = createOwnedDataSourc * 根据用户获取用户所有的数据 */ operator fun get(user: String): MutableMap { - return type.tableVar().select(dataSource) { + return table.select(dataSource) { rows("key", "value") where("user" eq user) }.map { @@ -38,7 +51,7 @@ class Database(val type: Type, val dataSource: DataSource = createOwnedDataSourc * 根据用户和键获取数据 */ operator fun get(user: String, key: String): String? { - return type.tableVar().select(dataSource) { + return table.select(dataSource) { rows("value") where("user" eq user and ("key" eq key)) limit(1) @@ -56,15 +69,15 @@ class Database(val type: Type, val dataSource: DataSource = createOwnedDataSourc remove(user, key) return } - if (get(user, key) == null) { - type.tableVar().insert(dataSource, "user", "key", "value") { + when (type) { + is TypeSQL -> table.insert(dataSource, "user", "key", "value") { value(user, key, data) + onDuplicateKeyUpdate { + update("value", data) + } } - } else { - type.tableVar().update(dataSource) { - set("value", data) - where("user" eq user and ("key" eq key)) - } + is TypeSQLite -> upsertSQLite(user, key, data) + else -> upsertGeneric(user, key, data) } } @@ -73,7 +86,7 @@ class Database(val type: Type, val dataSource: DataSource = createOwnedDataSourc * 如果数据不存在则返回 null */ fun getValue(user: String, key: String): String? { - return type.tableVar().select(dataSource) { + return table.select(dataSource) { rows("key", "value") where("user" eq user and ("key" eq key)) }.firstOrNull { @@ -85,7 +98,7 @@ class Database(val type: Type, val dataSource: DataSource = createOwnedDataSourc * 返回所有满足 Key = Value 的用户 */ fun getUserList(key: String, value: String): List { - return type.tableVar().select(dataSource) { + return table.select(dataSource) { rows("user") where("key" eq key and ("value" eq value)) }.map { @@ -97,7 +110,7 @@ class Database(val type: Type, val dataSource: DataSource = createOwnedDataSourc * 根据 Key 来返回一个 的Map */ fun getListByKey(key: String): MutableMap { - return type.tableVar().select(dataSource) { + return table.select(dataSource) { rows("user", "value") where("key" eq key) }.map { @@ -110,7 +123,7 @@ class Database(val type: Type, val dataSource: DataSource = createOwnedDataSourc * 例如 key = "title-" 则会查询所有以 "title-" 开头的数据 */ fun getLikeKeyList(user: String, key: String): MutableMap { - return type.tableVar().select(dataSource) { + return table.select(dataSource) { rows("key", "value") where("user" eq user and ("key" like "${key}%")) }.map { @@ -122,11 +135,246 @@ class Database(val type: Type, val dataSource: DataSource = createOwnedDataSourc * 删除符合条件的数据 */ fun remove(user: String, key: String) { - type.tableVar().delete(dataSource) { + table.delete(dataSource) { + where("user" eq user and ("key" eq key)) + } + } + + private fun upsertSQLite(user: String, key: String, data: String) { + setupQuoterForHost(type.host()) + val tableName = table.name.asFormattedColumnName() + val userColumn = "user".asFormattedColumnName() + val keyColumn = "key".asFormattedColumnName() + val valueColumn = "value".asFormattedColumnName() + val query = "INSERT OR REPLACE INTO $tableName ($userColumn, $keyColumn, $valueColumn) VALUES (?, ?, ?)" + dataSource.connection.use { connection -> + connection.prepareStatement(query).use { statement -> + statement.setString(1, user) + statement.setString(2, key) + statement.setString(3, data) + statement.executeUpdate() + } + } + } + + private fun upsertGeneric(user: String, key: String, data: String) { + if (updateValue(user, key, data) > 0) { + return + } + try { + table.insert(dataSource, "user", "key", "value") { + value(user, key, data) + } + } catch (ex: SQLException) { + if (!ex.isConstraintViolation() || updateValue(user, key, data) == 0) { + throw ex + } + } + } + + private fun updateValue(user: String, key: String, data: String): Int { + return table.update(dataSource) { + set("value", data) where("user" eq user and ("key" eq key)) } } + private fun ensureUniqueKeyIndex() { + synchronized(migrationLock) { + dataSource.connection.use { connection -> + if (findUniqueKeyIndex(connection) != null) { + return + } + if (connection.metaData.databaseProductName.orEmpty().contains("SQLite", ignoreCase = true)) { + migrateSQLite(connection) + } else { + migrateWithRetry(connection) + } + } + } + } + + private fun migrateSQLite(connection: Connection) { + val removedRows = inTransaction(connection) { + val removed = removeDuplicateRows(connection) + createUniqueIndex(connection, resolveUniqueIndexName(connection)) + removed + } + if (findUniqueKeyIndex(connection) == null) { + throw SQLException("Unable to create a unique player key index for table ${table.name}") + } + warnDuplicateRows(removedRows) + } + + private fun migrateWithRetry(connection: Connection) { + var removedRows = 0L + var lastFailure: SQLException? = null + repeat(MAX_INDEX_ATTEMPTS) { attempt -> + if (findUniqueKeyIndex(connection) != null) { + warnDuplicateRows(removedRows) + return + } + removedRows += inTransaction(connection) { + removeDuplicateRows(connection) + } + try { + createUniqueIndex(connection, resolveUniqueIndexName(connection)) + } catch (ex: SQLException) { + if (findUniqueKeyIndex(connection) != null) { + warnDuplicateRows(removedRows) + return + } + lastFailure = ex + if (attempt + 1 >= MAX_INDEX_ATTEMPTS || countDuplicateRows(connection) == 0L) { + throw ex + } + return@repeat + } + if (findUniqueKeyIndex(connection) != null) { + warnDuplicateRows(removedRows) + return + } + } + throw lastFailure ?: SQLException("Unable to create a unique player key index for table ${table.name}") + } + + private fun createUniqueIndex(connection: Connection, indexName: String) { + setupQuoterForHost(type.host()) + val tableName = table.name.asFormattedColumnName() + val formattedIndexName = indexName.asFormattedColumnName() + val userColumn = "user".asFormattedColumnName() + val keyColumn = "key".asFormattedColumnName() + val ifNotExists = if (connection.metaData.databaseProductName.orEmpty().contains("SQLite", ignoreCase = true)) " IF NOT EXISTS" else "" + val query = "CREATE UNIQUE INDEX$ifNotExists $formattedIndexName ON $tableName ($userColumn, $keyColumn)" + connection.prepareStatement(query).use { statement -> + statement.executeUpdate() + } + } + + private fun findUniqueKeyIndex(connection: Connection): String? { + return readIndices(connection).firstOrNull { index -> + !index.nonUnique && index.columns.values.map { it.lowercase(Locale.ROOT) } == UNIQUE_KEY_COLUMNS + }?.name + } + + private fun resolveUniqueIndexName(connection: Connection): String { + val existingNames = readIndices(connection).map { it.name.lowercase(Locale.ROOT) }.toHashSet() + if (uniqueIndexName.lowercase(Locale.ROOT) !in existingNames) { + return uniqueIndexName + } + for (suffix in 2..99) { + val candidate = "${uniqueIndexName}_$suffix" + if (candidate.lowercase(Locale.ROOT) !in existingNames) { + return candidate + } + } + throw SQLException("Unable to allocate a unique index name for table ${table.name}") + } + + private fun readIndices(connection: Connection): List { + val indices = LinkedHashMap() + val tableNames = linkedSetOf(table.name, table.name.substringAfterLast('.')) + tableNames.forEach { tableName -> + connection.metaData.getIndexInfo(connection.catalog, null, tableName, false, false).use { result -> + while (result.next()) { + val indexName = result.getString("INDEX_NAME") ?: continue + val columnName = result.getString("COLUMN_NAME") ?: continue + val index = indices.computeIfAbsent(indexName.lowercase(Locale.ROOT)) { + IndexMetadata(indexName, result.getBoolean("NON_UNIQUE")) + } + index.nonUnique = index.nonUnique || result.getBoolean("NON_UNIQUE") + index.columns[result.getShort("ORDINAL_POSITION").toInt()] = columnName + } + } + } + return indices.values.toList() + } + + private fun removeDuplicateRows(connection: Connection): Long { + val duplicateRows = countDuplicateRows(connection) + if (duplicateRows == 0L) { + return 0L + } + connection.prepareStatement(createDuplicateDeleteQuery(connection)).use { statement -> + statement.executeUpdate() + } + val remainingRows = countDuplicateRows(connection) + if (remainingRows > 0) { + throw SQLException("Unable to remove duplicate player database rows from table ${table.name}") + } + return duplicateRows + } + + private fun countDuplicateRows(connection: Connection): Long { + setupQuoterForHost(type.host()) + val tableName = table.name.asFormattedColumnName() + val userColumn = "user".asFormattedColumnName() + val keyColumn = "key".asFormattedColumnName() + val query = "SELECT COALESCE(SUM(group_size - 1), 0) FROM (" + + "SELECT COUNT(*) AS group_size FROM $tableName GROUP BY $userColumn, $keyColumn HAVING COUNT(*) > 1" + + ") duplicate_groups" + return connection.prepareStatement(query).use { statement -> + statement.executeQuery().use { result -> + if (result.next()) result.getLong(1) else 0L + } + } + } + + private fun createDuplicateDeleteQuery(connection: Connection): String { + setupQuoterForHost(type.host()) + val tableName = table.name.asFormattedColumnName() + val userColumn = "user".asFormattedColumnName() + val keyColumn = "key".asFormattedColumnName() + val databaseName = connection.metaData.databaseProductName.orEmpty() + return if (databaseName.contains("SQLite", ignoreCase = true)) { + "DELETE FROM $tableName WHERE rowid NOT IN (" + + "SELECT MAX(rowid) FROM $tableName GROUP BY $userColumn, $keyColumn)" + } else { + val idColumn = "id".asFormattedColumnName() + "DELETE FROM $tableName WHERE $idColumn NOT IN (" + + "SELECT retained_id FROM (SELECT MAX($idColumn) AS retained_id FROM $tableName " + + "GROUP BY $userColumn, $keyColumn) retained_rows)" + } + } + + private fun inTransaction(connection: Connection, block: () -> T): T { + val originalAutoCommit = connection.autoCommit + connection.autoCommit = false + return try { + block().also { connection.commit() } + } catch (ex: Throwable) { + runCatching { connection.rollback() }.exceptionOrNull()?.let(ex::addSuppressed) + throw ex + } finally { + runCatching { connection.autoCommit = originalAutoCommit } + } + } + + private fun warnDuplicateRows(removedRows: Long) { + if (removedRows > 0) { + PrimitiveIO.warning( + "Removed {0} duplicate rows from player database table {1} before creating its unique key index.", + removedRows, + table.name, + ) + } + } + + private fun createUniqueIndexName(tableName: String): String { + val normalizedName = tableName.replace(Regex("[^A-Za-z0-9_]"), "_").ifEmpty { "table" } + return "uk_${normalizedName.take(36)}_${Integer.toHexString(tableName.hashCode())}_user_key" + } + + private fun SQLException.isConstraintViolation(): Boolean { + return this is SQLIntegrityConstraintViolationException || sqlState?.startsWith("23") == true || errorCode == 19 + } + + private data class IndexMetadata( + val name: String, + var nonUnique: Boolean, + val columns: TreeMap = TreeMap(), + ) + /** * 关闭由当前实例创建的数据源。 * @@ -141,6 +389,9 @@ class Database(val type: Type, val dataSource: DataSource = createOwnedDataSourc companion object { + private const val MAX_INDEX_ATTEMPTS = 4 + private val UNIQUE_KEY_COLUMNS = listOf("user", "key") + private val migrationLocks = ConcurrentHashMap() private val ownedDataSources = ThreadLocal.withInitial { IdentityHashMap() } private fun createOwnedDataSource(type: Type): DataSource { @@ -171,4 +422,4 @@ class Database(val type: Type, val dataSource: DataSource = createOwnedDataSourc return ownsDataSource } } -} \ No newline at end of file +} diff --git a/module/database/database-player/src/main/kotlin/taboolib/expansion/TypeSQL.kt b/module/database/database-player/src/main/kotlin/taboolib/expansion/TypeSQL.kt index 5bb994ab6..7a611de2d 100644 --- a/module/database/database-player/src/main/kotlin/taboolib/expansion/TypeSQL.kt +++ b/module/database/database-player/src/main/kotlin/taboolib/expansion/TypeSQL.kt @@ -17,16 +17,18 @@ class TypeSQL(val host: Host, val table: String) : Type() { add { id() } add("user") { type(ColumnTypeSQL.VARCHAR, 36) { - options(ColumnOptionSQL.KEY) + options(ColumnOptionSQL.NOTNULL, ColumnOptionSQL.KEY) } } add("key") { type(ColumnTypeSQL.VARCHAR, 64) { - options(ColumnOptionSQL.KEY) + options(ColumnOptionSQL.NOTNULL, ColumnOptionSQL.KEY) } } add("value") { - type(ColumnTypeSQL.VARCHAR, 128) + type(ColumnTypeSQL.VARCHAR, 128) { + options(ColumnOptionSQL.NOTNULL) + } } } diff --git a/module/database/database-player/src/main/kotlin/taboolib/expansion/TypeSQLite.kt b/module/database/database-player/src/main/kotlin/taboolib/expansion/TypeSQLite.kt index a2cf1eabe..e1c54c42e 100644 --- a/module/database/database-player/src/main/kotlin/taboolib/expansion/TypeSQLite.kt +++ b/module/database/database-player/src/main/kotlin/taboolib/expansion/TypeSQLite.kt @@ -2,6 +2,7 @@ package taboolib.expansion import taboolib.common.io.newFile import taboolib.common.platform.function.pluginId +import taboolib.module.database.ColumnOptionSQLite import taboolib.module.database.ColumnTypeSQLite import taboolib.module.database.Host import taboolib.module.database.Table @@ -26,13 +27,19 @@ class TypeSQLite(val file: File, val tableName: String? = null) : Type() { */ val tableVar = Table(tableName ?: pluginId, host) { add("user") { - type(ColumnTypeSQLite.TEXT, 64) + type(ColumnTypeSQLite.TEXT, 64) { + options(ColumnOptionSQLite.NOTNULL) + } } add("key") { - type(ColumnTypeSQLite.TEXT, 64) + type(ColumnTypeSQLite.TEXT, 64) { + options(ColumnOptionSQLite.NOTNULL) + } } add("value") { - type(ColumnTypeSQLite.TEXT) + type(ColumnTypeSQLite.TEXT) { + options(ColumnOptionSQLite.NOTNULL) + } } } diff --git a/module/database/database-player/src/test/kotlin/taboolib/expansion/PlayerDatabaseConsistencyTest.kt b/module/database/database-player/src/test/kotlin/taboolib/expansion/PlayerDatabaseConsistencyTest.kt new file mode 100644 index 000000000..ec04445b3 --- /dev/null +++ b/module/database/database-player/src/test/kotlin/taboolib/expansion/PlayerDatabaseConsistencyTest.kt @@ -0,0 +1,317 @@ +package taboolib.expansion + +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertNull +import org.junit.jupiter.api.Assertions.assertThrows +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.io.TempDir +import org.sqlite.SQLiteConfig +import org.sqlite.SQLiteDataSource +import java.nio.file.Path +import java.sql.SQLException +import java.util.ArrayDeque +import java.util.concurrent.CountDownLatch +import java.util.concurrent.Executors +import java.util.concurrent.RejectedExecutionException +import java.util.concurrent.TimeUnit +import java.util.concurrent.atomic.AtomicInteger + +class PlayerDatabaseConsistencyTest { + + @TempDir + lateinit var tempDir: Path + + @Test + fun `concurrent first writes keep one row`() { + val fixture = createFixture("concurrent") + val executor = Executors.newFixedThreadPool(8) + val start = CountDownLatch(1) + val values = (0 until 32).map { "value-$it" } + val futures = values.map { value -> + executor.submit { + start.await() + fixture.database["player", "score"] = value + } + } + + try { + start.countDown() + futures.forEach { it.get(30, TimeUnit.SECONDS) } + } finally { + executor.shutdownNow() + } + + assertEquals(1, countRows(fixture.dataSource, fixture.table, "player", "score")) + assertTrue(fixture.database["player", "score"] in values) + } + + @Test + fun `legacy duplicate rows keep latest value before unique index creation`() { + val table = "legacy_player_data" + val file = tempDir.resolve("legacy.db").toFile() + val dataSource = createDataSource(file.toPath()) + dataSource.connection.use { connection -> + connection.createStatement().use { statement -> + statement.executeUpdate("CREATE TABLE `$table` (`user` TEXT, `key` TEXT, `value` TEXT)") + statement.executeUpdate("INSERT INTO `$table` (`user`, `key`, `value`) VALUES ('player', 'score', 'old')") + statement.executeUpdate("INSERT INTO `$table` (`user`, `key`, `value`) VALUES ('player', 'score', 'new')") + statement.executeUpdate("INSERT INTO `$table` (`user`, `key`, `value`) VALUES ('other', 'score', 'kept')") + } + } + + val database = Database(TypeSQLite(file, table), dataSource) + + assertEquals("new", database["player", "score"]) + assertEquals(1, countRows(dataSource, table, "player", "score")) + assertEquals("kept", database["other", "score"]) + assertThrows(SQLException::class.java) { + dataSource.connection.use { connection -> + connection.prepareStatement("INSERT INTO `$table` (`user`, `key`, `value`) VALUES (?, ?, ?)").use { statement -> + statement.setString(1, "player") + statement.setString(2, "score") + statement.setString(3, "duplicate") + statement.executeUpdate() + } + } + } + } + + @Test + fun `concurrent initialization migrates duplicates once`() { + val table = "concurrent_migration" + val file = tempDir.resolve("concurrent-migration.db").toFile() + val setupDataSource = createDataSource(file.toPath()) + setupDataSource.connection.use { connection -> + connection.createStatement().use { statement -> + statement.executeUpdate("CREATE TABLE `$table` (`user` TEXT, `key` TEXT, `value` TEXT)") + statement.executeUpdate("INSERT INTO `$table` (`user`, `key`, `value`) VALUES ('player', 'score', 'old')") + statement.executeUpdate("INSERT INTO `$table` (`user`, `key`, `value`) VALUES ('player', 'score', 'new')") + } + } + val executor = Executors.newFixedThreadPool(2) + val start = CountDownLatch(1) + val futures = (0 until 2).map { + executor.submit { + start.await() + val dataSource = createDataSource(file.toPath()) + Database(TypeSQLite(file, table), dataSource) + } + } + + val databases = try { + start.countDown() + futures.map { it.get(30, TimeUnit.SECONDS) } + } finally { + executor.shutdownNow() + } + + assertEquals("new", databases.first()["player", "score"]) + assertEquals(1, countRows(setupDataSource, table, "player", "score")) + } + + @Test + fun `same named index on wrong columns does not bypass key constraint`() { + val table = "wrong_index" + val file = tempDir.resolve("wrong-index.db").toFile() + val dataSource = createDataSource(file.toPath()) + val expectedIndexName = uniqueIndexName(table) + dataSource.connection.use { connection -> + connection.createStatement().use { statement -> + statement.executeUpdate("CREATE TABLE `$table` (`user` TEXT, `key` TEXT, `value` TEXT)") + statement.executeUpdate("CREATE UNIQUE INDEX `$expectedIndexName` ON `$table` (`value`)") + } + } + + val database = Database(TypeSQLite(file, table), dataSource) + database["player", "score"] = "one" + database["player", "score"] = "two" + + assertEquals("two", database["player", "score"]) + assertEquals(1, countRows(dataSource, table, "player", "score")) + } + + @Test + fun `special table names are quoted for unique index creation`() { + val fixture = createFixture("special", "player-data") + + fixture.database["player", "score"] = "one" + fixture.database["player", "score"] = "two" + + assertEquals("two", fixture.database["player", "score"]) + assertEquals(1, countRows(fixture.dataSource, fixture.table, "player", "score")) + } + + @Test + fun `queued writes coalesce to latest value`() { + val fixture = createFixture("coalesced") + val container = DataContainer("player", fixture.database) + val tasks = ArrayDeque<() -> Unit>() + container.asyncExecutor = { tasks.addLast(it) } + + container["score"] = "one" + container["score"] = "two" + + assertEquals(1, tasks.size) + tasks.removeFirst().invoke() + assertEquals("two", fixture.database["player", "score"]) + } + + @Test + fun `scheduler rejection does not hide a concurrent newer write`() { + val fixture = createFixture("scheduler-rejection") + val container = DataContainer("player", fixture.database) + val tasks = ArrayDeque<() -> Unit>() + val schedulingStarted = CountDownLatch(1) + val releaseFirstScheduling = CountDownLatch(1) + val schedulingAttempts = AtomicInteger() + container.asyncExecutor = { task -> + if (schedulingAttempts.getAndIncrement() == 0) { + schedulingStarted.countDown() + releaseFirstScheduling.await() + throw RejectedExecutionException("first scheduling attempt rejected") + } + tasks.addLast(task) + } + val executor = Executors.newFixedThreadPool(2) + val secondStarted = CountDownLatch(1) + val first = executor.submit { + runCatching { container["state"] = "old" }.exceptionOrNull() + } + assertTrue(schedulingStarted.await(10, TimeUnit.SECONDS)) + val second = executor.submit { + secondStarted.countDown() + container["state"] = "new" + } + assertTrue(secondStarted.await(10, TimeUnit.SECONDS)) + releaseFirstScheduling.countDown() + + try { + assertTrue(first.get(10, TimeUnit.SECONDS) is RejectedExecutionException) + second.get(10, TimeUnit.SECONDS) + } finally { + executor.shutdownNow() + } + + assertEquals(1, tasks.size) + tasks.removeFirst().invoke() + assertEquals("new", fixture.database["player", "state"]) + } + + @Test + fun `delete supersedes queued save without null assertion failure`() { + val fixture = createFixture("delete") + val container = DataContainer("player", fixture.database) + val tasks = ArrayDeque<() -> Unit>() + container.asyncExecutor = { tasks.addLast(it) } + + container["state"] = "present" + container["state"] = "" + + assertEquals(1, tasks.size) + tasks.removeFirst().invoke() + assertNull(fixture.database["player", "state"]) + } + + @Test + fun `delayed value is not persisted before its deadline`() { + val fixture = createFixture("delayed") + val container = DataContainer("player", fixture.database) + val tasks = ArrayDeque<() -> Unit>() + container.asyncExecutor = { tasks.addLast(it) } + + container["state"] = "old" + container.setDelayed("state", "new", 1, TimeUnit.DAYS) + tasks.removeFirst().invoke() + container.checkUpdate() + + assertTrue(tasks.isEmpty()) + assertNull(fixture.database["player", "state"]) + assertEquals("new", container["state"]) + } + + @Test + fun `concurrent delayed writes retain the latest deadline state`() { + val fixture = createFixture("concurrent-delayed") + val container = DataContainer("player", fixture.database) + val tasks = ArrayDeque<() -> Unit>() + container.asyncExecutor = { tasks.addLast(it) } + val executor = Executors.newFixedThreadPool(8) + val start = CountDownLatch(1) + val values = (1..128).map { "value-$it" } + val futures = values.mapIndexed { index, value -> + executor.submit { + start.await() + container.setDelayed("state", value, -index.toLong(), TimeUnit.MILLISECONDS) + } + } + + try { + start.countDown() + futures.forEach { it.get(10, TimeUnit.SECONDS) } + } finally { + executor.shutdownNow() + } + + container.checkUpdate() + assertEquals(1, tasks.size) + tasks.removeFirst().invoke() + assertEquals(container["state"], fixture.database["player", "state"]) + } + + @Test + fun `elapsed delayed value schedules one write`() { + val fixture = createFixture("elapsed") + val container = DataContainer("player", fixture.database) + val tasks = ArrayDeque<() -> Unit>() + container.asyncExecutor = { tasks.addLast(it) } + + container.setDelayed("state", "ready", 0, TimeUnit.MILLISECONDS) + container.checkUpdate() + + assertEquals(1, tasks.size) + tasks.removeFirst().invoke() + assertEquals("ready", fixture.database["player", "state"]) + } + + private fun createFixture(name: String, table: String = "${name}_player_data"): Fixture { + val file = tempDir.resolve("$name.db").toFile() + val dataSource = createDataSource(file.toPath()) + return Fixture(Database(TypeSQLite(file, table), dataSource), dataSource, table) + } + + private fun uniqueIndexName(tableName: String): String { + val normalizedName = tableName.replace(Regex("[^A-Za-z0-9_]"), "_").ifEmpty { "table" } + return "uk_${normalizedName.take(36)}_${Integer.toHexString(tableName.hashCode())}_user_key" + } + + private fun createDataSource(path: Path): SQLiteDataSource { + val config = SQLiteConfig().apply { + setBusyTimeout(30_000) + setJournalMode(SQLiteConfig.JournalMode.WAL) + setSynchronous(SQLiteConfig.SynchronousMode.NORMAL) + } + return SQLiteDataSource(config).apply { + url = "jdbc:sqlite:${path.toAbsolutePath()}" + } + } + + private fun countRows(dataSource: SQLiteDataSource, table: String, user: String, key: String): Int { + return dataSource.connection.use { connection -> + connection.prepareStatement("SELECT COUNT(*) FROM `$table` WHERE `user` = ? AND `key` = ?").use { statement -> + statement.setString(1, user) + statement.setString(2, key) + statement.executeQuery().use { result -> + result.next() + result.getInt(1) + } + } + } + } + + private data class Fixture( + val database: Database, + val dataSource: SQLiteDataSource, + val table: String, + ) +} From ac061ebdcd09defb57e12d53bcd0fd632bc932e4 Mon Sep 17 00:00:00 2001 From: zhibei <785740487@qq.com> Date: Mon, 13 Jul 2026 22:48:54 +0800 Subject: [PATCH 16/37] =?UTF-8?q?fix:=20=E4=BF=AE=E5=A4=8D=E9=85=8D?= =?UTF-8?q?=E7=BD=AE=E3=80=81SQL=20=E4=B8=8E=20JEXL=20=E6=AD=A3=E7=A1=AE?= =?UTF-8?q?=E6=80=A7=E9=97=AE=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 保留声明集合语义并按数据库方言生成合法 SQL,同时确保 JEXL 配置变更后重建引擎。 --- .../core/conversion/ObjectConverter.java | 325 ++++++++++++------ .../kotlin/TestObjectConverterCollections.kt | 81 +++++ module/database/build.gradle.kts | 1 + .../taboolib/module/database/ActionInsert.kt | 108 +++++- .../module/database/ExecutableSource.kt | 14 +- .../database/ActionInsertDialectTest.kt | 150 ++++++++ module/script/script-jexl/build.gradle.kts | 3 +- .../kotlin/taboolib/expansion/JexlCompiler.kt | 57 +-- .../kotlin/taboolib/expansion/JexlHelper.kt | 22 +- .../taboolib/expansion/JexlCompilerTest.kt | 48 +++ 10 files changed, 645 insertions(+), 164 deletions(-) create mode 100644 module/basic/basic-configuration/src/test/kotlin/TestObjectConverterCollections.kt create mode 100644 module/database/src/test/kotlin/taboolib/module/database/ActionInsertDialectTest.kt create mode 100644 module/script/script-jexl/src/test/kotlin/taboolib/expansion/JexlCompilerTest.kt diff --git a/module/basic/basic-configuration/src/main/java/com/electronwill/nightconfig/core/conversion/ObjectConverter.java b/module/basic/basic-configuration/src/main/java/com/electronwill/nightconfig/core/conversion/ObjectConverter.java index d7de6c048..017239305 100644 --- a/module/basic/basic-configuration/src/main/java/com/electronwill/nightconfig/core/conversion/ObjectConverter.java +++ b/module/basic/basic-configuration/src/main/java/com/electronwill/nightconfig/core/conversion/ObjectConverter.java @@ -316,9 +316,16 @@ private void convertToObject(UnmodifiableConfig config, Object object, Class // --- Writes the value to the object's field, converting it if needed --- Class fieldType = field.getType(); try { - if (value instanceof UnmodifiableConfig && !(fieldType.isAssignableFrom(value.getClass()))) { + if ((value instanceof UnmodifiableConfig || value instanceof Map) && Map.class.isAssignableFrom(fieldType)) { + // --- Reads as a map while preserving the declared map and generic value types --- + Map converted = convertMap(value, field.getGenericType(), fieldType); + AnnotationUtils.checkField(field, converted); + field.set(object, converted); + } else if ((value instanceof UnmodifiableConfig || value instanceof Map) && !(fieldType.isAssignableFrom(value.getClass()))) { // --- Read as a sub-object --- - final UnmodifiableConfig cfg = (UnmodifiableConfig) value; + final UnmodifiableConfig cfg = value instanceof UnmodifiableConfig + ? (UnmodifiableConfig) value + : configFromMap((Map) value); // Gets or creates the field and convert it (if null OR not preserved) Object fieldValue = field.get(object); if (fieldValue == null) { @@ -329,35 +336,10 @@ private void convertToObject(UnmodifiableConfig config, Object object, Class convertToObject(cfg, fieldValue, field.getType()); } } else if (value instanceof Collection && Collection.class.isAssignableFrom(fieldType)) { - // --- Reads as a collection, maybe a list of objects with conversion --- - final Collection src = (Collection) value; - final Class srcBottomType = bottomElementType(src); - - final ParameterizedType genericType = (ParameterizedType) field.getGenericType(); - final List> dstTypes = elementTypes(genericType); - final Class dstBottomType = dstTypes.get(dstTypes.size() - 1); - - if (srcBottomType == null || dstBottomType == null || dstBottomType.isAssignableFrom(srcBottomType)) { - // Simple list, no conversion needed - AnnotationUtils.checkField(field, value); - field.set(object, value); - } else { - // List of objects => the bottom elements need conversion - // Uses the current field value if there is one, or create a new list - Collection dst = (Collection) field.get(object); - if (dst == null) { - if (fieldType == ArrayList.class || fieldType.isInterface() || Modifier.isAbstract(fieldType.getModifiers())) { - dst = new ArrayList<>(src.size());// allocates the right size - } else { - dst = (Collection) createInstance(fieldType); - } - field.set(object, dst); - } - // Converts the elements of the list - convertConfigsToObject(src, dst, dstTypes, 0); - // Applies the checks - AnnotationUtils.checkField(field, dst); - } + // --- Reads as a collection while preserving the declared collection and generic element types --- + Collection converted = convertCollection((Collection) value, field.getGenericType(), fieldType); + AnnotationUtils.checkField(field, converted); + field.set(object, converted); } else { // --- Read as a plain value --- if (value == null && AnnotationUtils.mustPreserve(field, clazz)) { @@ -382,61 +364,213 @@ private void convertToObject(UnmodifiableConfig config, Object object, Class } } - /** - * Gets the type of the "bottom element" of a list. - * For instance, for {@code LinkedList>>>} - * this method returns the class {@code Supplier}. - * - * @param genericType the generic list type - * @return the type of the elements of the most nested list - */ - private Class bottomElementType(ParameterizedType genericType) { - if (genericType != null && genericType.getActualTypeArguments().length > 0) { - Type parameter = genericType.getActualTypeArguments()[0]; - if (parameter instanceof ParameterizedType) { - ParameterizedType genericParameter = (ParameterizedType) parameter; - Class paramClass = (Class) genericParameter.getRawType(); - if (paramClass.isAssignableFrom(Collection.class)) { - return bottomElementType(genericParameter); - } else { - return paramClass; - } + private Collection convertCollection(Collection source, Type declaredType, Class declaredClass) { + Type elementType = collectionElementType(declaredType); + Collection destination = createCollection(declaredClass, elementType, source.size()); + for (Object element : source) { + destination.add(convertValue(element, elementType)); + } + return destination; + } + + private Object convertValue(Object value, Type declaredType) { + if (value == null) { + return null; + } + Class declaredClass = rawClass(declaredType); + if (declaredClass == Object.class) { + return value; + } + if (value instanceof Collection && Collection.class.isAssignableFrom(declaredClass)) { + return convertCollection((Collection) value, declaredType, declaredClass); + } + if ((value instanceof UnmodifiableConfig || value instanceof Map) && Map.class.isAssignableFrom(declaredClass)) { + return convertMap(value, declaredType, declaredClass); + } + if ((value instanceof UnmodifiableConfig || value instanceof Map) && isStructuredObjectType(declaredClass)) { + Object elementObject = createInstance(declaredClass); + UnmodifiableConfig elementConfig = value instanceof UnmodifiableConfig + ? (UnmodifiableConfig) value + : configFromMap((Map) value); + convertToObject(elementConfig, elementObject, declaredClass); + return elementObject; + } + Object unwrapped = ConfigSection.Companion.unwrap(value); + if (unwrapped == null || declaredClass.isAssignableFrom(unwrapped.getClass())) { + return unwrapped; + } + if (declaredClass.isEnum()) { + return EnumGetMethod.NAME_IGNORECASE.get(unwrapped, (Class) declaredClass); + } + if (unwrapped instanceof Number) { + Object number = convertNumber((Number) unwrapped, declaredClass); + if (number != null) { + return number; } - if ((parameter instanceof Class)) { - return (Class) parameter; + } + if (declaredClass == String.class && !(unwrapped instanceof Map) && !(unwrapped instanceof Collection)) { + return unwrapped.toString(); + } + throw new InvalidValueException("Unexpected element of type " + unwrapped.getClass() + " for " + declaredType); + } + + private boolean isStructuredObjectType(Class type) { + return type != String.class + && type != Boolean.class + && type != Character.class + && !Number.class.isAssignableFrom(type) + && !type.isEnum() + && !Collection.class.isAssignableFrom(type) + && !Map.class.isAssignableFrom(type); + } + + private Map convertMap(Object source, Type declaredType, Class declaredClass) { + Map sourceMap; + if (source instanceof UnmodifiableConfig) { + sourceMap = ((UnmodifiableConfig) source).valueMap(); + } else { + Object unwrapped = ConfigSection.Companion.unwrap(source); + if (!(unwrapped instanceof Map)) { + throw new InvalidValueException("Unexpected value of type " + source.getClass() + " for " + declaredType); } + sourceMap = (Map) unwrapped; } - return null; + Type keyType = Object.class; + Type valueType = Object.class; + Type resolvedType = boundedType(declaredType); + if (resolvedType instanceof ParameterizedType) { + Type[] typeArguments = ((ParameterizedType) resolvedType).getActualTypeArguments(); + if (typeArguments.length > 0) { + keyType = typeArguments[0]; + } + if (typeArguments.length > 1) { + valueType = typeArguments[1]; + } + } + Map destination = createMap(declaredClass); + for (Map.Entry entry : sourceMap.entrySet()) { + destination.put(convertValue(entry.getKey(), keyType), convertValue(entry.getValue(), valueType)); + } + return destination; } - private void detectElementTypes(ParameterizedType genericType, List> storage) { - if (genericType != null && genericType.getActualTypeArguments().length > 0) { - Type parameter = genericType.getActualTypeArguments()[0]; - if (parameter instanceof ParameterizedType) { - ParameterizedType genericParameter = (ParameterizedType) parameter; - Class paramClass = (Class) genericParameter.getRawType(); - storage.add(paramClass); - if (Collection.class.isAssignableFrom(paramClass)) { - detectElementTypes(genericParameter, storage); - } - } else if ((parameter instanceof Class)) { - storage.add((Class) parameter); + private UnmodifiableConfig configFromMap(Map source) { + Config config = Config.inMemory(); + for (Map.Entry entry : source.entrySet()) { + config.set(String.valueOf(entry.getKey()), entry.getValue()); + } + return config; + } + + private Collection createCollection(Class declaredClass, Type elementType, int size) { + if (!declaredClass.isInterface() && !Modifier.isAbstract(declaredClass.getModifiers())) { + return (Collection) createInstance((Class) declaredClass); + } + if (EnumSet.class.isAssignableFrom(declaredClass)) { + Class enumType = rawClass(elementType); + if (!enumType.isEnum()) { + throw new ReflectionException("Unable to determine enum type for " + declaredClass); + } + return (Collection) (Collection) EnumSet.noneOf((Class) enumType); + } + if ((NavigableSet.class.isAssignableFrom(declaredClass) || SortedSet.class.isAssignableFrom(declaredClass)) + && declaredClass.isAssignableFrom(TreeSet.class)) { + return new TreeSet<>(); + } + if (Set.class.isAssignableFrom(declaredClass) && declaredClass.isAssignableFrom(LinkedHashSet.class)) { + return new LinkedHashSet<>(Math.max(16, size)); + } + if ((Deque.class.isAssignableFrom(declaredClass) || Queue.class.isAssignableFrom(declaredClass)) + && declaredClass.isAssignableFrom(LinkedList.class)) { + return new LinkedList<>(); + } + if (Collection.class.isAssignableFrom(declaredClass) && declaredClass.isAssignableFrom(ArrayList.class)) { + return new ArrayList<>(size); + } + throw new ReflectionException("Unable to create compatible collection for " + declaredClass); + } + + private Map createMap(Class declaredClass) { + if (!declaredClass.isInterface() && !Modifier.isAbstract(declaredClass.getModifiers())) { + return (Map) createInstance((Class) declaredClass); + } + if ((NavigableMap.class.isAssignableFrom(declaredClass) || SortedMap.class.isAssignableFrom(declaredClass)) + && declaredClass.isAssignableFrom(TreeMap.class)) { + return new TreeMap<>(); + } + if (Map.class.isAssignableFrom(declaredClass) && declaredClass.isAssignableFrom(LinkedHashMap.class)) { + return new LinkedHashMap<>(); + } + throw new ReflectionException("Unable to create compatible map for " + declaredClass); + } + + private Type collectionElementType(Type declaredType) { + Type resolvedType = boundedType(declaredType); + if (resolvedType instanceof ParameterizedType) { + Type[] arguments = ((ParameterizedType) resolvedType).getActualTypeArguments(); + if (arguments.length > 0) { + return arguments[0]; } } + return Object.class; } - /** - * Returns a list of the generic parameters of a list. - * For instance, for {@code LinkedList>>>} - * this method returns a list containing {@code [Collection.class, Supplier.class]}. - * - * @param genericType the list generic type - * @return a list of the types of the list's elements - */ - private List> elementTypes(ParameterizedType genericType) { - List> storage = new ArrayList<>(); - detectElementTypes(genericType, storage); - return storage; + private Type boundedType(Type type) { + if (type instanceof WildcardType) { + WildcardType wildcardType = (WildcardType) type; + Type[] lowerBounds = wildcardType.getLowerBounds(); + if (lowerBounds.length > 0) { + return boundedType(lowerBounds[0]); + } + Type[] upperBounds = wildcardType.getUpperBounds(); + return upperBounds.length == 0 ? Object.class : boundedType(upperBounds[0]); + } + if (type instanceof TypeVariable) { + Type[] bounds = ((TypeVariable) type).getBounds(); + return bounds.length == 0 ? Object.class : boundedType(bounds[0]); + } + return type; + } + + private Class rawClass(Type type) { + if (type instanceof Class) { + return wrapPrimitive((Class) type); + } + if (type instanceof ParameterizedType) { + return rawClass(((ParameterizedType) type).getRawType()); + } + if (type instanceof WildcardType) { + Type[] upperBounds = ((WildcardType) type).getUpperBounds(); + return upperBounds.length == 0 ? Object.class : rawClass(upperBounds[0]); + } + if (type instanceof TypeVariable) { + Type[] bounds = ((TypeVariable) type).getBounds(); + return bounds.length == 0 ? Object.class : rawClass(bounds[0]); + } + return Object.class; + } + + private Class wrapPrimitive(Class type) { + if (!type.isPrimitive()) return type; + if (type == int.class) return Integer.class; + if (type == long.class) return Long.class; + if (type == double.class) return Double.class; + if (type == float.class) return Float.class; + if (type == short.class) return Short.class; + if (type == byte.class) return Byte.class; + if (type == boolean.class) return Boolean.class; + if (type == char.class) return Character.class; + return type; + } + + private Object convertNumber(Number value, Class targetType) { + if (targetType == Integer.class) return value.intValue(); + if (targetType == Long.class) return value.longValue(); + if (targetType == Double.class) return value.doubleValue(); + if (targetType == Float.class) return value.floatValue(); + if (targetType == Short.class) return value.shortValue(); + if (targetType == Byte.class) return value.byteValue(); + return null; } /** @@ -458,43 +592,6 @@ private Class bottomElementType(Collection list) { return null; } - /** - * Converts a collection of configurations to a collection of objects of the type dstBottomType. - * - * @param src the collection of configs, may be nested, source - * @param dst the collection of objects, destination - * @param dstElementTypes the type of lists and objects in dst - */ - private void convertConfigsToObject(Collection src, Collection dst, List> dstElementTypes, int currentLevel) { - final Class currentType = dstElementTypes.get(currentLevel); - for (Object elem : src) { - if (elem == null) { - dst.add(null); - } else if (elem instanceof Collection) { - final Collection subSrc = (Collection) elem; - final Collection subDst; - - if (currentType == ArrayList.class - || currentType.isInterface() - || Modifier.isAbstract(currentType.getModifiers())) { - - subDst = new ArrayList<>(); - } else { - subDst = (Collection) createInstance(currentType); - } - convertConfigsToObject(subSrc, subDst, dstElementTypes, currentLevel + 1); - dst.add(subDst); - } else if (elem instanceof UnmodifiableConfig) { - Object elementObj = createInstance(currentType); - convertToObject((UnmodifiableConfig) elem, elementObj, currentType); - dst.add(elementObj); - } else { - String elemType = elem.getClass().toString(); - throw new InvalidValueException("Unexpected element of type " + elemType + " in collection of objects"); - } - } - } - /** * Converts a collection of objects of the type srcBottomType to a collection of configurations. * diff --git a/module/basic/basic-configuration/src/test/kotlin/TestObjectConverterCollections.kt b/module/basic/basic-configuration/src/test/kotlin/TestObjectConverterCollections.kt new file mode 100644 index 000000000..d809c2d33 --- /dev/null +++ b/module/basic/basic-configuration/src/test/kotlin/TestObjectConverterCollections.kt @@ -0,0 +1,81 @@ +import com.electronwill.nightconfig.core.Config +import com.electronwill.nightconfig.core.conversion.ObjectConverter +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertInstanceOf +import org.junit.jupiter.api.Test +import java.util.EnumSet +import java.util.LinkedHashSet +import java.util.LinkedList +import java.util.Queue +import java.util.SortedMap +import java.util.SortedSet +import java.util.TreeMap +import java.util.TreeSet + +class TestObjectConverterCollections { + + @Test + fun `preserves declared collection types and converts nested elements`() { + val root = Config.inMemory() + root.set("names", listOf("alpha", "beta", "alpha")) + root.set("queue", listOf("first", "second")) + root.set("numbers", listOf(1L, 2L)) + root.set("sorted", listOf("beta", "alpha")) + root.set("modes", listOf("first", "SECOND")) + root.set("groups", listOf(listOf(itemConfig("one"), itemConfig("two")))) + + val indexed = Config.inMemory() + indexed.set("primary", itemConfig("indexed")) + root.set("indexed", indexed) + + val sortedIndex = Config.inMemory() + sortedIndex.set("second", 2) + sortedIndex.set("first", 1) + root.set("sortedIndex", sortedIndex) + + val result = CollectionHolder() + ObjectConverter().toObject(root, result) + + assertInstanceOf(LinkedHashSet::class.java, result.names) + assertEquals(linkedSetOf("alpha", "beta"), result.names) + assertInstanceOf(LinkedList::class.java, result.queue) + assertEquals(listOf("first", "second"), result.queue.toList()) + assertInstanceOf(LinkedList::class.java, result.numbers) + assertEquals(listOf(1, 2), result.numbers) + assertInstanceOf(TreeSet::class.java, result.sorted) + assertEquals(listOf("alpha", "beta"), result.sorted.toList()) + assertEquals(EnumSet.of(Mode.FIRST, Mode.SECOND), result.modes) + assertInstanceOf(LinkedHashSet::class.java, result.groups.single()) + assertEquals(listOf("one", "two"), result.groups.single().map { it.name }) + assertEquals("indexed", result.indexed.getValue("primary").name) + assertInstanceOf(TreeMap::class.java, result.sortedIndex) + assertEquals(listOf("first", "second"), result.sortedIndex.keys.toList()) + assertEquals(listOf(1L, 2L), result.sortedIndex.values.toList()) + } + + private fun itemConfig(name: String): Config { + return Config.inMemory().also { it.set("name", name) } + } + + class CollectionHolder { + + var names: Set = emptySet() + var queue: Queue = LinkedList() + var numbers: LinkedList = LinkedList() + var sorted: SortedSet = sortedSetOf() + var modes: EnumSet = EnumSet.noneOf(Mode::class.java) + var groups: List> = emptyList() + var indexed: Map = emptyMap() + var sortedIndex: SortedMap = sortedMapOf() + } + + class Item { + + var name: String = "" + } + + enum class Mode { + FIRST, + SECOND, + } +} diff --git a/module/database/build.gradle.kts b/module/database/build.gradle.kts index 5f7c7b7cb..fe73508dc 100644 --- a/module/database/build.gradle.kts +++ b/module/database/build.gradle.kts @@ -7,6 +7,7 @@ dependencies { compileOnly(project(":common-platform-api")) compileOnly(project(":common-util")) compileOnly(project(":module:basic:basic-configuration")) + testImplementation(project(":common-util")) testImplementation("com.zaxxer:HikariCP:4.0.3") testImplementation("org.xerial:sqlite-jdbc:3.42.0.0") } diff --git a/module/database/src/main/kotlin/taboolib/module/database/ActionInsert.kt b/module/database/src/main/kotlin/taboolib/module/database/ActionInsert.kt index 2ee77ab68..f4a41313f 100644 --- a/module/database/src/main/kotlin/taboolib/module/database/ActionInsert.kt +++ b/module/database/src/main/kotlin/taboolib/module/database/ActionInsert.kt @@ -20,21 +20,32 @@ class ActionInsert(val table: String, val keys: Array) : Action { /** 重复时更新 */ private var duplicateUpdate = ArrayList() + /** 重复键方言 */ + private var duplicateKeyDialect = DuplicateKeyDialect.MYSQL + + /** 冲突目标 */ + private var conflictKeys: Array? = null + /** 语句 */ override val query: String - get() = Statement("INSERT INTO") - .addSegment(table.asFormattedColumnName()) - .addSegmentIfTrue(keys.isNotEmpty()) { - addKeys(keys) - } - .addSegmentIfTrue(values.isNotEmpty()) { - addSegment("VALUES") - addValues(values) + get() { + require(table.isNotBlank()) { "Insert table must not be blank" } + require(keys.none { it.isBlank() }) { "Insert keys must not contain blank names" } + require(values.isNotEmpty()) { "Insert values must not be empty" } + if (keys.isNotEmpty()) { + require(values.all { it.size == keys.size }) { "Insert value count must match key count" } } - .addSegmentIfTrue(duplicateUpdate.isNotEmpty()) { - addSegment("ON DUPLICATE KEY UPDATE") - addOperations(duplicateUpdate) - }.build() + return Statement("INSERT INTO") + .addSegment(table.asFormattedColumnName()) + .addSegmentIfTrue(keys.isNotEmpty()) { + addKeys(keys) + } + .addSegment("VALUES") + .addValues(values) + .addSegmentIfTrue(duplicateUpdate.isNotEmpty()) { + addDuplicateUpdate() + }.build() + } /** 元素 */ override val elements: List @@ -60,9 +71,28 @@ class ActionInsert(val table: String, val keys: Array) : Action { values.add(args.toTypedArray()) } - /** 重复时更新 */ + /** + * 重复时更新。 + * PostgreSQL 无法从插入字段可靠推断唯一约束,需使用带冲突字段的重载。 + */ fun onDuplicateKeyUpdate(func: DuplicateUpdateBehavior.() -> Unit) { - duplicateUpdate = DuplicateUpdateBehavior().also(func).updateOperations + setupDuplicateUpdate(null, func) + } + + /** + * 重复时更新,并显式指定 PostgreSQL/SQLite 的冲突字段。 + * MySQL 会忽略冲突字段并继续使用 ON DUPLICATE KEY UPDATE。 + */ + fun onDuplicateKeyUpdate(conflictKeys: Collection, func: DuplicateUpdateBehavior.() -> Unit) { + setupDuplicateUpdate(conflictKeys.toTypedArray(), func) + } + + internal fun setupDialect(host: Host<*>) { + duplicateKeyDialect = when (host) { + is HostPostgreSQL -> DuplicateKeyDialect.POSTGRESQL + is HostSQLite -> DuplicateKeyDialect.SQLITE + else -> DuplicateKeyDialect.MYSQL + } } override fun onFinally(onFinally: PreparedStatement.(Connection) -> Unit) { @@ -73,11 +103,53 @@ class ActionInsert(val table: String, val keys: Array) : Action { this.finallyCallback?.invoke(preparedStatement, connection) } + private fun setupDuplicateUpdate(conflictKeys: Array?, func: DuplicateUpdateBehavior.() -> Unit) { + val behavior = DuplicateUpdateBehavior().also(func) + duplicateUpdate = behavior.updateOperations + this.conflictKeys = conflictKeys + } + + private fun Statement.addDuplicateUpdate() { + when (duplicateKeyDialect) { + DuplicateKeyDialect.MYSQL -> { + addSegment("ON DUPLICATE KEY UPDATE") + addOperations(duplicateUpdate) + } + DuplicateKeyDialect.POSTGRESQL -> { + val targetKeys = conflictKeys + require(!targetKeys.isNullOrEmpty()) { + "PostgreSQL duplicate update requires explicit conflict keys" + } + require(targetKeys.none { it.isBlank() }) { + "PostgreSQL conflict keys must not contain blank names" + } + addSegment("ON CONFLICT") + addKeys(targetKeys) + addSegment("DO UPDATE SET") + addOperations(duplicateUpdate) + } + DuplicateKeyDialect.SQLITE -> { + addSegment("ON CONFLICT") + conflictKeys?.also { targetKeys -> + require(targetKeys.none { it.isBlank() }) { + "SQLite conflict keys must not contain blank names" + } + if (targetKeys.isNotEmpty()) { + addKeys(targetKeys) + } + } + addSegment("DO UPDATE SET") + addOperations(duplicateUpdate) + } + } + } + class DuplicateUpdateBehavior { val updateOperations = ArrayList() fun update(key: String, value: Any) { + require(key.isNotBlank()) { "Duplicate update key must not be blank" } updateOperations += if (value is PreValue) { UpdateOperation("${key.asFormattedColumnName()} = ${value.asFormattedColumnName()}") } else { @@ -85,4 +157,10 @@ class ActionInsert(val table: String, val keys: Array) : Action { } } } -} \ No newline at end of file + + private enum class DuplicateKeyDialect { + MYSQL, + POSTGRESQL, + SQLITE, + } +} diff --git a/module/database/src/main/kotlin/taboolib/module/database/ExecutableSource.kt b/module/database/src/main/kotlin/taboolib/module/database/ExecutableSource.kt index c2ce58d2a..c0244caaf 100644 --- a/module/database/src/main/kotlin/taboolib/module/database/ExecutableSource.kt +++ b/module/database/src/main/kotlin/taboolib/module/database/ExecutableSource.kt @@ -89,14 +89,20 @@ open class ExecutableSource(val table: Table<*, *>, var dataSource: DataSource, /** 插入数据 */ open fun insert(vararg keys: String, func: ActionInsert.() -> Unit = {}): ResultProcessor { setupQuoter() - val action = ActionInsert(table.name, arrayOf(*keys)).also(func) + val action = ActionInsert(table.name, arrayOf(*keys)).also { + it.setupDialect(table.host) + func(it) + } return executeUpdate(action.query, action) } /** 插入数据 */ open fun insert(keys: List, func: ActionInsert.() -> Unit = {}): ResultProcessor { setupQuoter() - val action = ActionInsert(table.name, keys.toTypedArray()).also(func) + val action = ActionInsert(table.name, keys.toTypedArray()).also { + it.setupDialect(table.host) + func(it) + } return executeUpdate(action.query, action) } @@ -289,9 +295,9 @@ open class ExecutableSource(val table: Table<*, *>, var dataSource: DataSource, .addSegmentIfTrue(index.checkExists) { addSegment("IF NOT EXISTS") } - .addSegment(index.name) + .addSegment(index.name.asFormattedColumnName()) .addSegment("ON") - .addSegment(table.name) + .addSegment(table.name.asFormattedColumnName()) .addSegment("(") .addSegment(index.columns.joinToString(",", transform = { it.asFormattedColumnName() })) .addSegment(")") diff --git a/module/database/src/test/kotlin/taboolib/module/database/ActionInsertDialectTest.kt b/module/database/src/test/kotlin/taboolib/module/database/ActionInsertDialectTest.kt new file mode 100644 index 000000000..0d7469ada --- /dev/null +++ b/module/database/src/test/kotlin/taboolib/module/database/ActionInsertDialectTest.kt @@ -0,0 +1,150 @@ +package taboolib.module.database + +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.AfterEach +import org.junit.jupiter.api.Assertions.assertThrows +import org.junit.jupiter.api.Test +import org.sqlite.SQLiteDataSource +import java.io.File + +class ActionInsertDialectTest { + + @AfterEach + fun resetIdentifierQuoter() { + currentQuoter.remove() + } + + @Test + fun `keeps mysql duplicate key syntax`() { + val host = HostSQL("localhost", "3306", "root", "", "test") + val action = insertAction(host, "order") { + onDuplicateKeyUpdate { + update("value", 2) + } + } + + assertEquals( + "INSERT INTO `order` (`key`, `value`) VALUES (?, ?) ON DUPLICATE KEY UPDATE `value` = ?", + action.query + ) + assertEquals(listOf("entry", 1, 2), action.elements) + } + + @Test + fun `uses postgresql conflict syntax and double quoted identifiers`() { + val host = HostPostgreSQL("localhost", "5432", "postgres", "", "test") + val action = insertAction(host, "public.order") { + onDuplicateKeyUpdate(listOf("key")) { + update("value", 2) + } + } + + assertEquals( + "INSERT INTO \"public\".\"order\" (\"key\", \"value\") VALUES (?, ?) ON CONFLICT (\"key\") DO UPDATE SET \"value\" = ?", + action.query + ) + } + + @Test + fun `requires explicit postgresql conflict keys`() { + val host = HostPostgreSQL("localhost", "5432", "postgres", "", "test") + val action = insertAction(host, "order") { + onDuplicateKeyUpdate { + update("value", 2) + } + } + + assertThrows(IllegalArgumentException::class.java) { action.query } + } + + @Test + fun `uses sqlite conflict syntax without guessing a conflict target`() { + val action = insertAction(HostSQLite(File("database.db")), "order") { + onDuplicateKeyUpdate { + update("value", 2) + } + } + + assertEquals( + "INSERT INTO `order` (`key`, `value`) VALUES (?, ?) ON CONFLICT DO UPDATE SET `value` = ?", + action.query + ) + } + + @Test + fun `keeps positional insert compatibility when keys are omitted`() { + setupQuoterForHost(HostSQLite(File("database.db"))) + val action = ActionInsert("order", emptyArray()).also { it.value("entry", 1) } + + assertEquals("INSERT INTO `order` VALUES (?, ?)", action.query) + assertEquals(listOf("entry", 1), action.elements) + } + + @Test + fun `rejects incomplete insert statements`() { + setupQuoterForHost(HostSQLite(File("database.db"))) + + val noValues = ActionInsert("order", arrayOf("key")) + assertThrows(IllegalArgumentException::class.java) { noValues.query } + + val blankKeys = ActionInsert("order", arrayOf(" ")).also { it.value("entry") } + assertThrows(IllegalArgumentException::class.java) { blankKeys.query } + + val mismatchedValues = ActionInsert("order", arrayOf("key", "value")).also { it.value("entry") } + assertThrows(IllegalArgumentException::class.java) { mismatchedValues.query } + } + + @Test + fun `executes generated sqlite upsert`() { + val action = insertAction(HostSQLite(File("database.db")), "entries") { + onDuplicateKeyUpdate { + update("value", 2) + } + } + val dataSource = SQLiteDataSource().also { it.url = "jdbc:sqlite::memory:" } + + dataSource.connection.use { connection -> + connection.createStatement().use { statement -> + statement.execute("CREATE TABLE entries (`key` TEXT PRIMARY KEY, `value` INTEGER)") + } + repeat(2) { + connection.prepareStatement(action.query).use { statement -> + action.elements.forEachIndexed { index, value -> statement.setObject(index + 1, value) } + statement.executeUpdate() + } + } + connection.createStatement().use { statement -> + statement.executeQuery("SELECT value FROM entries WHERE `key` = 'entry'").use { result -> + result.next() + assertEquals(2, result.getInt(1)) + } + } + } + } + + @Test + fun `quotes index names tables and columns for postgresql`() { + val host = HostPostgreSQL("localhost", "5432", "postgres", "", "test") + val table = Table("public.order", host) + val source = ExecutableSource(table, SQLiteDataSource(), false) + setupQuoterForHost(host) + + val query = with(source) { + table.generateCreateIndexQuery(Index("select", listOf("from", "value"), unique = true, checkExists = false)) + } + + assertEquals( + "CREATE UNIQUE INDEX \"select\" ON \"public\".\"order\" ( \"from\",\"value\" )", + query + ) + } + + private fun insertAction(host: Host<*>, table: String, configure: ActionInsert.() -> Unit): ActionInsert { + setupQuoterForHost(host) + return ActionInsert(table, arrayOf("key", "value")).also { + it.setupDialect(host) + it.value("entry", 1) + configure(it) + } + } +} diff --git a/module/script/script-jexl/build.gradle.kts b/module/script/script-jexl/build.gradle.kts index e51e45fd5..2dab3e43d 100644 --- a/module/script/script-jexl/build.gradle.kts +++ b/module/script/script-jexl/build.gradle.kts @@ -6,10 +6,11 @@ dependencies { compileOnly(project(":common-env")) // 表达式 compileOnly("org.apache.commons:commons-jexl3:3.2.1") + testImplementation("org.apache.commons:commons-jexl3:3.2.1") } tasks { withType { relocate("org.apache.commons.jexl3", "org.apache.commons.jexl3_3_2_1") } -} \ No newline at end of file +} diff --git a/module/script/script-jexl/src/main/kotlin/taboolib/expansion/JexlCompiler.kt b/module/script/script-jexl/src/main/kotlin/taboolib/expansion/JexlCompiler.kt index 5ab5fdb92..7d8a9b3cd 100644 --- a/module/script/script-jexl/src/main/kotlin/taboolib/expansion/JexlCompiler.kt +++ b/module/script/script-jexl/src/main/kotlin/taboolib/expansion/JexlCompiler.kt @@ -3,7 +3,6 @@ package taboolib.expansion import org.apache.commons.jexl3.JexlBuilder import org.apache.commons.jexl3.JexlEngine import org.apache.commons.jexl3.MapContext -import taboolib.common.util.unsafeLazy /** * TabooLib @@ -20,7 +19,18 @@ class JexlCompiler { .cacheThreshold(64) // 设置合适的缓存阈值 .collectMode(0) // 如果不需要变量收集,关闭它 - internal val jexlEngine: JexlEngine by unsafeLazy { jexlBuilder.create() } + private val engineLock = Any() + + @Volatile + private var currentEngine: JexlEngine? = null + + internal val jexlEngine: JexlEngine + get() { + currentEngine?.let { return it } + return synchronized(engineLock) { + currentEngine ?: jexlBuilder.create().also { currentEngine = it } + } + } /** * 是否启用 Ant 风格模式 @@ -44,68 +54,57 @@ class JexlCompiler { * 在高频调用场景:影响会更明显 */ fun antish(flag: Boolean): JexlCompiler { - jexlBuilder.antish(flag) - return this + return configure { antish(flag) } } /** 设置严格模式 */ fun strict(flag: Boolean): JexlCompiler { - jexlBuilder.strict(flag) - return this + return configure { strict(flag) } } /** 设置静默模式 */ fun silent(flag: Boolean): JexlCompiler { - jexlBuilder.silent(flag) - return this + return configure { silent(flag) } } /** 设置安全模式 */ fun safe(flag: Boolean): JexlCompiler { - jexlBuilder.safe(flag) - return this + return configure { safe(flag) } } /** 设置调试模式 */ fun debug(flag: Boolean): JexlCompiler { - jexlBuilder.debug(flag) - return this + return configure { debug(flag) } } /** 设置缓存大小 */ fun cache(size: Int): JexlCompiler { - jexlBuilder.cache(size) - return this + return configure { cache(size) } } /** 设置收集模式 */ fun collectMode(mode: Int): JexlCompiler { - jexlBuilder.collectMode(mode) - return this + return configure { collectMode(mode) } } /** 设置是否收集所有变量 */ fun collectAll(flag: Boolean): JexlCompiler { - jexlBuilder.collectAll(flag) - return this + return configure { collectAll(flag) } } /** 设置缓存阈值 */ fun cacheThreshold(size: Int): JexlCompiler { - jexlBuilder.cacheThreshold(size) - return this + return configure { cacheThreshold(size) } } /** 设置堆栈大小 */ fun stackOverflow(size: Int): JexlCompiler { - jexlBuilder.stackOverflow(size) - return this + return configure { stackOverflow(size) } } /** 设置命名空间 */ fun namespace(namespace: Map): JexlCompiler { - jexlBuilder.namespaces(namespace) - return this + return configure { namespaces(namespace) } } /** 编译为脚本 */ @@ -130,8 +129,16 @@ class JexlCompiler { } } + private fun configure(configureBuilder: JexlBuilder.() -> Unit): JexlCompiler { + synchronized(engineLock) { + jexlBuilder.configureBuilder() + currentEngine = null + } + return this + } + companion object { fun new() = JexlCompiler() } -} \ No newline at end of file +} diff --git a/module/script/script-jexl/src/main/kotlin/taboolib/expansion/JexlHelper.kt b/module/script/script-jexl/src/main/kotlin/taboolib/expansion/JexlHelper.kt index 53e0bf0d4..d270fcdeb 100644 --- a/module/script/script-jexl/src/main/kotlin/taboolib/expansion/JexlHelper.kt +++ b/module/script/script-jexl/src/main/kotlin/taboolib/expansion/JexlHelper.kt @@ -1,14 +1,26 @@ @file:Inject -@file:RuntimeDependency( - "!org.apache.commons:commons-jexl3:3.2.1", - test = "!org.apache.commons.jexl3_3_2_1.JexlEngine", - relocate = ["!org.apache.commons.jexl3", "!org.apache.commons.jexl3_3_2_1"], - transitive = false +@file:RuntimeDependencies( + RuntimeDependency( + "!org.apache.commons:commons-jexl3:3.2.1", + test = "!org.apache.commons.jexl3_3_2_1.JexlEngine", + relocate = [ + "!org.apache.commons.jexl3", "!org.apache.commons.jexl3_3_2_1", + "!org.apache.commons.logging", "!org.apache.commons.logging_1_2" + ], + transitive = false + ), + RuntimeDependency( + "!commons-logging:commons-logging:1.2", + test = "!org.apache.commons.logging_1_2.Log", + relocate = ["!org.apache.commons.logging", "!org.apache.commons.logging_1_2"], + transitive = false + ) ) package taboolib.expansion import taboolib.common.Inject +import taboolib.common.env.RuntimeDependencies import taboolib.common.env.RuntimeDependency import taboolib.common.util.unsafeLazy diff --git a/module/script/script-jexl/src/test/kotlin/taboolib/expansion/JexlCompilerTest.kt b/module/script/script-jexl/src/test/kotlin/taboolib/expansion/JexlCompilerTest.kt new file mode 100644 index 000000000..3c39ba8b9 --- /dev/null +++ b/module/script/script-jexl/src/test/kotlin/taboolib/expansion/JexlCompilerTest.kt @@ -0,0 +1,48 @@ +package taboolib.expansion + +import org.apache.commons.jexl3.JexlException +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertNotSame +import org.junit.jupiter.api.Assertions.assertNull +import org.junit.jupiter.api.Assertions.assertSame +import org.junit.jupiter.api.Assertions.assertThrows +import org.junit.jupiter.api.Test + +class JexlCompilerTest { + + @Test + fun `rebuilds the engine when strict mode changes after first compilation`() { + val compiler = JexlCompiler() + val initialEngine = compiler.jexlEngine + + assertNull(compiler.compileToExpression("missing").eval()) + + compiler.strict(true) + val strictEngine = compiler.jexlEngine + + assertNotSame(initialEngine, strictEngine) + assertSame(strictEngine, compiler.jexlEngine) + assertThrows(JexlException::class.java) { + compiler.compileToExpression("missing").eval() + } + } + + @Test + fun `applies namespace changes made after first compilation`() { + val compiler = JexlCompiler() + assertEquals(2, compiler.compileToExpression("1 + 1").eval()) + + compiler.namespace(mapOf("tools" to Tools(21))) + assertEquals(21, compiler.compileToExpression("tools:answer()").eval()) + + compiler.namespace(mapOf("tools" to Tools(42))) + assertEquals(42, compiler.compileToExpression("tools:answer()").eval()) + } + + class Tools(private val answer: Int) { + + fun answer(): Int { + return answer + } + } +} From 51abf129c65f15b55765bef6552016bc1a29f731 Mon Sep 17 00:00:00 2001 From: zhibei <785740487@qq.com> Date: Tue, 14 Jul 2026 00:03:54 +0800 Subject: [PATCH 17/37] =?UTF-8?q?fix(bukkit):=20=E6=94=B6=E7=B4=A7=20Folia?= =?UTF-8?q?=20=E8=B0=83=E5=BA=A6=E4=B8=8E=E8=83=8C=E5=8C=85=E7=BA=BF?= =?UTF-8?q?=E7=A8=8B=E6=89=80=E6=9C=89=E6=9D=83?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 避免无上下文同步任务错误落到全局区域线程,并让 UI、虚拟背包与 NMS 操作按玩家或区域线程执行;同时保证物品数量不足时不发生部分扣除。 --- .../main/kotlin/taboolib/module/nms/NMSMap.kt | 4 +- .../kotlin/taboolib/module/nms/NMSToast.kt | 60 +++--- .../kotlin/taboolib/module/nms/NMSSign.kt | 13 +- .../taboolib/module/nms/PacketSender.kt | 4 +- .../platform/compat/PlaceholderExpansion.kt | 8 +- .../taboolib/module/ui/ClickListener.kt | 18 +- .../kotlin/taboolib/module/ui/MenuBuilder.kt | 23 ++- .../taboolib/module/ui/MenuBuilderRaw.kt | 2 +- .../module/ui/type/impl/PageableChestImpl.kt | 65 ++++--- .../ui/type/storable/DragActionContext.kt | 1 - .../module/ui/virtual/InventoryHandler.kt | 57 +++--- .../module/ui/virtual/InventoryHandlerImpl.kt | 26 +-- .../module/ui/virtual/VirtualInventory.kt | 22 ++- .../ui/virtual/VirtualInventoryFactory.kt | 22 ++- module/bukkit/bukkit-util/build.gradle.kts | 1 + .../taboolib/platform/lang/TypeBossBar.kt | 26 +-- .../taboolib/platform/util/BukkitBook.kt | 3 +- .../taboolib/platform/util/BukkitChat.kt | 3 +- .../taboolib/platform/util/ItemMatcher.kt | 62 ++++-- .../taboolib/platform/util/ItemMatcherTest.kt | 47 +++++ .../platform-bukkit-impl/build.gradle.kts | 4 + .../taboolib/platform/BukkitExecutor.kt | 47 ++--- .../taboolib/platform/util/FoliaExecutor.kt | 177 ++++++++++++------ .../taboolib/platform/BukkitExecutorTest.kt | 52 +++++ .../java/taboolib/platform/BukkitPlugin.java | 2 +- .../java/taboolib/platform/FoliaExecutor.java | 7 + 26 files changed, 497 insertions(+), 259 deletions(-) create mode 100644 module/bukkit/bukkit-util/src/test/kotlin/taboolib/platform/util/ItemMatcherTest.kt create mode 100644 platform/platform-bukkit-impl/src/test/kotlin/taboolib/platform/BukkitExecutorTest.kt diff --git a/module/bukkit-nms/bukkit-nms-legacy/src/main/kotlin/taboolib/module/nms/NMSMap.kt b/module/bukkit-nms/bukkit-nms-legacy/src/main/kotlin/taboolib/module/nms/NMSMap.kt index a18f8730e..b1819c8b0 100644 --- a/module/bukkit-nms/bukkit-nms-legacy/src/main/kotlin/taboolib/module/nms/NMSMap.kt +++ b/module/bukkit-nms/bukkit-nms-legacy/src/main/kotlin/taboolib/module/nms/NMSMap.kt @@ -13,7 +13,7 @@ import org.tabooproject.reflex.Reflex.Companion.invokeConstructor import org.tabooproject.reflex.Reflex.Companion.invokeMethod import org.tabooproject.reflex.Reflex.Companion.setProperty import org.tabooproject.reflex.Reflex.Companion.unsafeInstance -import taboolib.common.platform.function.submit +import taboolib.platform.util.submit import taboolib.common.util.unsafeLazy import taboolib.library.xseries.XMaterial import taboolib.platform.util.ItemBuilder @@ -255,7 +255,7 @@ class NMSMap(val image: BufferedImage, var hand: Hand = Hand.MAIN, val builder: } fun sendTo(player: Player) { - submit(delay = 3) { + player.submit(delay = 3) { val container = if (MinecraftVersion.isUniversal) { player.getProperty("entity/inventoryMenu") } else { diff --git a/module/bukkit-nms/bukkit-nms-legacy/src/main/kotlin/taboolib/module/nms/NMSToast.kt b/module/bukkit-nms/bukkit-nms-legacy/src/main/kotlin/taboolib/module/nms/NMSToast.kt index a10585b50..1e416cb79 100644 --- a/module/bukkit-nms/bukkit-nms-legacy/src/main/kotlin/taboolib/module/nms/NMSToast.kt +++ b/module/bukkit-nms/bukkit-nms-legacy/src/main/kotlin/taboolib/module/nms/NMSToast.kt @@ -6,12 +6,12 @@ import com.google.gson.JsonObject import org.bukkit.Bukkit import org.bukkit.Material import org.bukkit.NamespacedKey +import org.bukkit.advancement.Advancement import org.bukkit.entity.Player import org.tabooproject.reflex.Reflex.Companion.getProperty import org.tabooproject.reflex.Reflex.Companion.invokeMethod import org.tabooproject.reflex.Reflex.Companion.setProperty import taboolib.common.UnsupportedVersionException -import taboolib.common.platform.function.submit import taboolib.common.platform.function.warning import taboolib.common.util.t import taboolib.common.util.unsafeLazy @@ -21,6 +21,8 @@ import taboolib.module.nms.type.Toast import taboolib.module.nms.type.ToastBackground import taboolib.module.nms.type.ToastFrame import taboolib.platform.BukkitPlugin +import taboolib.platform.util.submit +import taboolib.platform.util.submitGlobal import java.util.* import java.util.concurrent.ConcurrentHashMap @@ -100,18 +102,28 @@ fun Player.sendToast(icon: Material, message: String, frame: ToastFrame = ToastF } val cache = Toast(icon, message, frame) val jsonToast = toJsonToast(icon.invokeMethod("getKey").toString(), message, frame, background) - // 在主线程操作 - submit { - // 向服务器注册成就 - val namespaceKey = toastMap.getOrPut(cache) { - injectAdvancement(NamespacedKey(BukkitPlugin.getInstance(), "toast_${UUID.randomUUID()}"), jsonToast) + // 服务端成就注册必须在全局线程执行 + submitGlobal { + val namespaceKey = toastMap.compute(cache) { _, cachedKey -> + if (cachedKey == null || Bukkit.getAdvancement(cachedKey) == null) { + injectAdvancement(NamespacedKey(BukkitPlugin.getInstance(), "toast_${UUID.randomUUID()}"), jsonToast) + } else { + cachedKey + } + } ?: return@submitGlobal + val advancement = Bukkit.getAdvancement(namespaceKey) + if (advancement == null) { + warning("Advancement $namespaceKey not found.") + return@submitGlobal } - // 向玩家注册成就 - awardAdvancement(this@sendToast, namespaceKey) - // 延迟注销,否则会出问题 - submit(delay = 20) { - revokeAdvancement(this@sendToast, namespaceKey) - ejectAdvancement(namespaceKey) + // 玩家进度必须在玩家所属线程修改 + this@sendToast.submit(now = true) { + awardAdvancement(this@sendToast, advancement) + // 延迟注销,否则会出问题 + this@sendToast.submit(delay = 20) { + revokeAdvancement(this@sendToast, advancement) + submitGlobal { ejectAdvancement(namespaceKey) } + } } } } @@ -119,28 +131,20 @@ fun Player.sendToast(icon: Material, message: String, frame: ToastFrame = ToastF /** * 赋予玩家成就 */ -private fun awardAdvancement(player: Player, key: NamespacedKey) { - val advancement = Bukkit.getAdvancement(key) - if (advancement == null) { - warning("Advancement $key not found.") - return - } - if (!player.getAdvancementProgress(advancement).isDone) { - player.getAdvancementProgress(advancement).remainingCriteria.forEach { - player.getAdvancementProgress(advancement).awardCriteria(it) - } +private fun awardAdvancement(player: Player, advancement: Advancement) { + val progress = player.getAdvancementProgress(advancement) + if (!progress.isDone) { + progress.remainingCriteria.forEach { progress.awardCriteria(it) } } } /** * 注销玩家成就 */ -private fun revokeAdvancement(player: Player, key: NamespacedKey) { - val advancement = Bukkit.getAdvancement(key) - if (advancement != null && player.getAdvancementProgress(advancement).isDone) { - player.getAdvancementProgress(advancement).awardedCriteria.forEach { - player.getAdvancementProgress(advancement).revokeCriteria(it) - } +private fun revokeAdvancement(player: Player, advancement: Advancement) { + val progress = player.getAdvancementProgress(advancement) + if (progress.isDone) { + progress.awardedCriteria.forEach { progress.revokeCriteria(it) } } } diff --git a/module/bukkit-nms/bukkit-nms-stable/src/main/kotlin/taboolib/module/nms/NMSSign.kt b/module/bukkit-nms/bukkit-nms-stable/src/main/kotlin/taboolib/module/nms/NMSSign.kt index 22eef30e8..ef4a66de8 100644 --- a/module/bukkit-nms/bukkit-nms-stable/src/main/kotlin/taboolib/module/nms/NMSSign.kt +++ b/module/bukkit-nms/bukkit-nms-stable/src/main/kotlin/taboolib/module/nms/NMSSign.kt @@ -10,11 +10,8 @@ import taboolib.common.Inject import taboolib.common.platform.Platform import taboolib.common.platform.PlatformSide import taboolib.common.platform.event.SubscribeEvent -import taboolib.common.platform.function.submit import taboolib.common.util.unsafeLazy -import taboolib.platform.BukkitPlugin -import taboolib.platform.Folia -import taboolib.platform.FoliaExecutor +import taboolib.platform.util.runTask import java.lang.reflect.Constructor import java.util.concurrent.ConcurrentHashMap @@ -145,13 +142,7 @@ private object NMSSignListener { MinecraftVersion.isHigherOrEqual(MinecraftVersion.V1_9) -> e.packet.read>("b")!! else -> e.packet.read>("b")!!.map { nmsProxy().deserialize(it) }.toTypedArray() } - if (Folia.isFolia) { - FoliaExecutor.REGION_SCHEDULER.run(BukkitPlugin.getInstance(), e.player.location) { - function.invoke(lines) - } - } else { - submit { function.invoke(lines) } - } + e.player.runTask(Runnable { function.invoke(lines) }) } } } diff --git a/module/bukkit-nms/src/main/kotlin/taboolib/module/nms/PacketSender.kt b/module/bukkit-nms/src/main/kotlin/taboolib/module/nms/PacketSender.kt index 9b1213190..8cab9d63c 100644 --- a/module/bukkit-nms/src/main/kotlin/taboolib/module/nms/PacketSender.kt +++ b/module/bukkit-nms/src/main/kotlin/taboolib/module/nms/PacketSender.kt @@ -10,7 +10,7 @@ import taboolib.common.Inject import taboolib.common.platform.Platform import taboolib.common.platform.PlatformSide import taboolib.common.platform.event.SubscribeEvent -import taboolib.common.platform.function.submit +import taboolib.common.platform.function.submitAsync import taboolib.common.reflect.ClassHelper import java.lang.reflect.Constructor import java.util.concurrent.ConcurrentHashMap @@ -247,6 +247,6 @@ object PacketSender { @SubscribeEvent private fun onQuit(e: PlayerQuitEvent) { - submit(delay = 20) { playerConnectionMap.remove(e.player.name) } + submitAsync(delay = 20) { playerConnectionMap.remove(e.player.name) } } } \ No newline at end of file diff --git a/module/bukkit/bukkit-hook/src/main/kotlin/taboolib/platform/compat/PlaceholderExpansion.kt b/module/bukkit/bukkit-hook/src/main/kotlin/taboolib/platform/compat/PlaceholderExpansion.kt index bb61940ac..6a1e78975 100644 --- a/module/bukkit/bukkit-hook/src/main/kotlin/taboolib/platform/compat/PlaceholderExpansion.kt +++ b/module/bukkit/bukkit-hook/src/main/kotlin/taboolib/platform/compat/PlaceholderExpansion.kt @@ -11,6 +11,8 @@ import taboolib.common.inject.ClassVisitor import taboolib.common.platform.Awake import taboolib.common.platform.function.registerBukkitListener import taboolib.common.platform.function.submit +import taboolib.platform.Folia +import taboolib.platform.FoliaExecutor import taboolib.common.util.unsafeLazy import taboolib.platform.BukkitPlugin import java.util.function.Supplier @@ -138,7 +140,11 @@ interface PlaceholderExpansion { if (expansion.autoReload) { registerBukkitListener(ExpansionUnregisterEvent::class.java) { if (it.expansion == papiExpansion) { - submit { papiExpansion.register() } + if (Folia.isFolia) { + FoliaExecutor.runGlobal { papiExpansion.register() } + } else { + submit { papiExpansion.register() } + } } } } diff --git a/module/bukkit/bukkit-ui/src/main/kotlin/taboolib/module/ui/ClickListener.kt b/module/bukkit/bukkit-ui/src/main/kotlin/taboolib/module/ui/ClickListener.kt index 957760ca8..09519dafb 100644 --- a/module/bukkit/bukkit-ui/src/main/kotlin/taboolib/module/ui/ClickListener.kt +++ b/module/bukkit/bukkit-ui/src/main/kotlin/taboolib/module/ui/ClickListener.kt @@ -18,10 +18,10 @@ import taboolib.common.platform.Ghost import taboolib.common.platform.Platform import taboolib.common.platform.PlatformSide import taboolib.common.platform.event.SubscribeEvent -import taboolib.common.platform.function.submit import taboolib.common.platform.function.submitAsync import taboolib.module.ui.type.impl.ChestImpl import taboolib.platform.util.isNotAir +import taboolib.platform.util.runTask import taboolib.platform.util.setMeta @Inject @@ -30,10 +30,12 @@ internal object ClickListener { @Awake(LifeCycle.DISABLE) fun onDisable() { - Bukkit.getOnlinePlayers().forEach { - if (MenuHolder.fromInventory(InventoryViewProxy.getTopInventory(it.openInventory)) != null) { - it.closeInventory() - } + Bukkit.getOnlinePlayers().forEach { player -> + player.runTask(Runnable { + if (MenuHolder.fromInventory(InventoryViewProxy.getTopInventory(player.openInventory)) != null) { + player.closeInventory() + } + }) } } @@ -41,11 +43,11 @@ internal object ClickListener { fun onOpen(e: InventoryOpenEvent) { val builder = MenuHolder.fromInventory(e.inventory) as? ChestImpl ?: return val player = e.player as Player - // 构建回调 - submit { + // 构建回调必须在玩家所属线程执行 + player.runTask(Runnable { builder.buildCallback(player, e.inventory) builder.finalBuildCallback(player, e.inventory) - } + }) // 异步构建回调 submitAsync { builder.asyncBuildCallback(player, e.inventory) diff --git a/module/bukkit/bukkit-ui/src/main/kotlin/taboolib/module/ui/MenuBuilder.kt b/module/bukkit/bukkit-ui/src/main/kotlin/taboolib/module/ui/MenuBuilder.kt index f9d21a71c..826481b96 100644 --- a/module/bukkit/bukkit-ui/src/main/kotlin/taboolib/module/ui/MenuBuilder.kt +++ b/module/bukkit/bukkit-ui/src/main/kotlin/taboolib/module/ui/MenuBuilder.kt @@ -21,6 +21,8 @@ import taboolib.module.ui.virtual.VirtualInventory import taboolib.module.ui.virtual.inject import taboolib.module.ui.virtual.openVirtualInventory import taboolib.platform.util.isNotAir +import taboolib.platform.util.isOwnedByCurrentRegion +import taboolib.platform.util.runTask /** * 允许在 Vanilla Inventory 中使用 Raw Title @@ -84,11 +86,18 @@ inline fun buildMenu(title: String = "chest", builder: T.() - /** * 构建一个菜单并为玩家打开 */ -inline fun HumanEntity.openMenu(title: String = "chest", builder: T.() -> Unit) { - try { - openMenu(buildMenu(title, builder)) - } catch (ex: Throwable) { - ex.printStackTrace() +inline fun HumanEntity.openMenu(title: String = "chest", crossinline builder: T.() -> Unit) { + val openAction = Runnable { + try { + openMenu(buildMenu(title, builder)) + } catch (ex: Throwable) { + ex.printStackTrace() + } + } + if (isOwnedByCurrentRegion()) { + openAction.run() + } else { + runTask(openAction) } } @@ -96,6 +105,10 @@ inline fun HumanEntity.openMenu(title: String = "chest", buil * 打开一个构建后的菜单 */ fun HumanEntity.openMenu(buildMenu: Inventory, changeId: Boolean = true) { + if (!isOwnedByCurrentRegion()) { + runTask(Runnable { openMenu(buildMenu, changeId) }) + return + } try { if (buildMenu is VirtualInventory) { val remoteInventory = openVirtualInventory(buildMenu, changeId) diff --git a/module/bukkit/bukkit-ui/src/main/kotlin/taboolib/module/ui/MenuBuilderRaw.kt b/module/bukkit/bukkit-ui/src/main/kotlin/taboolib/module/ui/MenuBuilderRaw.kt index e59495eb0..3ab00cfbb 100644 --- a/module/bukkit/bukkit-ui/src/main/kotlin/taboolib/module/ui/MenuBuilderRaw.kt +++ b/module/bukkit/bukkit-ui/src/main/kotlin/taboolib/module/ui/MenuBuilderRaw.kt @@ -8,6 +8,6 @@ inline fun buildMenu(title: Source, builder: T.() -> Unit): I return buildMenu(title.toRawMessage(), builder) } -inline fun HumanEntity.openMenu(title: Source, builder: T.() -> Unit) { +inline fun HumanEntity.openMenu(title: Source, crossinline builder: T.() -> Unit) { openMenu(title.toRawMessage(), builder) } \ No newline at end of file diff --git a/module/bukkit/bukkit-ui/src/main/kotlin/taboolib/module/ui/type/impl/PageableChestImpl.kt b/module/bukkit/bukkit-ui/src/main/kotlin/taboolib/module/ui/type/impl/PageableChestImpl.kt index 6d986f414..5a41bb0d3 100644 --- a/module/bukkit/bukkit-ui/src/main/kotlin/taboolib/module/ui/type/impl/PageableChestImpl.kt +++ b/module/bukkit/bukkit-ui/src/main/kotlin/taboolib/module/ui/type/impl/PageableChestImpl.kt @@ -6,11 +6,10 @@ import org.bukkit.inventory.Inventory import org.bukkit.inventory.ItemStack import taboolib.common.util.subList import taboolib.module.ui.ClickEvent +import taboolib.module.ui.openMenu import taboolib.module.ui.type.PageableChest -import taboolib.module.ui.virtual.VirtualInventory -import taboolib.module.ui.virtual.inject -import taboolib.module.ui.virtual.openVirtualInventory import taboolib.platform.util.isNotAir +import taboolib.platform.util.runTask import java.util.concurrent.CopyOnWriteArrayList open class PageableChestImpl(title: String) : ChestImpl(title), PageableChest { @@ -124,11 +123,7 @@ open class PageableChestImpl(title: String) : ChestImpl(title), PageableChest ) { // 刷新页面 fun refresh() { - if (virtualized) { - viewer.openVirtualInventory(build() as VirtualInventory).inject(this) - } else { - viewer.openInventory(build()) - } + viewer.openMenu(build()) pageChangeCallback(viewer) } // 设置物品 @@ -160,11 +155,7 @@ open class PageableChestImpl(title: String) : ChestImpl(title), PageableChest ) { // 刷新页面 fun refresh() { - if (virtualized) { - viewer.openVirtualInventory(build() as VirtualInventory).inject(this) - } else { - viewer.openInventory(build()) - } + viewer.openMenu(build()) pageChangeCallback(viewer) } // 设置物品 @@ -176,7 +167,7 @@ open class PageableChestImpl(title: String) : ChestImpl(title), PageableChest refresh() } else if (roll) { // 若循环翻页, 则跳转到最后一页 - page = maxPage - 1 + page = (maxPage - 1).coerceAtLeast(0) refresh() } } @@ -219,33 +210,41 @@ open class PageableChestImpl(title: String) : ChestImpl(title), PageableChest elementsCache = elementsCallback() // 本次页面所使用的元素缓存 - val elementMap = hashMapOf() - val elementItems = subList(elementsCache, page * menuSlots.size, (page + 1) * menuSlots.size) + val elementItems = if (menuSlots.isEmpty()) { + emptyList() + } else { + subList(elementsCache, page * menuSlots.size, (page + 1) * menuSlots.size) + } + val pageElements = elementItems.mapIndexedNotNull { index, element -> + menuSlots.getOrNull(index)?.let { slot -> Triple(index, slot, element) } + } + val elementMap = pageElements.associate { (_, slot, element) -> slot to element } // 计算最大页数 - maxPage = elementsCache.size / menuSlots.size + maxPage = if (menuSlots.isEmpty()) 0 else (elementsCache.size + menuSlots.size - 1) / menuSlots.size - /** - * 构建事件处理函数 - */ - fun processBuild(p: Player, inventory: Inventory, async: Boolean) { + // 同步生成回调 + onFinalBuild { p, inventory -> viewer = p - elementItems.forEachIndexed { index, item -> - val slot = menuSlots.getOrNull(index) ?: 0 - elementMap[slot] = item - // 生成元素对应物品 - val callback = if (async) asyncGenerateCallback else generateCallback - val itemStack = callback(viewer, item, index, slot) + pageElements.forEach { (index, slot, element) -> + val itemStack = generateCallback(p, element, index, slot) if (itemStack.isNotAir()) { inventory.setItem(slot, itemStack) } } } - - // 生成回调 - onFinalBuild { p, it -> processBuild(p, it, false) } - // 生成异步回调 - onFinalBuild(async = true) { p, it -> processBuild(p, it, true) } + // 异步阶段只生成物品,实际 Inventory 修改切回玩家所属线程 + onFinalBuild(async = true) { p, inventory -> + val generatedItems = pageElements.mapNotNull { (index, slot, element) -> + asyncGenerateCallback(p, element, index, slot).takeIf { it.isNotAir() }?.let { slot to it } + } + p.runTask(Runnable { + if (lastInventory !== inventory) { + return@Runnable + } + generatedItems.forEach { (slot, itemStack) -> inventory.setItem(slot, itemStack) } + }) + } // 生成点击回调 selfClick { if (menuLocked) { @@ -261,6 +260,6 @@ open class PageableChestImpl(title: String) : ChestImpl(title), PageableChest * 是否存在下一页 */ private fun isNext(page: Int, size: Int, entry: Int): Boolean { - return size / entry.toDouble() > page + 1 + return entry > 0 && size / entry.toDouble() > page + 1 } } \ No newline at end of file diff --git a/module/bukkit/bukkit-ui/src/main/kotlin/taboolib/module/ui/type/storable/DragActionContext.kt b/module/bukkit/bukkit-ui/src/main/kotlin/taboolib/module/ui/type/storable/DragActionContext.kt index 867297811..60a32946e 100644 --- a/module/bukkit/bukkit-ui/src/main/kotlin/taboolib/module/ui/type/storable/DragActionContext.kt +++ b/module/bukkit/bukkit-ui/src/main/kotlin/taboolib/module/ui/type/storable/DragActionContext.kt @@ -4,7 +4,6 @@ import org.bukkit.entity.Player import org.bukkit.event.inventory.DragType import org.bukkit.inventory.Inventory import org.bukkit.inventory.ItemStack -import taboolib.common.platform.function.submit import taboolib.module.ui.ClickEvent import taboolib.module.ui.type.impl.StorableChestImpl.RuleImpl diff --git a/module/bukkit/bukkit-ui/src/main/kotlin/taboolib/module/ui/virtual/InventoryHandler.kt b/module/bukkit/bukkit-ui/src/main/kotlin/taboolib/module/ui/virtual/InventoryHandler.kt index e7b7c1c19..70b4f736e 100644 --- a/module/bukkit/bukkit-ui/src/main/kotlin/taboolib/module/ui/virtual/InventoryHandler.kt +++ b/module/bukkit/bukkit-ui/src/main/kotlin/taboolib/module/ui/virtual/InventoryHandler.kt @@ -18,6 +18,7 @@ import taboolib.module.nms.nmsProxy import taboolib.module.ui.InventoryViewProxy import taboolib.module.ui.MenuHolder import taboolib.module.ui.type.AnvilCallback +import taboolib.platform.util.runTask import java.util.concurrent.ConcurrentHashMap /** @@ -84,12 +85,15 @@ abstract class InventoryHandler { val player = e.player val remoteInventory = playerRemoteInventoryMap[player.name] if (remoteInventory != null && (remoteInventory.id == id || id == 0)) { - playerRemoteInventoryMap.remove(player.name)?.close(sendPacket = false) - try { - player.updateInventory() - } catch (ex: NoSuchMethodError) { - ex.printStackTrace() - } + val removedInventory = playerRemoteInventoryMap.remove(player.name) ?: return + player.runTask(Runnable { + removedInventory.close(sendPacket = false) + try { + player.updateInventory() + } catch (ex: NoSuchMethodError) { + ex.printStackTrace() + } + }) } } // 点击 @@ -100,31 +104,36 @@ abstract class InventoryHandler { } val id = e.packet.read(if (MinecraftVersion.isUniversal) "containerId" else "a")!! val player = e.player - val remoteInventory = playerRemoteInventoryMap[player.name] - if (remoteInventory != null && remoteInventory.id == id) { - remoteInventory.handleClick(e.packet) - } + val packet = e.packet + player.runTask(Runnable { + val remoteInventory = playerRemoteInventoryMap[player.name] + if (remoteInventory != null && remoteInventory.id == id) { + remoteInventory.handleClick(packet) + } + }) } // 重命名 "PacketPlayInItemName", "ServerboundRenameItemPacket" -> { val text = e.packet.read(if (MinecraftVersion.isUniversal) "name" else "a") ?: return val player = e.player - // 虚拟容器处理 - val virtualInventory = playerRemoteInventoryMap[player.name]?.inventory - if (virtualInventory != null) { - val builder = MenuHolder.fromInventory(virtualInventory) - if (builder is AnvilCallback) { - builder.invoke(player, text, virtualInventory) + player.runTask(Runnable { + // 虚拟容器处理 + val virtualInventory = playerRemoteInventoryMap[player.name]?.inventory + if (virtualInventory != null) { + val builder = MenuHolder.fromInventory(virtualInventory) + if (builder is AnvilCallback) { + builder.invoke(player, text, virtualInventory) + } } - } - // 普通容器处理 - else { - val openInventory = InventoryViewProxy.getTopInventory(player.openInventory) - val builder = MenuHolder.fromInventory(openInventory) - if (builder is AnvilCallback) { - builder.invoke(player, text, openInventory) + // 普通容器处理 + else { + val openInventory = InventoryViewProxy.getTopInventory(player.openInventory) + val builder = MenuHolder.fromInventory(openInventory) + if (builder is AnvilCallback) { + builder.invoke(player, text, openInventory) + } } - } + }) } } } diff --git a/module/bukkit/bukkit-ui/src/main/kotlin/taboolib/module/ui/virtual/InventoryHandlerImpl.kt b/module/bukkit/bukkit-ui/src/main/kotlin/taboolib/module/ui/virtual/InventoryHandlerImpl.kt index 661c4dacd..78a6fef1a 100644 --- a/module/bukkit/bukkit-ui/src/main/kotlin/taboolib/module/ui/virtual/InventoryHandlerImpl.kt +++ b/module/bukkit/bukkit-ui/src/main/kotlin/taboolib/module/ui/virtual/InventoryHandlerImpl.kt @@ -16,8 +16,6 @@ import org.bukkit.entity.Player import org.bukkit.event.inventory.InventoryCloseEvent import org.bukkit.inventory.ItemStack import taboolib.common.UnsupportedVersionException -import taboolib.common.platform.function.isPrimaryThread -import taboolib.common.platform.function.submit import taboolib.module.nms.MinecraftVersion import taboolib.module.nms.Packet import taboolib.module.nms.sendBundlePacket @@ -25,6 +23,8 @@ import taboolib.module.nms.sendPacket import taboolib.module.ui.InventoryViewProxy import taboolib.platform.util.isAir import taboolib.platform.util.isNotAir +import taboolib.platform.util.isOwnedByCurrentRegion +import taboolib.platform.util.runTask /** * TabooLib @@ -287,6 +287,10 @@ class InventoryHandlerImpl : InventoryHandler() { } override fun close(sendPacket: Boolean) { + if (!viewer.isOwnedByCurrentRegion()) { + viewer.runTask(Runnable { close(sendPacket) }) + return + } if (isClosed) { return } @@ -300,17 +304,9 @@ class InventoryHandlerImpl : InventoryHandler() { } } // 处理回调 - if (isPrimaryThread) { - onCloseCallback?.invoke() - } else { - submit { onCloseCallback?.invoke() } - } + onCloseCallback?.invoke() // 唤起事件 - if (isPrimaryThread) { - Bukkit.getPluginManager().callEvent(InventoryCloseEvent(createInventoryView())) - } else { - submit { Bukkit.getPluginManager().callEvent(InventoryCloseEvent(createInventoryView())) } - } + Bukkit.getPluginManager().callEvent(InventoryCloseEvent(createInventoryView())) } override fun onClick(callback: RemoteInventory.ClickEvent.() -> Unit) { @@ -365,6 +361,10 @@ class InventoryHandlerImpl : InventoryHandler() { } fun handle(slotNum: Int, buttonNum: Int, clickType: String) { + if (!viewer.isOwnedByCurrentRegion()) { + viewer.runTask(Runnable { handle(slotNum, buttonNum, clickType) }) + return + } val vClickType = when (clickType) { // 左右键 "PICKUP" -> { @@ -402,7 +402,7 @@ class InventoryHandlerImpl : InventoryHandler() { else -> inventory.getStorageItem(slotNum - inventory.size) } // 处理回调 - submit { onClickCallback?.invoke(RemoteInventory.ClickEvent(vClickType.toBukkit(), slotNum, buttonNum, clickItem ?: air)) } + onClickCallback?.invoke(RemoteInventory.ClickEvent(vClickType.toBukkit(), slotNum, buttonNum, clickItem ?: air)) // 处理页面 if (clickItem.isNotAir()) { // 一般点击方式 diff --git a/module/bukkit/bukkit-ui/src/main/kotlin/taboolib/module/ui/virtual/VirtualInventory.kt b/module/bukkit/bukkit-ui/src/main/kotlin/taboolib/module/ui/virtual/VirtualInventory.kt index f5f23cbc4..07ed49d69 100644 --- a/module/bukkit/bukkit-ui/src/main/kotlin/taboolib/module/ui/virtual/VirtualInventory.kt +++ b/module/bukkit/bukkit-ui/src/main/kotlin/taboolib/module/ui/virtual/VirtualInventory.kt @@ -8,6 +8,8 @@ import org.bukkit.inventory.Inventory import org.bukkit.inventory.InventoryHolder import org.bukkit.inventory.ItemStack import taboolib.common.util.t +import taboolib.platform.util.isOwnedByCurrentRegion +import taboolib.platform.util.runTask /** * TabooLib @@ -77,7 +79,7 @@ class VirtualInventory(val bukkitInventory: Inventory, storageContents: List if (storageContents == null) { initStorageItems() } @@ -88,7 +90,7 @@ class VirtualInventory(val bukkitInventory: Inventory, storageContents: List) { + fun setStorageItems(items: List) = mutate { remoteInventory -> storageContents = items remoteInventory?.refresh(bukkitInventory.contents.map { it ?: ItemStack(Material.AIR) }, storageContents) } @@ -109,7 +111,7 @@ class VirtualInventory(val bukkitInventory: Inventory, storageContents: List bukkitInventory.maxStackSize = p0 } @@ -117,7 +119,7 @@ class VirtualInventory(val bukkitInventory: Inventory, storageContents: List bukkitInventory.setItem(slot, item) remoteInventory?.sendSlotChange(slot, item ?: ItemStack(Material.AIR)) } @@ -134,7 +136,7 @@ class VirtualInventory(val bukkitInventory: Inventory, storageContents: List) { + override fun setContents(p0: Array) = mutate { remoteInventory -> bukkitInventory.contents = p0 remoteInventory?.refresh(bukkitInventory.contents.map { it ?: ItemStack(Material.AIR) }, storageContents) } @@ -223,6 +225,16 @@ class VirtualInventory(val bukkitInventory: Inventory, storageContents: List Unit) { + val remoteInventory = remoteInventory + val viewer = remoteInventory?.viewer + if (viewer != null && !viewer.isOwnedByCurrentRegion()) { + viewer.runTask(Runnable { action(remoteInventory) }) + } else { + action(remoteInventory) + } + } + /** * 对于老版本, Inventory 下有 getTitle 函数 * 部分插件监听 InventoryCloseEvent 时调用, 所以得给个标题给他们玩 diff --git a/module/bukkit/bukkit-ui/src/main/kotlin/taboolib/module/ui/virtual/VirtualInventoryFactory.kt b/module/bukkit/bukkit-ui/src/main/kotlin/taboolib/module/ui/virtual/VirtualInventoryFactory.kt index 32736ca99..4347566e9 100644 --- a/module/bukkit/bukkit-ui/src/main/kotlin/taboolib/module/ui/virtual/VirtualInventoryFactory.kt +++ b/module/bukkit/bukkit-ui/src/main/kotlin/taboolib/module/ui/virtual/VirtualInventoryFactory.kt @@ -9,14 +9,15 @@ import org.bukkit.event.inventory.InventoryOpenEvent import org.bukkit.inventory.Inventory import org.bukkit.inventory.InventoryView import org.bukkit.inventory.ItemStack -import taboolib.common.platform.function.isPrimaryThread -import taboolib.common.platform.function.submit import taboolib.module.nms.MinecraftVersion import taboolib.module.ui.ClickEvent import taboolib.module.ui.ClickType import taboolib.module.ui.type.Basic import taboolib.module.ui.type.Chest import taboolib.module.ui.type.impl.ChestImpl +import taboolib.platform.util.callRegionAsync +import taboolib.platform.util.isOwnedByCurrentRegion +import java.util.concurrent.CompletableFuture /** * 将背包转换为 VirtualInventory 实例 @@ -29,18 +30,23 @@ fun Inventory.virtualize(storageContents: List? = null): VirtualInven * 使玩家打开虚拟页面 */ fun HumanEntity.openVirtualInventory(inventory: VirtualInventory, updateId: Boolean = true): RemoteInventory { + check(isOwnedByCurrentRegion()) { + "Virtual inventory must be opened on the thread that owns the viewer. Use openVirtualInventoryAsync(), HumanEntity.openMenu(), or Entity.runTask() instead." + } val remoteInventory = InventoryHandler.instance.openInventory(this as Player, inventory, ItemStack(Material.AIR), updateId) inventory.remoteInventory = remoteInventory InventoryHandler.playerRemoteInventoryMap[name] = remoteInventory - // 唤起事件 - if (isPrimaryThread) { - Bukkit.getPluginManager().callEvent(InventoryOpenEvent(remoteInventory.createInventoryView())) - } else { - submit { Bukkit.getPluginManager().callEvent(InventoryOpenEvent(remoteInventory.createInventoryView())) } - } + Bukkit.getPluginManager().callEvent(InventoryOpenEvent(remoteInventory.createInventoryView())) return remoteInventory } +/** + * 在玩家所属线程打开虚拟页面,并通过 Future 非阻塞返回远程页面。 + */ +fun HumanEntity.openVirtualInventoryAsync(inventory: VirtualInventory, updateId: Boolean = true): CompletableFuture { + return callRegionAsync { openVirtualInventory(inventory, updateId) } +} + fun RemoteInventory.inject(menu: Basic) = inject(menu as ChestImpl) fun RemoteInventory.inject(menu: Chest) = inject(menu as ChestImpl) diff --git a/module/bukkit/bukkit-util/build.gradle.kts b/module/bukkit/bukkit-util/build.gradle.kts index dc72204c0..1a85dba49 100644 --- a/module/bukkit/bukkit-util/build.gradle.kts +++ b/module/bukkit/bukkit-util/build.gradle.kts @@ -18,4 +18,5 @@ dependencies { compileOnly("ink.ptms.core:v12111:12111-minimize:universal") compileOnly("ink.ptms.core:v12101:12101-minimize:universal") compileOnly("ink.ptms.core:v11200:11200-minimize") + testImplementation("ink.ptms.core:v11200:11200-minimize") } \ No newline at end of file diff --git a/module/bukkit/bukkit-util/src/main/kotlin/taboolib/platform/lang/TypeBossBar.kt b/module/bukkit/bukkit-util/src/main/kotlin/taboolib/platform/lang/TypeBossBar.kt index a62108a65..9a19d72de 100644 --- a/module/bukkit/bukkit-util/src/main/kotlin/taboolib/platform/lang/TypeBossBar.kt +++ b/module/bukkit/bukkit-util/src/main/kotlin/taboolib/platform/lang/TypeBossBar.kt @@ -3,10 +3,10 @@ package taboolib.platform.lang import org.bukkit.Bukkit import org.bukkit.boss.BarColor import org.bukkit.boss.BarStyle +import org.bukkit.entity.Player import taboolib.common.Inject import taboolib.common.LifeCycle import taboolib.common.platform.* -import taboolib.common.platform.function.submit import taboolib.common.platform.function.warning import taboolib.common.util.replaceWithOrder import taboolib.common.util.t @@ -14,6 +14,7 @@ import taboolib.common5.cdouble import taboolib.common5.clong import taboolib.module.lang.Language import taboolib.module.lang.Type +import taboolib.platform.util.submit /** * TabooLib @@ -62,16 +63,19 @@ class TypeBossBar : Type { return } if (sender is ProxyPlayer) { - val bossBar = Bukkit.createBossBar(text!!.translate(sender, *args).replaceWithOrder(*args), color, style) - bossBar.progress = if (method == "INCREASE") 0.0 else 1.0 - bossBar.addPlayer(sender.cast()) - submit(period = period) { - val progress = bossBar.progress + if (method == "INCREASE") step else -step - if (progress in 0.0..1.0) { - bossBar.progress = progress - } else { - bossBar.removeAll() - cancel() + val player = sender.cast() + player.submit(now = true) { + val bossBar = Bukkit.createBossBar(text!!.translate(sender, *args).replaceWithOrder(*args), color, style) + bossBar.progress = if (method == "INCREASE") 0.0 else 1.0 + bossBar.addPlayer(player) + player.submit(period = period) { + val progress = bossBar.progress + if (method == "INCREASE") step else -step + if (progress in 0.0..1.0) { + bossBar.progress = progress + } else { + bossBar.removeAll() + cancel() + } } } } else { diff --git a/module/bukkit/bukkit-util/src/main/kotlin/taboolib/platform/util/BukkitBook.kt b/module/bukkit/bukkit-util/src/main/kotlin/taboolib/platform/util/BukkitBook.kt index 4a19d0275..b5bd8e85c 100644 --- a/module/bukkit/bukkit-util/src/main/kotlin/taboolib/platform/util/BukkitBook.kt +++ b/module/bukkit/bukkit-util/src/main/kotlin/taboolib/platform/util/BukkitBook.kt @@ -7,7 +7,6 @@ import taboolib.common.Inject import taboolib.common.platform.Platform import taboolib.common.platform.PlatformSide import taboolib.common.platform.event.SubscribeEvent -import taboolib.common.platform.function.submit import taboolib.library.xseries.XMaterial import java.util.concurrent.ConcurrentHashMap @@ -57,7 +56,7 @@ internal object BookListener { consumer(pages) if (lore.getOrNull(1) == regex[1]) { inputs.remove(event.player.name) - submit(delay = 1) { + event.player.submit(delay = 1) { event.player.inventory.takeItem(99) { i -> i.hasLore(regex[0]) } } } diff --git a/module/bukkit/bukkit-util/src/main/kotlin/taboolib/platform/util/BukkitChat.kt b/module/bukkit/bukkit-util/src/main/kotlin/taboolib/platform/util/BukkitChat.kt index 79e930d64..0634da236 100644 --- a/module/bukkit/bukkit-util/src/main/kotlin/taboolib/platform/util/BukkitChat.kt +++ b/module/bukkit/bukkit-util/src/main/kotlin/taboolib/platform/util/BukkitChat.kt @@ -7,7 +7,6 @@ import taboolib.common.Inject import taboolib.common.platform.Platform import taboolib.common.platform.PlatformSide import taboolib.common.platform.event.SubscribeEvent -import taboolib.common.platform.function.submit import java.util.concurrent.ConcurrentHashMap /** @@ -36,7 +35,7 @@ fun Player.nextChatInTick(tick: Long, func: (message: String) -> Unit, timeout: reuse(this) } else { ChatListener.inputs[name] = func - submit(delay = tick) { + this@nextChatInTick.submit(delay = tick) { if (ChatListener.inputs.containsKey(name)) { timeout(this@nextChatInTick) ChatListener.inputs.remove(name) diff --git a/module/bukkit/bukkit-util/src/main/kotlin/taboolib/platform/util/ItemMatcher.kt b/module/bukkit/bukkit-util/src/main/kotlin/taboolib/platform/util/ItemMatcher.kt index b9f57c8e7..65105f574 100644 --- a/module/bukkit/bukkit-util/src/main/kotlin/taboolib/platform/util/ItemMatcher.kt +++ b/module/bukkit/bukkit-util/src/main/kotlin/taboolib/platform/util/ItemMatcher.kt @@ -4,6 +4,29 @@ import org.bukkit.entity.Player import org.bukkit.inventory.Inventory import org.bukkit.inventory.ItemStack +internal data class RemovalPlan(val entry: T, val amount: Int) + +internal fun planRemoval(amount: Int, entries: Sequence, amountOf: (T) -> Int): List>? { + if (amount <= 0) { + return emptyList() + } + val plan = ArrayList>() + var remainingAmount = amount + for (entry in entries) { + val availableAmount = amountOf(entry) + if (availableAmount <= 0) { + continue + } + val takenAmount = minOf(availableAmount, remainingAmount) + plan += RemovalPlan(entry, takenAmount) + remainingAmount -= takenAmount + if (remainingAmount == 0) { + return plan + } + } + return null +} + /** * 检查玩家背包中的特定物品是否达到特定数量 * @@ -31,7 +54,11 @@ fun Inventory.checkItem(item: ItemStack, amount: Int = 1, remove: Boolean = fals if (item.isAir()) { error("air") } - return hasItem(amount) { it.isSimilar(item) } && (!remove || takeItem(amount) { it.isSimilar(item) }) + return if (remove) { + takeItem(amount) { it.isSimilar(item) } + } else { + hasItem(amount) { it.isSimilar(item) } + } } /** @@ -42,6 +69,9 @@ fun Inventory.checkItem(item: ItemStack, amount: Int = 1, remove: Boolean = fals * @return boolean */ fun Inventory.hasItem(amount: Int = 1, matcher: (itemStack: ItemStack) -> Boolean): Boolean { + if (amount <= 0) { + return true + } var checkAmount = amount contents.forEach { itemStack -> if (itemStack.isNotAir() && matcher(itemStack)) { @@ -63,24 +93,22 @@ fun Inventory.hasItem(amount: Int = 1, matcher: (itemStack: ItemStack) -> Boolea * @return boolean */ fun Inventory.takeItem(amount: Int = 1, takeList: MutableList = mutableListOf(), matcher: (itemStack: ItemStack) -> Boolean): Boolean { - var takeAmount = amount - contents.forEachIndexed { index, itemStack -> - if (itemStack.isNotAir() && matcher(itemStack)) { - takeAmount -= itemStack.amount - if (takeAmount < 0) { - takeList.add(itemStack.clone().apply { this.amount = takeAmount + itemStack.amount }) - itemStack.amount -= takeAmount + itemStack.amount - return takeList.isNotEmpty() - } else { - takeList.add(itemStack.clone()) - setItem(index, null) - if (takeAmount == 0) { - return takeList.isNotEmpty() - } - } + val matchedItems = contents.asSequence().mapIndexedNotNull { index, itemStack -> + if (itemStack.isNotAir() && matcher(itemStack)) index to itemStack else null + } + val removalPlan = planRemoval(amount, matchedItems) { (_, itemStack) -> itemStack.amount } ?: return false + val takenItems = ArrayList(removalPlan.size) + removalPlan.forEach { (entry, takenAmount) -> + val (index, itemStack) = entry + takenItems += itemStack.clone().apply { this.amount = takenAmount } + if (takenAmount == itemStack.amount) { + setItem(index, null) + } else { + setItem(index, itemStack.clone().apply { this.amount = itemStack.amount - takenAmount }) } } - return takeList.isNotEmpty() + takeList += takenItems + return true } diff --git a/module/bukkit/bukkit-util/src/test/kotlin/taboolib/platform/util/ItemMatcherTest.kt b/module/bukkit/bukkit-util/src/test/kotlin/taboolib/platform/util/ItemMatcherTest.kt new file mode 100644 index 000000000..cfb0c5f15 --- /dev/null +++ b/module/bukkit/bukkit-util/src/test/kotlin/taboolib/platform/util/ItemMatcherTest.kt @@ -0,0 +1,47 @@ +package taboolib.platform.util + +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertNull +import org.junit.jupiter.api.Test + +class ItemMatcherTest { + + @Test + fun `insufficient amount produces no removal plan`() { + val entries = listOf("first" to 2, "second" to 3) + + val plan = planRemoval(6, entries.asSequence()) { it.second } + + assertNull(plan) + assertEquals(listOf("first" to 2, "second" to 3), entries) + } + + @Test + fun `removal plan takes exact amount across stacks`() { + val entries = sequenceOf("first" to 3, "second" to 5) + + val plan = planRemoval(6, entries) { it.second } + + assertEquals( + listOf(RemovalPlan("first" to 3, 3), RemovalPlan("second" to 5, 3)), + plan + ) + } + + @Test + fun `non-positive amount produces an empty plan`() { + assertEquals(emptyList>(), planRemoval(0, sequenceOf(2, 3)) { it }) + } + + @Test + fun `planning stops after enough items are found`() { + var evaluations = 0 + val plan = planRemoval(4, sequenceOf(2, 3, 4)) { + evaluations++ + it + } + + assertEquals(2, evaluations) + assertEquals(listOf(2, 2), plan?.map { it.amount }) + } +} diff --git a/platform/platform-bukkit-impl/build.gradle.kts b/platform/platform-bukkit-impl/build.gradle.kts index 2044a3cab..ba010f351 100644 --- a/platform/platform-bukkit-impl/build.gradle.kts +++ b/platform/platform-bukkit-impl/build.gradle.kts @@ -19,6 +19,10 @@ dependencies { compileOnly("ink.ptms.core:v12110:12110:mapped") compileOnly("io.paper:folia-api:1.21.4") compileOnly("net.md-5:bungeecord-chat:1.20") + testImplementation(project(":common")) + testImplementation(project(":common-platform-api")) + testImplementation(project(":platform:platform-bukkit")) + testImplementation("io.paper:folia-api:1.21.4") // 用于处理命令 // ClassCastException: Cannot cast java.lang.String to net.kyori.adventure.text.Component diff --git a/platform/platform-bukkit-impl/src/main/kotlin/taboolib/platform/BukkitExecutor.kt b/platform/platform-bukkit-impl/src/main/kotlin/taboolib/platform/BukkitExecutor.kt index b6f81d5fd..9c3692a65 100644 --- a/platform/platform-bukkit-impl/src/main/kotlin/taboolib/platform/BukkitExecutor.kt +++ b/platform/platform-bukkit-impl/src/main/kotlin/taboolib/platform/BukkitExecutor.kt @@ -54,6 +54,12 @@ class BukkitExecutor : PlatformExecutor { } override fun submit(runnable: PlatformExecutor.PlatformRunnable): PlatformExecutor.PlatformTask { + if (Folia.isFolia && !runnable.now && !runnable.async) { + error( + "Context-free synchronous tasks are unsupported on Folia. " + + "Use Location.submit(), Entity.submit(), or an explicit global scheduler." + ) + } // 服务器已启动 val task = createRunningTask(runnable) return if (started) { @@ -133,39 +139,24 @@ class BukkitExecutor : PlatformExecutor { } override fun execute(async: Boolean, delay: Long, period: Long) { - scheduledTask = if (async) { - if (period < 1) { - if (delay < 1) { - FoliaExecutor.ASYNC_SCHEDULER.runNow(BukkitPlugin.getInstance()) { task -> - runnable.executor(BukkitPlatformTask { task.cancel() }) - } - } else { - FoliaExecutor.ASYNC_SCHEDULER.runDelayed(BukkitPlugin.getInstance(), { task -> - runnable.executor(BukkitPlatformTask { task.cancel() }) - }, delay.coerceAtLeast(1) * 50, TimeUnit.MILLISECONDS) - } - } else { - FoliaExecutor.ASYNC_SCHEDULER.runAtFixedRate(BukkitPlugin.getInstance(), { task -> + check(async) { + "Context-free synchronous tasks are unsupported on Folia. " + + "Use Location.submit(), Entity.submit(), or an explicit global scheduler." + } + scheduledTask = if (period < 1) { + if (delay < 1) { + FoliaExecutor.ASYNC_SCHEDULER.runNow(BukkitPlugin.getInstance()) { task -> runnable.executor(BukkitPlatformTask { task.cancel() }) - }, delay.coerceAtLeast(1) * 50, period * 50, TimeUnit.MILLISECONDS) - } - } else { - if (period < 1) { - // Delay ticks may not be <= 0, 蠢 - if (delay < 1) { - FoliaExecutor.GLOBAL_REGION_SCHEDULER.run(BukkitPlugin.getInstance()) { task -> - runnable.executor(BukkitPlatformTask { task.cancel() }) - } - } else { - FoliaExecutor.GLOBAL_REGION_SCHEDULER.runDelayed(BukkitPlugin.getInstance(), { task -> - runnable.executor(BukkitPlatformTask { task.cancel() }) - }, delay.coerceAtLeast(1)) } } else { - FoliaExecutor.GLOBAL_REGION_SCHEDULER.runAtFixedRate(BukkitPlugin.getInstance(), { task -> + FoliaExecutor.ASYNC_SCHEDULER.runDelayed(BukkitPlugin.getInstance(), { task -> runnable.executor(BukkitPlatformTask { task.cancel() }) - }, delay.coerceAtLeast(1), period) + }, delay.coerceAtLeast(1) * 50, TimeUnit.MILLISECONDS) } + } else { + FoliaExecutor.ASYNC_SCHEDULER.runAtFixedRate(BukkitPlugin.getInstance(), { task -> + runnable.executor(BukkitPlatformTask { task.cancel() }) + }, delay.coerceAtLeast(1) * 50, period * 50, TimeUnit.MILLISECONDS) } } diff --git a/platform/platform-bukkit-impl/src/main/kotlin/taboolib/platform/util/FoliaExecutor.kt b/platform/platform-bukkit-impl/src/main/kotlin/taboolib/platform/util/FoliaExecutor.kt index 5f04a690d..1dc38e7d5 100644 --- a/platform/platform-bukkit-impl/src/main/kotlin/taboolib/platform/util/FoliaExecutor.kt +++ b/platform/platform-bukkit-impl/src/main/kotlin/taboolib/platform/util/FoliaExecutor.kt @@ -15,7 +15,38 @@ import taboolib.platform.BukkitPlugin import taboolib.platform.Folia import taboolib.platform.FoliaExecutor import java.util.concurrent.CompletableFuture -import java.util.concurrent.ExecutionException + +/** + * 在 Bukkit 主线程或 Folia 全局区域线程执行不属于具体实体、区块或位置的任务。 + */ +@JvmOverloads +fun submitGlobal( + now: Boolean = false, + delay: Long = 0, + period: Long = 0, + executor: PlatformExecutor.PlatformTask.() -> Unit, +): PlatformExecutor.PlatformTask { + if (!Folia.isFolia) { + val runNow = now && Bukkit.isPrimaryThread() + return submitPlatform(runNow, false, if (now) 0 else delay, if (now) 0 else period, executor) + } + val scheduledTask = if (now || period < 1) { + if (now || delay < 1) { + FoliaExecutor.GLOBAL_REGION_SCHEDULER.run(BukkitPlugin.getInstance()) { task -> + executor(BukkitExecutor.BukkitPlatformTask { task.cancel() }) + } + } else { + FoliaExecutor.GLOBAL_REGION_SCHEDULER.runDelayed(BukkitPlugin.getInstance(), { task -> + executor(BukkitExecutor.BukkitPlatformTask { task.cancel() }) + }, delay.coerceAtLeast(1)) + } + } else { + FoliaExecutor.GLOBAL_REGION_SCHEDULER.runAtFixedRate(BukkitPlugin.getInstance(), { task -> + executor(BukkitExecutor.BukkitPlatformTask { task.cancel() }) + }, delay.coerceAtLeast(1), period) + } + return BukkitExecutor.BukkitPlatformTask { scheduledTask.cancel() } +} // ============================================ // Location 扩展函数 @@ -25,14 +56,27 @@ import java.util.concurrent.ExecutionException * 在指定位置所属的 Folia 区域线程中执行回调并返回结果。 */ fun Location.callRegion(executor: () -> T): T { - if (isOwnedByCurrentRegion()) { - return callDirect(executor) + check(isOwnedByCurrentRegion()) { + "The current thread does not own this location. Use Location.callRegionAsync(), runTask(), or submit() instead." } + return executor() +} + +/** + * 在指定位置所属线程中执行回调,并通过 Future 非阻塞返回结果。 + */ +fun Location.callRegionAsync(executor: () -> T): CompletableFuture { val future = CompletableFuture() - FoliaExecutor.REGION_SCHEDULER.run(BukkitPlugin.getInstance(), this) { + if (isOwnedByCurrentRegion()) { future.completeWith(executor) + } else if (Folia.isFolia) { + FoliaExecutor.REGION_SCHEDULER.run(BukkitPlugin.getInstance(), this) { + future.completeWith(executor) + } + } else { + submitPlatform { future.completeWith(executor) } } - return future.awaitResult() + return future } /** @@ -80,10 +124,11 @@ fun Location.submit( useScheduler: Boolean = true, executor: PlatformExecutor.PlatformTask.() -> Unit, ): PlatformExecutor.PlatformTask { - // 如果是异步执行、或不是 Folia 环境 + // 如果不是 Folia 环境 if (!Folia.isFolia) { return if (useScheduler || async) { - submitPlatform(now, async, delay, period, executor) + val runNow = now && (async || Bukkit.isPrimaryThread()) + submitPlatform(runNow, async, if (now) 0 else delay, if (now) 0 else period, executor) } else { val task = BukkitExecutor.BukkitPlatformTask { } if (now) { @@ -101,17 +146,17 @@ fun Location.submit( // Folia 环境下,使用 RegionScheduler 在指定位置执行 var scheduledTask: ScheduledTask? = null - if (now) { - // 立即执行 + if (now && isOwnedByCurrentRegion()) { + // 当前线程拥有该区域时立即执行 val task = BukkitExecutor.BukkitPlatformTask { scheduledTask?.cancel() } executor(task) return task } // 延迟或定时执行 - scheduledTask = if (period < 1) { + scheduledTask = if (now || period < 1) { // 单次执行 - if (delay < 1) { + if (now || delay < 1) { FoliaExecutor.REGION_SCHEDULER.run(BukkitPlugin.getInstance(), this) { task -> val platformTask = BukkitExecutor.BukkitPlatformTask { task.cancel() } executor(platformTask) @@ -141,19 +186,32 @@ fun Location.submit( * 在实体所属的 Folia 实体线程中执行回调并返回结果。 */ fun Entity.callRegion(executor: () -> T): T { - if (isOwnedByCurrentRegion()) { - return callDirect(executor) + check(isOwnedByCurrentRegion()) { + "The current thread does not own this entity. Use Entity.callRegionAsync(), runTask(), or submit() instead." } + return executor() +} + +/** + * 在实体所属线程中执行回调,并通过 Future 非阻塞返回结果。 + */ +fun Entity.callRegionAsync(executor: () -> T): CompletableFuture { val future = CompletableFuture() - val scheduledTask = FoliaExecutor.getEntityScheduler(this).run(BukkitPlugin.getInstance(), { + if (isOwnedByCurrentRegion()) { future.completeWith(executor) - }, { - future.completeExceptionally(IllegalStateException("Entity scheduler retired.")) - }) - if (scheduledTask == null && !future.isDone) { - future.completeExceptionally(IllegalStateException("Entity scheduler rejected task.")) + } else if (Folia.isFolia) { + val scheduledTask = FoliaExecutor.getEntityScheduler(this).run(BukkitPlugin.getInstance(), { + future.completeWith(executor) + }, { + future.completeExceptionally(IllegalStateException("Entity scheduler retired.")) + }) + if (scheduledTask == null && !future.isDone) { + future.completeExceptionally(IllegalStateException("Entity scheduler rejected task.")) + } + } else { + submitPlatform { future.completeWith(executor) } } - return future.awaitResult() + return future } /** @@ -202,10 +260,11 @@ fun Entity.submit( useScheduler: Boolean = true, executor: PlatformExecutor.PlatformTask.() -> Unit, ): PlatformExecutor.PlatformTask { - // 如果是异步执行、或不是 Folia 环境 + // 如果不是 Folia 环境 if (!Folia.isFolia) { return if (useScheduler || async) { - submitPlatform(now, async, delay, period, executor) + val runNow = now && (async || Bukkit.isPrimaryThread()) + submitPlatform(runNow, async, if (now) 0 else delay, if (now) 0 else period, executor) } else { val task = BukkitExecutor.BukkitPlatformTask { } if (now) { @@ -223,8 +282,8 @@ fun Entity.submit( // Folia 环境下,使用 Entity Scheduler var scheduledTask: ScheduledTask? = null - if (now) { - // 立即执行 + if (now && isOwnedByCurrentRegion()) { + // 当前线程拥有该实体时立即执行 val task = BukkitExecutor.BukkitPlatformTask { scheduledTask?.cancel() } executor(task) return task @@ -234,9 +293,9 @@ fun Entity.submit( val entityScheduler = FoliaExecutor.getEntityScheduler(this) // 延迟或定时执行 - scheduledTask = if (period < 1) { + scheduledTask = if (now || period < 1) { // 单次执行 - if (delay < 1) { + if (now || delay < 1) { entityScheduler.run(BukkitPlugin.getInstance(), { task -> val platformTask = BukkitExecutor.BukkitPlatformTask { task.cancel() } executor(platformTask) @@ -269,6 +328,13 @@ fun Block.callRegion(executor: () -> T): T { return location.callRegion(executor) } +/** + * 在方块所属线程中执行回调,并通过 Future 非阻塞返回结果。 + */ +fun Block.callRegionAsync(executor: () -> T): CompletableFuture { + return location.callRegionAsync(executor) +} + /** * 在方块所在位置执行一个任务(Folia 安全) * @@ -313,6 +379,13 @@ fun Chunk.callRegion(executor: () -> T): T { return Location(world, (x shl 4) + 8.0, 64.0, (z shl 4) + 8.0).callRegion(executor) } +/** + * 在区块中心所属线程中执行回调,并通过 Future 非阻塞返回结果。 + */ +fun Chunk.callRegionAsync(executor: () -> T): CompletableFuture { + return Location(world, (x shl 4) + 8.0, 64.0, (z shl 4) + 8.0).callRegionAsync(executor) +} + /** * 在区块中心位置执行一个任务(Folia 安全) * @@ -359,6 +432,13 @@ fun World.callRegion(x: Double, z: Double, executor: () -> T): T { return Location(this, x, 64.0, z).callRegion(executor) } +/** + * 在指定世界坐标所属线程中执行回调,并通过 Future 非阻塞返回结果。 + */ +fun World.callRegionAsync(x: Double, z: Double, executor: () -> T): CompletableFuture { + return Location(this, x, 64.0, z).callRegionAsync(executor) +} + /** * 在指定世界方块坐标所属的 Folia 区域线程中执行回调并返回结果。 */ @@ -366,6 +446,13 @@ fun World.callRegion(x: Int, y: Int, z: Int, executor: () -> T): T { return Location(this, x.toDouble(), y.toDouble(), z.toDouble()).callRegion(executor) } +/** + * 在指定世界方块坐标所属线程中执行回调,并通过 Future 非阻塞返回结果。 + */ +fun World.callRegionAsync(x: Int, y: Int, z: Int, executor: () -> T): CompletableFuture { + return Location(this, x.toDouble(), y.toDouble(), z.toDouble()).callRegionAsync(executor) +} + /** * 在指定世界坐标执行一个任务(Folia 安全) * @@ -407,26 +494,22 @@ fun World.submit( return location.submit(now, async, delay, period, useScheduler, executor) } -private fun callDirect(executor: () -> T): T { - return executor() -} - -private fun Location.isOwnedByCurrentRegion(): Boolean { +fun Location.isOwnedByCurrentRegion(): Boolean { if (!Folia.isFolia) { - return true + return Bukkit.isPrimaryThread() } return kotlin.runCatching { - Bukkit::class.java.invokeMethod("isOwnedByCurrentRegion", this, isStatic = true, remap = false) ?: true - }.getOrDefault(true) + Bukkit::class.java.invokeMethod("isOwnedByCurrentRegion", this, isStatic = true, remap = false) == true + }.getOrDefault(false) } -private fun Entity.isOwnedByCurrentRegion(): Boolean { +fun Entity.isOwnedByCurrentRegion(): Boolean { if (!Folia.isFolia) { - return true + return Bukkit.isPrimaryThread() } return kotlin.runCatching { - Bukkit::class.java.invokeMethod("isOwnedByCurrentRegion", this, isStatic = true, remap = false) ?: true - }.getOrDefault(true) + Bukkit::class.java.invokeMethod("isOwnedByCurrentRegion", this, isStatic = true, remap = false) == true + }.getOrDefault(false) } private fun CompletableFuture.completeWith(executor: () -> T) { @@ -436,21 +519,3 @@ private fun CompletableFuture.completeWith(executor: () -> T) { completeExceptionally(throwable) } } - -private fun CompletableFuture.awaitResult(): T { - try { - return get() - } catch (exception: InterruptedException) { - Thread.currentThread().interrupt() - throw RuntimeException(exception) - } catch (exception: ExecutionException) { - val cause = exception.cause - if (cause is RuntimeException) { - throw cause - } - if (cause is Error) { - throw cause - } - throw RuntimeException(cause) - } -} diff --git a/platform/platform-bukkit-impl/src/test/kotlin/taboolib/platform/BukkitExecutorTest.kt b/platform/platform-bukkit-impl/src/test/kotlin/taboolib/platform/BukkitExecutorTest.kt new file mode 100644 index 000000000..2dc0e14ff --- /dev/null +++ b/platform/platform-bukkit-impl/src/test/kotlin/taboolib/platform/BukkitExecutorTest.kt @@ -0,0 +1,52 @@ +package taboolib.platform + +import org.junit.jupiter.api.Assertions.assertDoesNotThrow +import org.junit.jupiter.api.Assertions.assertThrows +import org.junit.jupiter.api.Test +import taboolib.common.platform.service.PlatformExecutor + +class BukkitExecutorTest { + + @Test + fun `context-free synchronous task is rejected before enqueue on Folia`() { + withFolia { + val executor = BukkitExecutor() + assertThrows(IllegalStateException::class.java) { + executor.submit(runnable(now = false, async = false)) + } + } + } + + @Test + fun `asynchronous and immediate tasks keep their existing entry points on Folia`() { + withFolia { + val executor = BukkitExecutor() + assertDoesNotThrow { executor.submit(runnable(now = false, async = true)) } + assertDoesNotThrow { executor.submit(runnable(now = true, async = false)) } + } + } + + @Test + fun `Folia running task cannot bypass synchronous context check`() { + withFolia { + val task = BukkitExecutor.FoliaRunningTask(runnable(now = false, async = false)) + assertThrows(IllegalStateException::class.java) { + task.execute(async = false, delay = 0, period = 0) + } + } + } + + private fun runnable(now: Boolean, async: Boolean): PlatformExecutor.PlatformRunnable { + return PlatformExecutor.PlatformRunnable(now, async, 0, 0) {} + } + + private fun withFolia(block: () -> Unit) { + val previous = Folia.isFolia + Folia.isFolia = true + try { + block() + } finally { + Folia.isFolia = previous + } + } +} diff --git a/platform/platform-bukkit/src/main/java/taboolib/platform/BukkitPlugin.java b/platform/platform-bukkit/src/main/java/taboolib/platform/BukkitPlugin.java index caeea9d3c..2e1fbfb17 100644 --- a/platform/platform-bukkit/src/main/java/taboolib/platform/BukkitPlugin.java +++ b/platform/platform-bukkit/src/main/java/taboolib/platform/BukkitPlugin.java @@ -110,7 +110,7 @@ public void onEnable() { if (!TabooLib.isStopped()) { // 创建调度器,执行 onActive() 方法 if (Folia.isFolia) { - FoliaExecutor.ASYNC_SCHEDULER.runNow(this, task -> invokeActive()); + FoliaExecutor.GLOBAL_REGION_SCHEDULER.run(this, task -> invokeActive()); } else { Bukkit.getScheduler().runTask(this, this::invokeActive); } diff --git a/platform/platform-bukkit/src/main/java/taboolib/platform/FoliaExecutor.java b/platform/platform-bukkit/src/main/java/taboolib/platform/FoliaExecutor.java index e5727f54e..e855c15a1 100644 --- a/platform/platform-bukkit/src/main/java/taboolib/platform/FoliaExecutor.java +++ b/platform/platform-bukkit/src/main/java/taboolib/platform/FoliaExecutor.java @@ -53,4 +53,11 @@ public class FoliaExecutor { public static EntityScheduler getEntityScheduler(final Entity entity) throws InvocationTargetException, IllegalAccessException { return (EntityScheduler) GET_ENTITY_SCHEDULER.invoke(entity); } + + /** + * 在 Folia 全局区域线程执行不属于具体实体或位置的任务。 + */ + public static void runGlobal(final Runnable runnable) { + GLOBAL_REGION_SCHEDULER.run(BukkitPlugin.getInstance(), task -> runnable.run()); + } } From 229d4dcd2a21707269c42aa7817b531f9dd60516 Mon Sep 17 00:00:00 2001 From: zhibei <785740487@qq.com> Date: Tue, 14 Jul 2026 01:44:02 +0800 Subject: [PATCH 18/37] =?UTF-8?q?fix(porticus):=20=E4=BF=AE=E5=A4=8D?= =?UTF-8?q?=E5=8F=91=E9=80=81=E7=BA=BF=E7=A8=8B=E4=B8=8E=E5=88=86=E5=8C=85?= =?UTF-8?q?=E7=AB=9E=E6=80=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../minecraft-porticus/build.gradle.kts | 4 + .../module/porticus/PorticusMission.java | 31 +- .../porticus/bukkitside/MissionBukkit.java | 95 ++++- .../porticus/bukkitside/PorticusListener.java | 73 +++- .../porticus/bungeeside/MissionBungee.java | 176 +++++++- .../porticus/bungeeside/PorticusListener.java | 97 +++-- .../module/porticus/common/ByteUtils.java | 16 +- .../module/porticus/common/Message.java | 245 ++++++++++- .../porticus/common/MessageBuilder.java | 41 +- .../module/porticus/common/MessagePacket.java | 12 + .../module/porticus/common/MessageReader.java | 395 +++++++++++++++++- .../taboolib/module/porticus/Porticus.kt | 8 + .../module/porticus/PorticusMissionTest.java | 124 ++++++ .../porticus/common/MessageProtocolTest.java | 331 +++++++++++++++ 14 files changed, 1542 insertions(+), 106 deletions(-) create mode 100644 module/minecraft/minecraft-porticus/src/test/java/taboolib/module/porticus/PorticusMissionTest.java create mode 100644 module/minecraft/minecraft-porticus/src/test/java/taboolib/module/porticus/common/MessageProtocolTest.java diff --git a/module/minecraft/minecraft-porticus/build.gradle.kts b/module/minecraft/minecraft-porticus/build.gradle.kts index 387b0075e..6f683bf05 100644 --- a/module/minecraft/minecraft-porticus/build.gradle.kts +++ b/module/minecraft/minecraft-porticus/build.gradle.kts @@ -5,4 +5,8 @@ dependencies { compileOnly(project(":common-util")) compileOnly(project(":common-env")) compileOnly(project(":common-platform-api")) + testImplementation(project(":common")) + testImplementation(project(":common-util")) + testImplementation(project(":common-platform-api")) + testImplementation("ink.ptms.core:v12004:12004-minimize:mapped") } \ No newline at end of file diff --git a/module/minecraft/minecraft-porticus/src/main/java/taboolib/module/porticus/PorticusMission.java b/module/minecraft/minecraft-porticus/src/main/java/taboolib/module/porticus/PorticusMission.java index 73b218555..16d06df76 100644 --- a/module/minecraft/minecraft-porticus/src/main/java/taboolib/module/porticus/PorticusMission.java +++ b/module/minecraft/minecraft-porticus/src/main/java/taboolib/module/porticus/PorticusMission.java @@ -6,6 +6,7 @@ import java.util.UUID; import java.util.concurrent.TimeUnit; import java.util.function.Consumer; +import java.util.function.LongSupplier; /** * Porticus @@ -24,7 +25,9 @@ public abstract class PorticusMission { protected Runnable runnable; protected String[] command; protected long timeout = TimeUnit.SECONDS.toMillis(10); - private long start; + private volatile long start; + private volatile boolean started; + LongSupplier timeSource = System::currentTimeMillis; public PorticusMission() { this(UUID.randomUUID()); @@ -38,7 +41,8 @@ public PorticusMission(UUID uid) { * 通讯任务是否超时 */ public boolean isTimeout() { - return start + timeout < System.currentTimeMillis(); + long startedAt = start; + return started && timeSource.getAsLong() - startedAt >= timeout; } /** @@ -46,11 +50,26 @@ public boolean isTimeout() { * * @param target 发送目标,根据服务端类型传入对应玩家对象,当 API 类型为 SERVER 时传入 ProxyPlayer 类型,为 CLIENT 时则传入 Player 类型。 */ - public void run(@NotNull Object target) { - if (consumer != null || runnable != null) { - Porticus.INSTANCE.getMissions().add(this); + public synchronized void run(@NotNull Object target) { + if (started) { + throw new IllegalStateException("Porticus missions can only be run once"); + } + boolean trackCompletion = consumer != null || runnable != null; + if (trackCompletion) { + synchronized (Porticus.INSTANCE.getMissions()) { + for (PorticusMission mission : Porticus.INSTANCE.getMissions()) { + if (mission.getUID().equals(uid)) { + throw new IllegalStateException("A Porticus mission with the same UID is already pending"); + } + } + this.start = timeSource.getAsLong(); + this.started = true; + Porticus.INSTANCE.getMissions().add(this); + } + } else { + this.start = timeSource.getAsLong(); + this.started = true; } - this.start = System.currentTimeMillis(); } /** diff --git a/module/minecraft/minecraft-porticus/src/main/java/taboolib/module/porticus/bukkitside/MissionBukkit.java b/module/minecraft/minecraft-porticus/src/main/java/taboolib/module/porticus/bukkitside/MissionBukkit.java index 88d135c0d..cbdfbe259 100644 --- a/module/minecraft/minecraft-porticus/src/main/java/taboolib/module/porticus/bukkitside/MissionBukkit.java +++ b/module/minecraft/minecraft-porticus/src/main/java/taboolib/module/porticus/bukkitside/MissionBukkit.java @@ -10,7 +10,10 @@ import taboolib.module.porticus.common.MessageBuilder; import java.io.IOException; +import java.lang.reflect.Method; +import java.util.List; import java.util.UUID; +import java.util.function.Consumer; /** * Porticus @@ -32,23 +35,97 @@ public MissionBukkit(UUID uid) { @Override public void run(@NotNull Object target) { - super.run(target); - if (target instanceof Player) { - sendBukkitMessage((Player) target, command); - } else { + if (!(target instanceof Player)) { throw new IllegalStateException("target must be Player"); } + if (command == null) { + throw new IllegalStateException("command must be set before running mission"); + } + List messages; + try { + messages = MessageBuilder.create(command); + } catch (IOException e) { + throw new IllegalStateException("failed to encode mission command", e); + } + boolean tracked = consumer != null || runnable != null; + super.run(target); + try { + scheduleBukkitMessage((Player) target, messages, tracked); + } catch (Throwable t) { + Porticus.INSTANCE.getMissions().remove(this); + throw new IllegalStateException("failed to schedule mission message", t); + } } public void sendBukkitMessage(Player player, String[] command) { - Bukkit.getScheduler().runTaskAsynchronously(plugin, () -> { + try { + if (player == null) { + throw new IllegalArgumentException("player cannot be null"); + } + scheduleBukkitMessage(player, MessageBuilder.create(command), false); + } catch (Throwable t) { + t.printStackTrace(); + } + } + + private void scheduleBukkitMessage(Player player, List messages, boolean tracked) throws Exception { + Runnable failure = tracked ? () -> Porticus.INSTANCE.getMissions().remove(this) : () -> { + }; + Runnable sendTask = () -> { + if (tracked && !Porticus.INSTANCE.getMissions().contains(this)) { + return; + } try { - for (byte[] bytes : MessageBuilder.create(command)) { + for (byte[] bytes : messages) { + if (tracked && !Porticus.INSTANCE.getMissions().contains(this)) { + return; + } player.sendPluginMessage(plugin, Porticus.INSTANCE.getChannelId(), bytes); } - } catch (IOException e) { - e.printStackTrace(); + } catch (Throwable t) { + failure.run(); + t.printStackTrace(); + } + }; + if (isFolia()) { + runOnEntityScheduler(player, sendTask, failure); + } else if (Bukkit.isPrimaryThread()) { + sendTask.run(); + } else { + Bukkit.getScheduler().runTask(plugin, sendTask); + } + } + + private static boolean isFolia() { + try { + Class.forName("io.papermc.paper.threadedregions.RegionizedServer", false, playerClassLoader()); + return true; + } catch (Throwable ignored) { + return false; + } + } + + private static ClassLoader playerClassLoader() { + ClassLoader classLoader = Player.class.getClassLoader(); + return classLoader == null ? ClassLoader.getSystemClassLoader() : classLoader; + } + + private void runOnEntityScheduler(Player player, Runnable sendTask, Runnable retired) throws Exception { + Object scheduler = player.getClass().getMethod("getScheduler").invoke(player); + Method runMethod = null; + for (Method method : scheduler.getClass().getMethods()) { + if (method.getName().equals("run") && method.getParameterTypes().length == 3) { + runMethod = method; + break; } - }); + } + if (runMethod == null) { + throw new NoSuchMethodException("EntityScheduler#run"); + } + Consumer task = ignored -> sendTask.run(); + Object scheduled = runMethod.invoke(scheduler, plugin, task, retired); + if (scheduled == null) { + throw new IllegalStateException("EntityScheduler rejected Porticus message task"); + } } } diff --git a/module/minecraft/minecraft-porticus/src/main/java/taboolib/module/porticus/bukkitside/PorticusListener.java b/module/minecraft/minecraft-porticus/src/main/java/taboolib/module/porticus/bukkitside/PorticusListener.java index 17212d43c..58e9883c3 100644 --- a/module/minecraft/minecraft-porticus/src/main/java/taboolib/module/porticus/bukkitside/PorticusListener.java +++ b/module/minecraft/minecraft-porticus/src/main/java/taboolib/module/porticus/bukkitside/PorticusListener.java @@ -15,6 +15,9 @@ import taboolib.module.porticus.common.MessageReader; import java.io.IOException; +import java.lang.reflect.Method; +import java.util.concurrent.atomic.AtomicLong; +import java.util.function.Consumer; /** * @author 坏黑 @@ -23,14 +26,17 @@ @SuppressWarnings("DuplicatedCode") public class PorticusListener implements Listener, PluginMessageListener { + private final Plugin plugin; + private final AtomicLong nextCacheWarning = new AtomicLong(); + public PorticusListener() { - Plugin plugin = JavaPlugin.getProvidingPlugin(Porticus.class); + plugin = JavaPlugin.getProvidingPlugin(Porticus.class); Bukkit.getPluginManager().registerEvents(this, plugin); Bukkit.getMessenger().registerIncomingPluginChannel(plugin, Porticus.INSTANCE.getChannelId(), this); Bukkit.getMessenger().registerOutgoingPluginChannel(plugin, Porticus.INSTANCE.getChannelId()); - Bukkit.getScheduler().runTaskTimer(plugin, () -> { + Runnable timeoutTask = () -> { for (PorticusMission mission : Porticus.INSTANCE.getMissions()) { - if (mission.isTimeout()) { + if (mission.isTimeout() && Porticus.INSTANCE.getMissions().remove(mission)) { if (mission.getTimeoutRunnable() != null) { try { mission.getTimeoutRunnable().run(); @@ -38,16 +44,21 @@ public PorticusListener() { t.printStackTrace(); } } - Porticus.INSTANCE.getMissions().remove(mission); } } - }, 0, 20); + MessageReader.cleanUp(); + }; + if (isFolia()) { + runGlobalTimer(plugin, timeoutTask); + } else { + Bukkit.getScheduler().runTaskTimer(plugin, timeoutTask, 0, 20); + } } @EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true) public void e(PorticusBukkitEvent e) { for (PorticusMission mission : Porticus.INSTANCE.getMissions()) { - if (mission.getUID().equals(e.getUID())) { + if (mission.getUID().equals(e.getUID()) && Porticus.INSTANCE.getMissions().remove(mission)) { if (mission.getResponseConsumer() != null) { try { mission.getResponseConsumer().accept(e.getArgs()); @@ -55,7 +66,7 @@ public void e(PorticusBukkitEvent e) { t.printStackTrace(); } } - Porticus.INSTANCE.getMissions().remove(mission); + break; } } } @@ -66,11 +77,57 @@ public void onPluginMessageReceived(@NotNull String channel, @NotNull Player pla try { Message message = MessageReader.read(bytes); if (message.isCompleted()) { - PorticusBukkitEvent.call(player, message.getMessages().get(0).getUID(), message.build()); + String[] args = message.buildOnce(); + if (args != null) { + PorticusBukkitEvent.call(player, message.getUID(), args); + } } + } catch (MessageReader.ProtocolException ignored) { + // Malformed or oversized plugin messages are rejected without flooding the server log. + } catch (MessageReader.CapacityException ex) { + warnCacheCapacity(ex); } catch (IOException ex) { ex.printStackTrace(); + } catch (Throwable t) { + t.printStackTrace(); + } + } + } + + private void warnCacheCapacity(IOException exception) { + long now = System.currentTimeMillis(); + long next = nextCacheWarning.get(); + if (now >= next && nextCacheWarning.compareAndSet(next, now + 10_000)) { + plugin.getLogger().warning("Porticus message cache rejected input: " + exception.getMessage()); + } + } + + private static boolean isFolia() { + try { + Class.forName("io.papermc.paper.threadedregions.RegionizedServer"); + return true; + } catch (Throwable ignored) { + return false; + } + } + + private static void runGlobalTimer(Plugin plugin, Runnable runnable) { + try { + Object scheduler = Bukkit.class.getMethod("getGlobalRegionScheduler").invoke(null); + Method runAtFixedRate = null; + for (Method method : scheduler.getClass().getMethods()) { + if (method.getName().equals("runAtFixedRate") && method.getParameterTypes().length == 4) { + runAtFixedRate = method; + break; + } + } + if (runAtFixedRate == null) { + throw new NoSuchMethodException("GlobalRegionScheduler#runAtFixedRate"); } + Consumer task = ignored -> runnable.run(); + runAtFixedRate.invoke(scheduler, plugin, task, 1L, 20L); + } catch (Throwable t) { + throw new IllegalStateException("Unable to schedule Porticus timeout task on Folia", t); } } } diff --git a/module/minecraft/minecraft-porticus/src/main/java/taboolib/module/porticus/bungeeside/MissionBungee.java b/module/minecraft/minecraft-porticus/src/main/java/taboolib/module/porticus/bungeeside/MissionBungee.java index df5ea82e9..9fbe04b68 100644 --- a/module/minecraft/minecraft-porticus/src/main/java/taboolib/module/porticus/bungeeside/MissionBungee.java +++ b/module/minecraft/minecraft-porticus/src/main/java/taboolib/module/porticus/bungeeside/MissionBungee.java @@ -5,12 +5,14 @@ import net.md_5.bungee.api.connection.ProxiedPlayer; import net.md_5.bungee.api.connection.Server; import net.md_5.bungee.api.plugin.Plugin; +import net.md_5.bungee.api.scheduler.ScheduledTask; import org.jetbrains.annotations.NotNull; import taboolib.module.porticus.Porticus; import taboolib.module.porticus.PorticusMission; import taboolib.module.porticus.common.MessageBuilder; import java.io.IOException; +import java.util.List; import java.util.UUID; /** @@ -22,8 +24,6 @@ */ public class MissionBungee extends PorticusMission { - private static final Plugin plugin = BungeeCord.getInstance().pluginManager.getPlugins().iterator().next(); - public MissionBungee() { super(); } @@ -34,35 +34,173 @@ public MissionBungee(UUID uid) { @Override public void run(@NotNull Object target) { + if (command == null) { + throw new IllegalStateException("command must be set before running mission"); + } + boolean tracked = consumer != null || runnable != null; + MessageTarget messageTarget = resolveTarget(target, tracked); + Plugin plugin = getPlugin(); + List messages; + try { + messages = MessageBuilder.create(command); + } catch (IOException e) { + throw new IllegalStateException("failed to encode mission command", e); + } super.run(target); - if (target instanceof Server) { - sendBungeeMessage((Server) target, command); - } else if (target instanceof ServerInfo) { - sendBungeeMessage((ServerInfo) target, command); - } else if (target instanceof ProxiedPlayer) { - sendBungeeMessage((ProxiedPlayer) target, command); - } else { - throw new IllegalStateException("target must be Server or ProxiedPlayer"); + try { + ScheduledTask task = BungeeCord.getInstance().getScheduler().runAsync(plugin, () -> { + if (tracked && !Porticus.INSTANCE.getMissions().contains(this)) { + return; + } + try { + sendMessages(messageTarget, messages, true); + } catch (Throwable t) { + Porticus.INSTANCE.getMissions().remove(this); + t.printStackTrace(); + } + }); + if (task == null) { + throw new IllegalStateException("Bungee scheduler rejected Porticus message task"); + } + } catch (Throwable t) { + Porticus.INSTANCE.getMissions().remove(this); + throw new IllegalStateException("failed to schedule mission message", t); } } public static void sendBungeeMessage(ProxiedPlayer player, String... args) { - sendBungeeMessage(player.getServer(), args); + sendStandalone(resolvePlayer(player), args); } public static void sendBungeeMessage(Server server, String... args) { - sendBungeeMessage(server.getInfo(), args); + sendStandalone(resolveServer(server), args); } public static void sendBungeeMessage(ServerInfo server, String... args) { - BungeeCord.getInstance().getScheduler().runAsync(plugin, () -> { - try { - for (byte[] bytes : MessageBuilder.create(args)) { - server.sendData(Porticus.INSTANCE.getChannelId(), bytes); + sendStandalone(resolveServerInfo(server), args); + } + + private static void sendStandalone(MessageTarget target, String[] args) { + try { + Plugin plugin = getPlugin(); + List messages = MessageBuilder.create(args); + ScheduledTask task = BungeeCord.getInstance().getScheduler().runAsync(plugin, () -> { + try { + sendMessages(target, messages, false); + } catch (Throwable t) { + t.printStackTrace(); } - } catch (IOException e) { - e.printStackTrace(); + }); + if (task == null) { + throw new IllegalStateException("Bungee scheduler rejected Porticus message task"); + } + } catch (Throwable t) { + t.printStackTrace(); + } + } + + private static void sendMessages(MessageTarget target, List messages, boolean mission) { + for (byte[] bytes : messages) { + if (mission && target instanceof MissionTarget && !((MissionTarget) target).missionPending()) { + return; } - }); + target.send(bytes); + } + } + + private MessageTarget resolveTarget(Object target, boolean tracked) { + MessageTarget resolved; + if (target instanceof Server) { + resolved = resolveServer((Server) target); + } else if (target instanceof ServerInfo) { + resolved = resolveServerInfo((ServerInfo) target); + } else if (target instanceof ProxiedPlayer) { + resolved = resolvePlayer((ProxiedPlayer) target); + } else { + throw new IllegalStateException("target must be Server, ServerInfo or ProxiedPlayer"); + } + return new MissionTarget(resolved, tracked); + } + + private static MessageTarget resolvePlayer(ProxiedPlayer player) { + if (player == null) { + throw new IllegalArgumentException("player cannot be null"); + } + Server connection = player.getServer(); + if (connection == null || !connection.isConnected()) { + throw new IllegalStateException("target player is not connected to a server"); + } + return bytes -> { + if (player.getServer() != connection || !connection.isConnected()) { + throw new IllegalStateException("target player changed server before Porticus message was sent"); + } + connection.sendData(Porticus.INSTANCE.getChannelId(), bytes); + }; + } + + private static MessageTarget resolveServer(Server server) { + if (server == null) { + throw new IllegalArgumentException("server cannot be null"); + } + if (server.getInfo() == null || !server.isConnected()) { + throw new IllegalStateException("target server connection is closed"); + } + return bytes -> { + if (!server.isConnected()) { + throw new IllegalStateException("target server connection is closed"); + } + server.sendData(Porticus.INSTANCE.getChannelId(), bytes); + }; + } + + private static MessageTarget resolveServerInfo(ServerInfo server) { + if (server == null) { + throw new IllegalArgumentException("server cannot be null"); + } + if (server.getPlayers().isEmpty()) { + throw new IllegalStateException("target server has no active player connection"); + } + return bytes -> { + if (!server.sendData(Porticus.INSTANCE.getChannelId(), bytes, false)) { + throw new IllegalStateException("target server has no active player connection"); + } + }; + } + + private static Plugin getPlugin() { + try { + Object instance = Class.forName("taboolib.platform.BungeePlugin").getMethod("getInstance").invoke(null); + if (instance instanceof Plugin) { + return (Plugin) instance; + } + } catch (Throwable t) { + throw new IllegalStateException("TabooLib BungeePlugin is not available", t); + } + throw new IllegalStateException("TabooLib BungeePlugin is not available"); + } + + private interface MessageTarget { + + void send(byte[] bytes); + } + + private final class MissionTarget implements MessageTarget { + + private final MessageTarget delegate; + private final boolean tracked; + + private MissionTarget(MessageTarget delegate, boolean tracked) { + this.delegate = delegate; + this.tracked = tracked; + } + + @Override + public void send(byte[] bytes) { + delegate.send(bytes); + } + + private boolean missionPending() { + return !tracked || Porticus.INSTANCE.getMissions().contains(MissionBungee.this); + } } } diff --git a/module/minecraft/minecraft-porticus/src/main/java/taboolib/module/porticus/bungeeside/PorticusListener.java b/module/minecraft/minecraft-porticus/src/main/java/taboolib/module/porticus/bungeeside/PorticusListener.java index 86fb2e5a4..871f38db4 100644 --- a/module/minecraft/minecraft-porticus/src/main/java/taboolib/module/porticus/bungeeside/PorticusListener.java +++ b/module/minecraft/minecraft-porticus/src/main/java/taboolib/module/porticus/bungeeside/PorticusListener.java @@ -16,6 +16,7 @@ import java.io.IOException; import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicLong; /** * @author Bkm016 @@ -24,20 +25,26 @@ @SuppressWarnings("DuplicatedCode") public class PorticusListener implements Listener { - private static final Plugin plugin = BungeeCord.getInstance().pluginManager.getPlugins().iterator().next(); + private final Plugin plugin; + private final AtomicLong nextCacheWarning = new AtomicLong(); public PorticusListener() { + plugin = getPlugin(); ProxyServer.getInstance().registerChannel(Porticus.INSTANCE.getChannelId()); ProxyServer.getInstance().getPluginManager().registerListener(plugin, this); BungeeCord.getInstance().getScheduler().schedule(plugin, () -> { for (PorticusMission mission : Porticus.INSTANCE.getMissions()) { - if (!mission.isTimeout()) { + if (mission.isTimeout() && Porticus.INSTANCE.getMissions().remove(mission)) { if (mission.getTimeoutRunnable() != null) { - mission.getTimeoutRunnable().run(); + try { + mission.getTimeoutRunnable().run(); + } catch (Throwable t) { + t.printStackTrace(); + } } - Porticus.INSTANCE.getMissions().remove(mission); } } + MessageReader.cleanUp(); }, 1, 1, TimeUnit.SECONDS); } @@ -46,19 +53,41 @@ public void e(PorticusBungeeEvent e) { if (e.isCancelled()) { return; } - if (e.get(0).equals("porticus")) { - switch (e.get(1)) { + try { + for (PorticusMission mission : Porticus.INSTANCE.getMissions()) { + if (mission.getUID().equals(e.getUID()) && Porticus.INSTANCE.getMissions().remove(mission)) { + if (mission.getResponseConsumer() != null) { + try { + mission.getResponseConsumer().accept(e.getArgs()); + } catch (Throwable t) { + t.printStackTrace(); + } + } + return; + } + } + String[] args = e.getArgs(); + if (args.length < 2 || !"porticus".equals(args[0])) { + return; + } + switch (args[1]) { case "connect": { - ProxiedPlayer proxiedPlayer = ProxyServer.getInstance().getPlayer(e.get(2)); - ServerInfo serverInfo = ProxyServer.getInstance().getServerInfo(e.get(3)); + if (args.length < 4) { + return; + } + ProxiedPlayer proxiedPlayer = ProxyServer.getInstance().getPlayer(args[2]); + ServerInfo serverInfo = ProxyServer.getInstance().getServerInfo(args[3]); if (proxiedPlayer != null && serverInfo != null) { proxiedPlayer.connect(serverInfo); } break; } case "whois": { - ProxiedPlayer proxiedPlayer = ProxyServer.getInstance().getPlayer(e.get(2)); - if (proxiedPlayer != null) { + if (args.length < 3) { + return; + } + ProxiedPlayer proxiedPlayer = ProxyServer.getInstance().getPlayer(args[2]); + if (proxiedPlayer != null && proxiedPlayer.getServer() != null) { e.response(proxiedPlayer.getServer().getInfo().getName()); } break; @@ -66,19 +95,8 @@ public void e(PorticusBungeeEvent e) { default: break; } - } else { - for (PorticusMission mission : Porticus.INSTANCE.getMissions()) { - if (mission.getUID().equals(e.getUID())) { - if (mission.getResponseConsumer() != null) { - try { - mission.getResponseConsumer().accept(e.getArgs()); - } catch (Throwable t) { - t.printStackTrace(); - } - } - Porticus.INSTANCE.getMissions().remove(mission); - } - } + } catch (Throwable t) { + t.printStackTrace(); } } @@ -87,15 +105,44 @@ public void e(PluginMessageEvent e) { if (e.isCancelled()) { return; } - if (e.getSender() instanceof Server && e.getTag().equalsIgnoreCase(Porticus.INSTANCE.getChannelId())) { + if (e.getSender() instanceof Server && e.getReceiver() instanceof ProxiedPlayer && e.getTag().equalsIgnoreCase(Porticus.INSTANCE.getChannelId())) { try { Message message = MessageReader.read(e.getData()); if (message.isCompleted()) { - PorticusBungeeEvent.call((Server) e.getSender(), message.getMessages().get(0).getUID(), message.build()); + String[] args = message.buildOnce(); + if (args != null) { + PorticusBungeeEvent.call((Server) e.getSender(), message.getUID(), args); + } } + } catch (MessageReader.ProtocolException ignored) { + // Malformed or oversized plugin messages are rejected without flooding the proxy log. + } catch (MessageReader.CapacityException ex) { + warnCacheCapacity(ex); } catch (IOException ex) { ex.printStackTrace(); + } catch (Throwable t) { + t.printStackTrace(); + } + } + } + + private void warnCacheCapacity(IOException exception) { + long now = System.currentTimeMillis(); + long next = nextCacheWarning.get(); + if (now >= next && nextCacheWarning.compareAndSet(next, now + 10_000)) { + plugin.getLogger().warning("Porticus message cache rejected input: " + exception.getMessage()); + } + } + + private static Plugin getPlugin() { + try { + Object instance = Class.forName("taboolib.platform.BungeePlugin").getMethod("getInstance").invoke(null); + if (instance instanceof Plugin) { + return (Plugin) instance; } + } catch (Throwable t) { + throw new IllegalStateException("TabooLib BungeePlugin is not available", t); } + throw new IllegalStateException("TabooLib BungeePlugin is not available"); } } diff --git a/module/minecraft/minecraft-porticus/src/main/java/taboolib/module/porticus/common/ByteUtils.java b/module/minecraft/minecraft-porticus/src/main/java/taboolib/module/porticus/common/ByteUtils.java index c80c669d1..e89f76274 100644 --- a/module/minecraft/minecraft-porticus/src/main/java/taboolib/module/porticus/common/ByteUtils.java +++ b/module/minecraft/minecraft-porticus/src/main/java/taboolib/module/porticus/common/ByteUtils.java @@ -1,5 +1,8 @@ package taboolib.module.porticus.common; +import java.nio.ByteBuffer; +import java.nio.charset.CharacterCodingException; +import java.nio.charset.CodingErrorAction; import java.nio.charset.StandardCharsets; import java.util.Base64; @@ -14,7 +17,16 @@ public static String serialize(String var) { } public static String deSerialize(String var) { - return new String(Base64.getDecoder().decode(var), StandardCharsets.UTF_8); + byte[] decoded = Base64.getDecoder().decode(var); + try { + return StandardCharsets.UTF_8.newDecoder() + .onMalformedInput(CodingErrorAction.REPORT) + .onUnmappableCharacter(CodingErrorAction.REPORT) + .decode(ByteBuffer.wrap(decoded)) + .toString(); + } catch (CharacterCodingException ex) { + throw new IllegalArgumentException("Serialized value is not valid UTF-8", ex); + } } public static String[] serialize(String... var) { @@ -28,7 +40,7 @@ public static String[] serialize(String... var) { public static String[] deSerialize(String... var) { String[] varEncode = new String[var.length]; for (int i = 0; i < var.length; i++) { - varEncode[i] = new String(Base64.getDecoder().decode(var[i]), StandardCharsets.UTF_8); + varEncode[i] = deSerialize(var[i]); } return varEncode; } diff --git a/module/minecraft/minecraft-porticus/src/main/java/taboolib/module/porticus/common/Message.java b/module/minecraft/minecraft-porticus/src/main/java/taboolib/module/porticus/common/Message.java index be855bdae..9a9b37b75 100644 --- a/module/minecraft/minecraft-porticus/src/main/java/taboolib/module/porticus/common/Message.java +++ b/module/minecraft/minecraft-porticus/src/main/java/taboolib/module/porticus/common/Message.java @@ -2,11 +2,18 @@ import com.google.common.collect.Lists; import com.google.gson.JsonArray; +import com.google.gson.JsonElement; import com.google.gson.JsonParser; import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; +import java.util.AbstractList; import java.util.Comparator; +import java.util.HashSet; import java.util.List; +import java.util.Set; +import java.util.UUID; +import java.util.concurrent.atomic.AtomicBoolean; /** * 通讯信息容器 @@ -17,32 +24,250 @@ public class Message { private final List messages = Lists.newCopyOnWriteArrayList(); + private final List exposedMessages = new AbstractList() { + @Override + public MessagePacket get(int index) { + return messages.get(index); + } + + @Override + public int size() { + return messages.size(); + } + + @Override + public MessagePacket set(int index, MessagePacket element) { + synchronized (Message.this) { + MessagePacket previous = messages.set(index, element); + invalidateDecodedArguments(); + return previous; + } + } + + @Override + public void add(int index, MessagePacket element) { + synchronized (Message.this) { + messages.add(index, element); + invalidateDecodedArguments(); + } + } + + @Override + public MessagePacket remove(int index) { + synchronized (Message.this) { + MessagePacket removed = messages.remove(index); + invalidateDecodedArguments(); + return removed; + } + } + + @Override + public void clear() { + synchronized (Message.this) { + if (!messages.isEmpty()) { + messages.clear(); + invalidateDecodedArguments(); + } + } + } + }; + private final AtomicBoolean built = new AtomicBoolean(); + private final long createdAt; + private volatile long lastAccess; + private volatile long completedAt; + private volatile String[] decodedArguments; + private long cachedBytes; + + public Message() { + this(System.nanoTime()); + } + + Message(long createdAt) { + this.createdAt = createdAt; + this.lastAccess = createdAt; + } /** * 构建为可读取的通讯内容 */ @NotNull public String[] build() { - StringBuilder builder = new StringBuilder(); - messages.sort(Comparator.comparingInt(MessagePacket::getIndex)); - messages.forEach(message -> builder.append(message.getData())); - JsonArray json = new JsonParser().parse(ByteUtils.deSerialize(builder.toString())).getAsJsonArray(); - String[] args = new String[json.size()]; - for (int i = 0; i < json.size(); i++) { - args[i] = json.get(i).getAsString(); + String[] arguments = decodedArguments; + if (arguments == null) { + synchronized (this) { + arguments = decodedArguments; + if (arguments == null) { + arguments = decodeArguments(); + decodedArguments = arguments; + } + } + } + return arguments.clone(); + } + + /** + * 在所有数据包接收完成后仅构建一次。 + * + * @return 首次完整构建的内容,尚未完成或已经构建时返回 null + */ + @Nullable + public String[] buildOnce() { + if (!isCompleted() || !built.compareAndSet(false, true)) { + return null; + } + try { + return build(); + } catch (RuntimeException ex) { + built.set(false); + throw ex; } - return args; } /** * 所有数据包是否接收完成 */ public boolean isCompleted() { - return !messages.isEmpty() && messages.size() == messages.get(0).getTotal(); + List snapshot = Lists.newArrayList(messages); + if (snapshot.isEmpty()) { + return false; + } + try { + validateCompleted(snapshot); + return true; + } catch (IllegalStateException ignored) { + return false; + } + } + + /** + * 获取消息 UID。 + * + * @return 尚未接收任何数据包时返回 null + */ + @Nullable + public UUID getUID() { + return messages.isEmpty() ? null : messages.get(0).getUID(); } + /** + * 获取实时数据包列表,保持旧版 API 的可修改语义。 + */ @NotNull public List getMessages() { - return messages; + return exposedMessages; + } + + synchronized boolean addPacket(MessagePacket packet, int packetBytes, long now, MessageReader.CacheState cache) { + for (MessagePacket message : messages) { + if (!message.getUID().equals(packet.getUID())) { + throw new IllegalArgumentException("Message UID is inconsistent"); + } + if (message.getTotal() != packet.getTotal()) { + throw new IllegalArgumentException("Message total is inconsistent"); + } + if (message.getIndex() == packet.getIndex()) { + if (message.getData().equals(packet.getData())) { + lastAccess = now; + return false; + } + throw new IllegalArgumentException("Message packet data conflicts with an existing index"); + } + } + if (cachedBytes + packetBytes > MessageReader.MAX_MESSAGE_SIZE) { + throw new IllegalArgumentException("Message exceeds protocol cache size limit"); + } + if (!cache.reserve(packetBytes)) { + throw MessageReader.cacheCapacityExceeded("Message cache byte capacity exceeded"); + } + boolean added = false; + try { + messages.add(packet); + cachedBytes += packetBytes; + lastAccess = now; + decodedArguments = null; + if (messages.size() == packet.getTotal()) { + completedAt = now; + } + added = true; + return true; + } finally { + if (!added) { + cache.release(packetBytes); + } + } + } + + void validatePayload() { + if (decodedArguments == null) { + build(); + } + } + + synchronized long releaseCachedBytes() { + long released = cachedBytes; + cachedBytes = 0; + return released; + } + + boolean isExpired(long now) { + long completed = completedAt; + return now - lastAccess >= MessageReader.IDLE_TIMEOUT_NANOS + || now - createdAt >= MessageReader.MAX_LIFETIME_NANOS + || completed != 0 && now - completed >= MessageReader.COMPLETED_RETENTION_NANOS; + } + + private String[] decodeArguments() { + List snapshot = Lists.newArrayList(messages); + validateCompleted(snapshot); + messages.sort(Comparator.comparingInt(MessagePacket::getIndex)); + snapshot = Lists.newArrayList(messages); + StringBuilder builder = new StringBuilder(); + for (MessagePacket message : snapshot) { + builder.append(message.getData()); + } + JsonElement element; + try { + element = new JsonParser().parse(ByteUtils.deSerialize(builder.toString())); + } catch (RuntimeException ex) { + throw new IllegalArgumentException("Message payload is not valid JSON", ex); + } + if (!element.isJsonArray()) { + throw new IllegalArgumentException("Message payload must be a JSON array"); + } + JsonArray json = element.getAsJsonArray(); + String[] args = new String[json.size()]; + for (int i = 0; i < json.size(); i++) { + JsonElement argument = json.get(i); + if (!argument.isJsonPrimitive() || !argument.getAsJsonPrimitive().isString()) { + throw new IllegalArgumentException("Message argument must be a string"); + } + args[i] = argument.getAsString(); + } + return args; + } + + private void invalidateDecodedArguments() { + decodedArguments = null; + completedAt = 0; + } + + private static void validateCompleted(List packets) { + if (packets.isEmpty()) { + throw new IllegalStateException("Message is incomplete"); + } + MessagePacket first = packets.get(0); + int total = first.getTotal(); + if (packets.size() != total) { + throw new IllegalStateException("Message is incomplete"); + } + Set indexes = new HashSet<>(); + for (MessagePacket packet : packets) { + if (!first.getUID().equals(packet.getUID()) || packet.getTotal() != total) { + throw new IllegalStateException("Message metadata is inconsistent"); + } + if (packet.getIndex() < 1 || packet.getIndex() > total || !indexes.add(packet.getIndex())) { + throw new IllegalStateException("Message indexes are invalid"); + } + } } } diff --git a/module/minecraft/minecraft-porticus/src/main/java/taboolib/module/porticus/common/MessageBuilder.java b/module/minecraft/minecraft-porticus/src/main/java/taboolib/module/porticus/common/MessageBuilder.java index 49fbdec38..340314b94 100644 --- a/module/minecraft/minecraft-porticus/src/main/java/taboolib/module/porticus/common/MessageBuilder.java +++ b/module/minecraft/minecraft-porticus/src/main/java/taboolib/module/porticus/common/MessageBuilder.java @@ -8,6 +8,7 @@ import java.io.IOException; import java.nio.charset.StandardCharsets; import java.util.List; +import java.util.UUID; /** * 通讯信息数据包创建工具 @@ -29,25 +30,49 @@ public class MessageBuilder { * @param message 源数据 */ public static List create(String[] message) throws IOException { + if (message == null || message.length == 0 || message[0] == null) { + throw new IOException("Message UID is required"); + } + UUID uid; + try { + uid = UUID.fromString(message[0]); + } catch (IllegalArgumentException ex) { + throw new IOException("Message UID is invalid", ex); + } + if (!uid.toString().equalsIgnoreCase(message[0])) { + throw new IOException("Message UID is invalid"); + } List messages = Lists.newArrayList(); JsonArray array = new JsonArray(); for (int i = 1; i < message.length; i++) { + if (message[i] == null) { + throw new IOException("Message arguments cannot be null"); + } array.add(new JsonPrimitive(message[i])); } String source = ByteUtils.serialize(array.toString()); - int times = (int) Math.ceil(source.length() / (double) MESSAGE_LENGTH); + int times = (source.length() + MESSAGE_LENGTH - 1) / MESSAGE_LENGTH; + if (times < 1 || times > MessageReader.MAX_TOTAL) { + throw new IOException("Message contains too many packets"); + } + long totalBytes = 0; for (int i = 0; i < times; i++) { + int from = i * MESSAGE_LENGTH; + int to = Math.min(from + MESSAGE_LENGTH, source.length()); JsonObject json = new JsonObject(); - json.addProperty("uid", message[0]); + json.addProperty("uid", uid.toString()); json.addProperty("index", i + 1); json.addProperty("total", times); - if (source.length() < MESSAGE_LENGTH) { - json.addProperty("data", source); - } else { - json.addProperty("data", source.substring(0, source.length() - (source.length() - MESSAGE_LENGTH))); - source = source.substring(MESSAGE_LENGTH); + json.addProperty("data", source.substring(from, to)); + byte[] packet = json.toString().getBytes(StandardCharsets.UTF_8); + if (packet.length > MessageReader.MAX_PACKET_SIZE) { + throw new IOException("Message packet exceeds protocol size limit"); + } + totalBytes += packet.length; + if (totalBytes > MessageReader.MAX_MESSAGE_SIZE) { + throw new IOException("Message exceeds protocol cache size limit"); } - messages.add(json.toString().getBytes(StandardCharsets.UTF_8)); + messages.add(packet); } return messages; } diff --git a/module/minecraft/minecraft-porticus/src/main/java/taboolib/module/porticus/common/MessagePacket.java b/module/minecraft/minecraft-porticus/src/main/java/taboolib/module/porticus/common/MessagePacket.java index d22ca5acd..2f365cdd2 100644 --- a/module/minecraft/minecraft-porticus/src/main/java/taboolib/module/porticus/common/MessagePacket.java +++ b/module/minecraft/minecraft-porticus/src/main/java/taboolib/module/porticus/common/MessagePacket.java @@ -26,6 +26,18 @@ public class MessagePacket { private final int total; MessagePacket(UUID uid, String data, int index, int total) { + if (uid == null) { + throw new IllegalArgumentException("Message UID is required"); + } + if (data == null) { + throw new IllegalArgumentException("Message data is required"); + } + if (total < 1 || total > MessageReader.MAX_TOTAL) { + throw new IllegalArgumentException("Message total is out of range"); + } + if (index < 1 || index > total) { + throw new IllegalArgumentException("Message index is out of range"); + } this.uid = uid; this.data = data; this.index = index; diff --git a/module/minecraft/minecraft-porticus/src/main/java/taboolib/module/porticus/common/MessageReader.java b/module/minecraft/minecraft-porticus/src/main/java/taboolib/module/porticus/common/MessageReader.java index 09505ed8a..bbc19cafc 100644 --- a/module/minecraft/minecraft-porticus/src/main/java/taboolib/module/porticus/common/MessageReader.java +++ b/module/minecraft/minecraft-porticus/src/main/java/taboolib/module/porticus/common/MessageReader.java @@ -1,14 +1,22 @@ package taboolib.module.porticus.common; -import com.google.common.cache.Cache; -import com.google.common.cache.CacheBuilder; +import com.google.gson.JsonElement; import com.google.gson.JsonObject; import com.google.gson.JsonParser; import java.io.IOException; +import java.math.BigDecimal; +import java.nio.ByteBuffer; +import java.nio.charset.CharacterCodingException; +import java.nio.charset.CodingErrorAction; import java.nio.charset.StandardCharsets; import java.util.UUID; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.ConcurrentMap; +import java.util.concurrent.Semaphore; import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicLong; +import java.util.concurrent.atomic.AtomicReference; /** * 通讯信息数据包读取工具 @@ -18,9 +26,45 @@ */ public class MessageReader { - private static final Cache queueMessages = CacheBuilder.newBuilder() - .expireAfterWrite(10, TimeUnit.SECONDS) - .build(); + static final int MAX_PACKET_SIZE = 32767; + static final int MAX_TOTAL = 1024; + static final int MAX_CACHED_MESSAGES = 1024; + static final long MAX_MESSAGE_SIZE = 4L * 1024 * 1024; + static final long MAX_CACHED_BYTES = 16L * 1024 * 1024; + static final long IDLE_TIMEOUT_NANOS = TimeUnit.SECONDS.toNanos(10); + static final long COMPLETED_RETENTION_NANOS = TimeUnit.SECONDS.toNanos(10); + static final long MAX_LIFETIME_NANOS = TimeUnit.SECONDS.toNanos(30); + private static final long CLEANUP_INTERVAL_NANOS = TimeUnit.SECONDS.toNanos(1); + + private static final AtomicReference cache = new AtomicReference<>(new CacheState()); + + /** + * 清空消息缓存,并允许后续消息进入新的缓存状态。 + */ + public static void clear() { + replaceCache(true); + } + + /** + * 打开消息接收缓存。 + */ + public static void open() { + replaceCache(true); + } + + /** + * 关闭并清空消息接收缓存。 + */ + public static void close() { + replaceCache(false); + } + + /** + * 清理过期的未完成消息和已消费消息。 + */ + public static void cleanUp() { + cleanUp(cache.get(), System.nanoTime()); + } /** * 将通讯数据读取为数据包 @@ -28,7 +72,26 @@ public class MessageReader { * @param packet 通讯数据(未经过处理的原始内容) */ public static Message read(byte[] packet) throws IOException { - return read(new String(packet, StandardCharsets.UTF_8)); + if (packet == null || packet.length == 0) { + throw new ProtocolException("Message packet is empty"); + } + if (packet.length > MAX_PACKET_SIZE) { + throw new ProtocolException("Message packet exceeds protocol size limit"); + } + try { + String decoded = StandardCharsets.UTF_8.newDecoder() + .onMalformedInput(CodingErrorAction.REPORT) + .onUnmappableCharacter(CodingErrorAction.REPORT) + .decode(ByteBuffer.wrap(packet)) + .toString(); + return readValidated(decoded, packet.length, System.nanoTime()); + } catch (CharacterCodingException ex) { + throw new ProtocolException("Message packet is not valid UTF-8", ex); + } catch (CacheCapacityException ex) { + throw new CapacityException(ex.getMessage(), ex); + } catch (IllegalArgumentException ex) { + throw new ProtocolException("Invalid message packet", ex); + } } /** @@ -37,18 +100,312 @@ public static Message read(byte[] packet) throws IOException { * @param packet 通讯数据(未经过处理的原始内容) */ public static Message read(String packet) { - JsonObject json = new JsonParser().parse(packet).getAsJsonObject(); - Message message = queueMessages.getIfPresent(json.get("uid").getAsString()); - if (message == null) { - message = new Message(); - queueMessages.put(json.get("uid").getAsString(), message); - } - message.getMessages().add(new MessagePacket( - UUID.fromString(json.get("uid").getAsString()), - json.get("data").getAsString(), - json.get("index").getAsInt(), - json.get("total").getAsInt() - )); - return message; + return read(packet, System.nanoTime()); + } + + static Message read(String packet, long now) { + if (packet == null || packet.isEmpty()) { + throw new IllegalArgumentException("Message packet is empty"); + } + int packetBytes = packet.getBytes(StandardCharsets.UTF_8).length; + if (packetBytes > MAX_PACKET_SIZE) { + throw new IllegalArgumentException("Message packet exceeds protocol size limit"); + } + return readValidated(packet, packetBytes, now); + } + + private static Message readValidated(String source, int packetBytes, long now) { + ParsedPacket packet = parse(source); + while (true) { + CacheState state = cache.get(); + if (state.closed) { + throw new IllegalStateException("Message cache is closed"); + } + cleanUpIfNeeded(state, now); + String key = packet.uid.toString(); + Message message = computeMessage(state, key, packet, packetBytes, now, true); + if (state.closed || cache.get() != state) { + state.remove(key, message); + continue; + } + if (message.isCompleted()) { + try { + message.validatePayload(); + } catch (RuntimeException ex) { + state.remove(key, message); + throw ex; + } + } + if (state.closed || cache.get() != state) { + state.remove(key, message); + continue; + } + return message; + } + } + + private static Message computeMessage(CacheState state, String key, ParsedPacket packet, int packetBytes, long now, boolean retryAfterCleanup) { + AtomicReference deferredFailure = new AtomicReference<>(); + try { + Message message = state.messages.compute(key, (ignored, current) -> { + MessagePacket incoming = new MessagePacket(packet.uid, packet.data, packet.index, packet.total); + if (current != null && current.isExpired(now)) { + state.release(current.releaseCachedBytes()); + Message replacement = new Message(now); + try { + replacement.addPacket(incoming, packetBytes, now, state); + return replacement; + } catch (RuntimeException ex) { + state.slots.release(); + deferredFailure.set(ex); + return null; + } + } + if (current != null) { + current.addPacket(incoming, packetBytes, now, state); + return current; + } + if (!state.slots.tryAcquire()) { + throw cacheCapacityExceeded("Message cache entry capacity exceeded"); + } + Message created = new Message(now); + try { + created.addPacket(incoming, packetBytes, now, state); + return created; + } catch (RuntimeException ex) { + state.slots.release(); + throw ex; + } + }); + RuntimeException failure = deferredFailure.get(); + if (failure != null) { + throw failure; + } + return message; + } catch (CacheCapacityException ex) { + if (retryAfterCleanup && !state.closed) { + cleanUp(state, now); + return computeMessage(state, key, packet, packetBytes, now, false); + } + throw ex; + } + } + + static CacheCapacityException cacheCapacityExceeded(String message) { + return new CacheCapacityException(message); + } + + static void cleanUp(long now) { + cleanUp(cache.get(), now); + } + + static int cachedMessageCount() { + return cache.get().messages.size(); + } + + static long cachedByteCount() { + return cache.get().cachedBytes.get(); + } + + private static void cleanUpIfNeeded(CacheState state, long now) { + long next = state.nextCleanup.get(); + if (now >= next && state.nextCleanup.compareAndSet(next, now + CLEANUP_INTERVAL_NANOS)) { + cleanUp(state, now); + } + } + + private static void cleanUp(CacheState state, long now) { + for (String key : state.messages.keySet()) { + state.messages.computeIfPresent(key, (ignored, current) -> { + if (current.isExpired(now)) { + state.release(current.releaseCachedBytes()); + state.slots.release(); + return null; + } + return current; + }); + } + } + + private static void replaceCache(boolean keepAccepting) { + CacheState replacement = new CacheState(!keepAccepting); + CacheState previous = cache.getAndSet(replacement); + previous.closed = true; + previous.clear(); + } + + private static ParsedPacket parse(String source) { + JsonElement root; + try { + root = new JsonParser().parse(source); + } catch (RuntimeException ex) { + throw new IllegalArgumentException("Message packet is not valid JSON", ex); + } + if (!root.isJsonObject()) { + throw new IllegalArgumentException("Message packet must be a JSON object"); + } + JsonObject json = root.getAsJsonObject(); + String uidSource = stringField(json, "uid"); + String data = stringField(json, "data"); + int index = integerField(json, "index"); + int total = integerField(json, "total"); + if (total < 1 || total > MAX_TOTAL) { + throw new IllegalArgumentException("Message total is out of range"); + } + if (index < 1 || index > total) { + throw new IllegalArgumentException("Message index is out of range"); + } + UUID uid; + try { + uid = UUID.fromString(uidSource); + } catch (IllegalArgumentException ex) { + throw new IllegalArgumentException("Message UID is invalid", ex); + } + if (!uid.toString().equalsIgnoreCase(uidSource)) { + throw new IllegalArgumentException("Message UID is invalid"); + } + validateBase64Chunk(data, index, total); + return new ParsedPacket(uid, data, index, total); + } + + private static String stringField(JsonObject json, String name) { + JsonElement element = json.get(name); + if (element == null || !element.isJsonPrimitive() || !element.getAsJsonPrimitive().isString()) { + throw new IllegalArgumentException("Message field '" + name + "' must be a string"); + } + return element.getAsString(); + } + + private static int integerField(JsonObject json, String name) { + JsonElement element = json.get(name); + if (element == null || !element.isJsonPrimitive() || !element.getAsJsonPrimitive().isNumber()) { + throw new IllegalArgumentException("Message field '" + name + "' must be an integer"); + } + try { + return new BigDecimal(element.getAsString()).intValueExact(); + } catch (ArithmeticException | NumberFormatException ex) { + throw new IllegalArgumentException("Message field '" + name + "' must be an integer", ex); + } + } + + private static void validateBase64Chunk(String data, int index, int total) { + if (data.isEmpty()) { + throw new IllegalArgumentException("Message data is empty"); + } + if (data.length() > MessageBuilder.MESSAGE_LENGTH) { + throw new IllegalArgumentException("Message data exceeds packet chunk size limit"); + } + boolean padding = false; + int paddingLength = 0; + for (int i = 0; i < data.length(); i++) { + char character = data.charAt(i); + if (character == '=') { + if (index != total || ++paddingLength > 2) { + throw new IllegalArgumentException("Message data is not valid Base64"); + } + padding = true; + } else { + boolean base64 = character >= 'A' && character <= 'Z' + || character >= 'a' && character <= 'z' + || character >= '0' && character <= '9' + || character == '+' + || character == '/'; + if (!base64 || padding) { + throw new IllegalArgumentException("Message data is not valid Base64"); + } + } + } + } + + static final class CacheState { + + private final ConcurrentMap messages = new ConcurrentHashMap<>(); + private final Semaphore slots = new Semaphore(MAX_CACHED_MESSAGES); + private final AtomicLong cachedBytes = new AtomicLong(); + private final AtomicLong nextCleanup = new AtomicLong(); + private volatile boolean closed; + + private CacheState() { + this(false); + } + + private CacheState(boolean closed) { + this.closed = closed; + } + + boolean reserve(long bytes) { + while (true) { + long current = cachedBytes.get(); + if (bytes < 0 || current > MAX_CACHED_BYTES - bytes) { + return false; + } + if (cachedBytes.compareAndSet(current, current + bytes)) { + return true; + } + } + } + + void release(long bytes) { + if (bytes != 0) { + cachedBytes.addAndGet(-bytes); + } + } + + void remove(String key, Message message) { + if (messages.remove(key, message)) { + release(message.releaseCachedBytes()); + slots.release(); + } + } + + void clear() { + for (String key : messages.keySet()) { + messages.computeIfPresent(key, (ignored, current) -> { + release(current.releaseCachedBytes()); + slots.release(); + return null; + }); + } + } + } + + private static final class ParsedPacket { + + private final UUID uid; + private final String data; + private final int index; + private final int total; + + private ParsedPacket(UUID uid, String data, int index, int total) { + this.uid = uid; + this.data = data; + this.index = index; + this.total = total; + } + } + + private static final class CacheCapacityException extends IllegalStateException { + + private CacheCapacityException(String message) { + super(message); + } + } + + public static class ProtocolException extends IOException { + + public ProtocolException(String message) { + super(message); + } + + public ProtocolException(String message, Throwable cause) { + super(message, cause); + } + } + + public static class CapacityException extends IOException { + + public CapacityException(String message, Throwable cause) { + super(message, cause); + } } } diff --git a/module/minecraft/minecraft-porticus/src/main/kotlin/taboolib/module/porticus/Porticus.kt b/module/minecraft/minecraft-porticus/src/main/kotlin/taboolib/module/porticus/Porticus.kt index fc8b8d18f..e7c6912b5 100644 --- a/module/minecraft/minecraft-porticus/src/main/kotlin/taboolib/module/porticus/Porticus.kt +++ b/module/minecraft/minecraft-porticus/src/main/kotlin/taboolib/module/porticus/Porticus.kt @@ -10,6 +10,7 @@ import taboolib.common.platform.Platform import taboolib.common.platform.PlatformSide import taboolib.common.platform.function.pluginId import taboolib.common.util.unsafeLazy +import taboolib.module.porticus.common.MessageReader import java.util.concurrent.CopyOnWriteArrayList /** @@ -43,6 +44,7 @@ object Porticus { */ @Awake(LifeCycle.ENABLE) private fun onEnable() { + MessageReader.open() try { Bukkit.getServer() API = taboolib.module.porticus.bukkitside.PorticusAPI() @@ -54,4 +56,10 @@ object Porticus { } catch (ignored: Throwable) { } } + + @Awake(LifeCycle.DISABLE) + private fun onDisable() { + missions.clear() + MessageReader.close() + } } \ No newline at end of file diff --git a/module/minecraft/minecraft-porticus/src/test/java/taboolib/module/porticus/PorticusMissionTest.java b/module/minecraft/minecraft-porticus/src/test/java/taboolib/module/porticus/PorticusMissionTest.java new file mode 100644 index 000000000..f1489a744 --- /dev/null +++ b/module/minecraft/minecraft-porticus/src/test/java/taboolib/module/porticus/PorticusMissionTest.java @@ -0,0 +1,124 @@ +package taboolib.module.porticus; + +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Test; + +import java.util.UUID; +import java.util.concurrent.TimeUnit; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class PorticusMissionTest { + + @AfterEach + void clearMissions() { + Porticus.INSTANCE.getMissions().clear(); + } + + @Test + void unstartedMissionNeverTimesOut() { + TestMission mission = new TestMission(); + mission.now = Long.MAX_VALUE; + mission.timeout(0, TimeUnit.MILLISECONDS); + + assertFalse(mission.isTimeout()); + } + + @Test + void timeoutUsesElapsedTimeAndIncludesBoundary() { + TestMission mission = new TestMission(); + mission.now = 1_000; + mission.timeout(100, TimeUnit.MILLISECONDS); + mission.run(new Object()); + + mission.now = 1_099; + assertFalse(mission.isTimeout()); + mission.now = 1_100; + assertTrue(mission.isTimeout()); + } + + @Test + void pendingMissionCannotBeStartedAgain() { + TestMission mission = new TestMission(); + mission.now = 1_000; + mission.onTimeout(() -> { + }); + mission.run(new Object()); + + mission.now = 2_000; + + assertThrows(IllegalStateException.class, () -> mission.run(new Object())); + assertEquals(1, Porticus.INSTANCE.getMissions().stream().filter(it -> it == mission).count()); + assertEquals(1_000, mission.getStart()); + } + + @Test + void differentMissionsCannotSharePendingUid() { + UUID uid = UUID.randomUUID(); + TestMission first = new TestMission(uid); + TestMission second = new TestMission(uid); + first.onTimeout(() -> { + }); + second.onTimeout(() -> { + }); + + first.run(new Object()); + + assertThrows(IllegalStateException.class, () -> second.run(new Object())); + assertEquals(1, Porticus.INSTANCE.getMissions().size()); + assertTrue(Porticus.INSTANCE.getMissions().contains(first)); + } + + @Test + void missionCanOnlyBeFinalizedOnce() { + TestMission mission = new TestMission(); + mission.now = 1_000; + mission.onTimeout(() -> { + }); + mission.run(new Object()); + + assertTrue(mission.cancel()); + assertFalse(mission.cancel()); + } + + @Test + void missionCannotBeReusedAfterFinalization() { + TestMission mission = new TestMission(); + mission.onTimeout(() -> { + }); + mission.now = 1_000; + mission.run(new Object()); + assertTrue(mission.cancel()); + + mission.now = 2_000; + + assertThrows(IllegalStateException.class, () -> mission.run(new Object())); + assertFalse(mission.pending()); + assertEquals(1_000, mission.getStart()); + } + + private static class TestMission extends PorticusMission { + + private long now; + + private TestMission() { + timeSource = () -> now; + } + + private TestMission(UUID uid) { + super(uid); + timeSource = () -> now; + } + + private boolean cancel() { + return Porticus.INSTANCE.getMissions().remove(this); + } + + private boolean pending() { + return Porticus.INSTANCE.getMissions().contains(this); + } + } +} diff --git a/module/minecraft/minecraft-porticus/src/test/java/taboolib/module/porticus/common/MessageProtocolTest.java b/module/minecraft/minecraft-porticus/src/test/java/taboolib/module/porticus/common/MessageProtocolTest.java new file mode 100644 index 000000000..94ec711af --- /dev/null +++ b/module/minecraft/minecraft-porticus/src/test/java/taboolib/module/porticus/common/MessageProtocolTest.java @@ -0,0 +1,331 @@ +package taboolib.module.porticus.common; + +import com.google.gson.JsonObject; +import com.google.gson.JsonParser; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.Base64; +import java.util.Collections; +import java.util.List; +import java.util.UUID; + +import static org.junit.jupiter.api.Assertions.assertArrayEquals; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotSame; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertSame; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class MessageProtocolTest { + + @BeforeEach + void resetCache() { + MessageReader.clear(); + } + + @AfterEach + void verifyCacheCanBeCleared() { + MessageReader.clear(); + assertEquals(0, MessageReader.cachedMessageCount()); + assertEquals(0, MessageReader.cachedByteCount()); + } + + @Test + void shouldRoundTripNormalMessage() throws IOException { + String uid = UUID.randomUUID().toString(); + String[] source = {uid, "command", "first", "second"}; + + Message message = readAll(MessageBuilder.create(source)); + + assertTrue(message.isCompleted()); + assertEquals(UUID.fromString(uid), message.getUID()); + assertArrayEquals(new String[]{"command", "first", "second"}, message.build()); + } + + @Test + void shouldRoundTripUnicodeMessage() throws IOException { + String[] source = {UUID.randomUUID().toString(), "你好,世界", "emoji: 😀", "日本語", "Привет"}; + + Message message = readAll(MessageBuilder.create(source)); + + assertArrayEquals(new String[]{"你好,世界", "emoji: 😀", "日本語", "Привет"}, message.build()); + } + + @Test + void shouldSplitAndReassembleMultiplePackets() throws IOException { + String large = repeat('a', MessageBuilder.MESSAGE_LENGTH * 2); + String[] source = {UUID.randomUUID().toString(), large, "tail"}; + List packets = MessageBuilder.create(source); + + assertTrue(packets.size() > 1); + for (byte[] packet : packets) { + assertTrue(packet.length <= MessageReader.MAX_PACKET_SIZE); + } + Message message = readAll(packets); + assertArrayEquals(new String[]{large, "tail"}, message.build()); + } + + @Test + void shouldReassembleOutOfOrderPackets() throws IOException { + String large = repeat('b', MessageBuilder.MESSAGE_LENGTH * 2); + List packets = new ArrayList<>(MessageBuilder.create(new String[]{UUID.randomUUID().toString(), large})); + Collections.reverse(packets); + + Message message = readAll(packets); + + assertTrue(message.isCompleted()); + assertArrayEquals(new String[]{large}, message.build()); + for (int i = 0; i < message.getMessages().size(); i++) { + assertEquals(i + 1, message.getMessages().get(i).getIndex()); + } + } + + @Test + void shouldDeduplicatePacketsByIndex() throws IOException { + String large = repeat('c', MessageBuilder.MESSAGE_LENGTH * 2); + List packets = MessageBuilder.create(new String[]{UUID.randomUUID().toString(), large}); + + Message message = MessageReader.read(packets.get(0)); + Message duplicate = MessageReader.read(packets.get(0)); + + assertEquals(1, duplicate.getMessages().size()); + assertEquals(message, duplicate); + for (int i = 1; i < packets.size(); i++) { + message = MessageReader.read(packets.get(i)); + } + assertTrue(message.isCompleted()); + assertArrayEquals(new String[]{large}, message.build()); + } + + @Test + void shouldRejectConflictingDataForTheSameIndex() throws IOException { + List packets = MessageBuilder.create(new String[]{UUID.randomUUID().toString(), repeat('c', MessageBuilder.MESSAGE_LENGTH * 2)}); + Message message = MessageReader.read(packets.get(0)); + JsonObject conflict = new JsonParser().parse(new String(packets.get(0), StandardCharsets.UTF_8)).getAsJsonObject(); + String data = conflict.get("data").getAsString(); + conflict.addProperty("data", (data.charAt(0) == 'A' ? 'B' : 'A') + data.substring(1)); + + assertThrows(IllegalArgumentException.class, () -> MessageReader.read(conflict.toString())); + assertEquals(1, message.getMessages().size()); + } + + @Test + void shouldRemainIncompleteWhenPacketIsMissing() throws IOException { + String large = repeat('d', MessageBuilder.MESSAGE_LENGTH * 2); + List packets = MessageBuilder.create(new String[]{UUID.randomUUID().toString(), large}); + Message message = null; + + for (int i = 0; i < packets.size() - 1; i++) { + message = MessageReader.read(packets.get(i)); + } + + assertFalse(message.isCompleted()); + assertNull(message.buildOnce()); + Message incomplete = message; + assertThrows(IllegalStateException.class, incomplete::build); + } + + @Test + void shouldRejectIndexesAndTotalsOutsideProtocolBounds() { + String uid = UUID.randomUUID().toString(); + String data = ByteUtils.serialize("[]"); + + assertThrows(IllegalArgumentException.class, () -> MessageReader.read(packet(uid, data, 0, 1))); + assertThrows(IllegalArgumentException.class, () -> MessageReader.read(packet(uid, data, 2, 1))); + assertThrows(IllegalArgumentException.class, () -> MessageReader.read(packet(uid, data, 1, 0))); + assertThrows(IllegalArgumentException.class, () -> MessageReader.read(packet(uid, data, 1, MessageReader.MAX_TOTAL + 1))); + } + + @Test + void shouldRejectConflictingTotalWithoutMutatingCachedMessage() throws IOException { + String large = repeat('e', MessageBuilder.MESSAGE_LENGTH * 2); + List packets = MessageBuilder.create(new String[]{UUID.randomUUID().toString(), large}); + Message message = MessageReader.read(packets.get(0)); + JsonObject conflict = new JsonParser().parse(new String(packets.get(0), StandardCharsets.UTF_8)).getAsJsonObject(); + conflict.addProperty("total", conflict.get("total").getAsInt() + 1); + + assertThrows(IllegalArgumentException.class, () -> MessageReader.read(conflict.toString())); + assertEquals(1, message.getMessages().size()); + for (int i = 1; i < packets.size(); i++) { + MessageReader.read(packets.get(i)); + } + assertTrue(message.isCompleted()); + assertArrayEquals(new String[]{large}, message.build()); + } + + @Test + void shouldRejectMalformedJsonBase64AndFieldTypesWithoutPollutingCache() throws IOException { + String uid = UUID.randomUUID().toString(); + + assertThrows(IllegalArgumentException.class, () -> MessageReader.read("not-json")); + assertThrows(IllegalArgumentException.class, () -> MessageReader.read(packet(uid, "%%%", 1, 1))); + assertThrows(IllegalArgumentException.class, () -> MessageReader.read(packet("1-1-1-1-1", ByteUtils.serialize("[]"), 1, 1))); + + JsonObject wrongType = new JsonObject(); + wrongType.addProperty("uid", uid); + wrongType.addProperty("data", ByteUtils.serialize("[]")); + wrongType.addProperty("index", "1"); + wrongType.addProperty("total", 1); + assertThrows(IllegalArgumentException.class, () -> MessageReader.read(wrongType.toString())); + + Message valid = readAll(MessageBuilder.create(new String[]{uid, "valid"})); + assertTrue(valid.isCompleted()); + assertArrayEquals(new String[]{"valid"}, valid.build()); + } + + @Test + void shouldRejectInvalidOuterAndInnerUtf8() throws IOException { + assertThrows(IOException.class, () -> MessageReader.read(new byte[]{(byte) 0xC3, 0x28})); + + String uid = UUID.randomUUID().toString(); + byte[] invalidJsonBytes = new byte[]{'[', '"', (byte) 0xC3, '"', ']'}; + String encoded = Base64.getEncoder().encodeToString(invalidJsonBytes); + assertThrows(IllegalArgumentException.class, () -> MessageReader.read(packet(uid, encoded, 1, 1))); + assertEquals(0, MessageReader.cachedMessageCount()); + + Message valid = readAll(MessageBuilder.create(new String[]{uid, "valid"})); + assertArrayEquals(new String[]{"valid"}, valid.build()); + } + + @Test + void shouldRejectOversizedRawPacketAndMessage() { + String oversized = repeat('x', MessageReader.MAX_PACKET_SIZE + 1); + + assertThrows(IllegalArgumentException.class, () -> MessageReader.read(oversized)); + assertThrows(IOException.class, () -> MessageReader.read(oversized.getBytes(StandardCharsets.UTF_8))); + assertThrows(IOException.class, () -> MessageBuilder.create(new String[]{ + UUID.randomUUID().toString(), + repeat('x', (int) MessageReader.MAX_MESSAGE_SIZE) + })); + } + + @Test + void shouldBuildCompletedMessageOnlyOnce() throws IOException { + String large = repeat('f', MessageBuilder.MESSAGE_LENGTH * 2); + Message message = readAll(MessageBuilder.create(new String[]{UUID.randomUUID().toString(), large, "done"})); + + assertArrayEquals(new String[]{large, "done"}, message.buildOnce()); + assertNull(message.buildOnce()); + assertTrue(message.isCompleted()); + assertArrayEquals(new String[]{large, "done"}, message.build()); + } + + @Test + void shouldSuppressCompletedMessageReplayUntilRetentionExpires() throws IOException { + List packets = MessageBuilder.create(new String[]{UUID.randomUUID().toString(), repeat('g', MessageBuilder.MESSAGE_LENGTH * 2)}); + long now = 1_000; + Message completed = readAll(packets, now); + assertArrayEquals(new String[]{repeat('g', MessageBuilder.MESSAGE_LENGTH * 2)}, completed.buildOnce()); + + Message replay = readAll(packets, now + 1); + + assertSame(completed, replay); + assertNull(replay.buildOnce()); + MessageReader.cleanUp(now + MessageReader.COMPLETED_RETENTION_NANOS + 1); + Message next = MessageReader.read(new String(packets.get(0), StandardCharsets.UTF_8), now + MessageReader.COMPLETED_RETENTION_NANOS + 2); + assertNotSame(completed, next); + assertFalse(next.isCompleted()); + } + + @Test + void shouldExpireIdlePartialMessageWithoutWaiting() throws IOException { + List packets = MessageBuilder.create(new String[]{UUID.randomUUID().toString(), repeat('h', MessageBuilder.MESSAGE_LENGTH * 2)}); + long now = 10_000; + Message partial = MessageReader.read(new String(packets.get(0), StandardCharsets.UTF_8), now); + + MessageReader.cleanUp(now + MessageReader.IDLE_TIMEOUT_NANOS + 1); + + assertEquals(0, MessageReader.cachedMessageCount()); + assertEquals(0, MessageReader.cachedByteCount()); + Message replacement = MessageReader.read(new String(packets.get(0), StandardCharsets.UTF_8), now + MessageReader.IDLE_TIMEOUT_NANOS + 2); + assertNotSame(partial, replacement); + } + + @Test + void shouldEnforcePerMessageAndEntryCacheCapacity() { + String uid = UUID.randomUUID().toString(); + String chunk = repeat('A', MessageBuilder.MESSAGE_LENGTH); + boolean rejected = false; + for (int index = 1; index <= 200; index++) { + try { + MessageReader.read(packet(uid, chunk, index, 200)); + } catch (IllegalArgumentException ex) { + rejected = true; + break; + } + } + assertTrue(rejected); + assertTrue(MessageReader.cachedByteCount() <= MessageReader.MAX_MESSAGE_SIZE); + + MessageReader.clear(); + String partialData = "Ww"; + for (int i = 0; i < MessageReader.MAX_CACHED_MESSAGES; i++) { + MessageReader.read(packet(UUID.randomUUID().toString(), partialData, 1, 2)); + } + assertEquals(MessageReader.MAX_CACHED_MESSAGES, MessageReader.cachedMessageCount()); + assertThrows(IllegalStateException.class, () -> MessageReader.read(packet(UUID.randomUUID().toString(), partialData, 1, 2))); + } + + @Test + void shouldRejectNewPacketsWhileCacheIsClosed() throws IOException { + String packet = new String(MessageBuilder.create(new String[]{UUID.randomUUID().toString(), "value"}).get(0), StandardCharsets.UTF_8); + + MessageReader.close(); + assertThrows(IllegalStateException.class, () -> MessageReader.read(packet)); + assertEquals(0, MessageReader.cachedMessageCount()); + + MessageReader.open(); + assertTrue(MessageReader.read(packet).isCompleted()); + } + + @Test + void shouldPreserveMutableLivePacketListCompatibility() throws IOException { + Message message = MessageReader.read(MessageBuilder.create(new String[]{UUID.randomUUID().toString(), "value"}).get(0)); + assertArrayEquals(new String[]{"value"}, message.build()); + + message.getMessages().clear(); + + assertFalse(message.isCompleted()); + assertThrows(IllegalStateException.class, message::build); + } + + private static Message readAll(List packets) throws IOException { + Message message = null; + for (byte[] packet : packets) { + message = MessageReader.read(packet); + } + return message; + } + + private static Message readAll(List packets, long now) { + Message message = null; + for (byte[] packet : packets) { + message = MessageReader.read(new String(packet, StandardCharsets.UTF_8), now); + } + return message; + } + + private static String packet(String uid, String data, int index, int total) { + JsonObject json = new JsonObject(); + json.addProperty("uid", uid); + json.addProperty("data", data); + json.addProperty("index", index); + json.addProperty("total", total); + return json.toString(); + } + + private static String repeat(char character, int length) { + StringBuilder builder = new StringBuilder(length); + for (int i = 0; i < length; i++) { + builder.append(character); + } + return builder.toString(); + } +} From 39baf1eef9b9d9734e2e34f842b6cc3e9e12ad49 Mon Sep 17 00:00:00 2001 From: zhibei <785740487@qq.com> Date: Tue, 14 Jul 2026 02:48:39 +0800 Subject: [PATCH 19/37] =?UTF-8?q?fix(platform):=20=E4=BF=AE=E5=A4=8D?= =?UTF-8?q?=E7=94=9F=E5=91=BD=E5=91=A8=E6=9C=9F=E7=BB=88=E6=80=81=E4=B8=8E?= =?UTF-8?q?=E4=BB=BB=E5=8A=A1=E6=B8=85=E7=90=86?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../main/java/taboolib/common/TabooLib.java | 5 +- .../common/TabooLibDisableLifecycleTest.java | 30 ++ platform/platform-afybroker/build.gradle.kts | 5 + .../platform/AfyBrokerActiveGate.java | 36 ++ .../taboolib/platform/AfyBrokerPlugin.java | 85 ++++- .../taboolib/platform/AfyBrokerExecutor.kt | 204 ++++++++--- .../platform/AfyBrokerExecutorLifecycle.kt | 173 +++++++++ .../AfyBrokerExecutorLifecycleTest.kt | 249 +++++++++++++ .../platform-application/build.gradle.kts | 4 + .../src/main/java/taboolib/platform/App.java | 30 +- .../java/taboolib/platform/AppLifeCycle.java | 154 ++++++++ .../kotlin/taboolib/platform/AppCommand.kt | 16 +- .../kotlin/taboolib/platform/AppConsole.kt | 6 +- .../kotlin/taboolib/platform/AppExecutor.kt | 143 ++++++-- .../main/kotlin/taboolib/platform/AppIO.kt | 6 +- .../platform/ApplicationPlatformTest.kt | 206 +++++++++++ .../kotlin/taboolib/platform/BukkitCommand.kt | 77 +++- .../platform/BukkitCommandRegistryTest.kt | 60 ++++ .../platform-velocity-impl/build.gradle.kts | 6 + .../taboolib/platform/VelocityExecutor.kt | 333 +++++++++++++++--- .../taboolib/platform/VelocityExecutorTest.kt | 285 +++++++++++++++ platform/platform-velocity/build.gradle.kts | 4 + .../platform/VelocityActivationGate.java | 39 ++ .../taboolib/platform/VelocityPlugin.java | 91 ++++- .../platform/type/VelocityProxyEvent.kt | 57 ++- .../platform/VelocityActivationGateTest.java | 73 ++++ .../platform/type/VelocityProxyEventTest.kt | 125 +++++++ 27 files changed, 2329 insertions(+), 173 deletions(-) create mode 100644 common/src/test/java/taboolib/common/TabooLibDisableLifecycleTest.java create mode 100644 platform/platform-afybroker/src/main/java/taboolib/platform/AfyBrokerActiveGate.java create mode 100644 platform/platform-afybroker/src/main/kotlin/taboolib/platform/AfyBrokerExecutorLifecycle.kt create mode 100644 platform/platform-afybroker/src/test/kotlin/taboolib/platform/AfyBrokerExecutorLifecycleTest.kt create mode 100644 platform/platform-application/src/main/java/taboolib/platform/AppLifeCycle.java create mode 100644 platform/platform-application/src/test/kotlin/taboolib/platform/ApplicationPlatformTest.kt create mode 100644 platform/platform-bukkit-impl/src/test/kotlin/taboolib/platform/BukkitCommandRegistryTest.kt create mode 100644 platform/platform-velocity-impl/src/test/kotlin/taboolib/platform/VelocityExecutorTest.kt create mode 100644 platform/platform-velocity/src/main/java/taboolib/platform/VelocityActivationGate.java create mode 100644 platform/platform-velocity/src/test/java/taboolib/platform/VelocityActivationGateTest.java create mode 100644 platform/platform-velocity/src/test/kotlin/taboolib/platform/type/VelocityProxyEventTest.kt diff --git a/common/src/main/java/taboolib/common/TabooLib.java b/common/src/main/java/taboolib/common/TabooLib.java index c26de1ccb..dfcf963ef 100644 --- a/common/src/main/java/taboolib/common/TabooLib.java +++ b/common/src/main/java/taboolib/common/TabooLib.java @@ -64,11 +64,14 @@ public Class getClass(String name, boolean initialize, ClassLoader classLoade * 执行生命周期任务 */ public static void lifeCycle(LifeCycle lifeCycle) { - if (isStopped) { + if (isStopped && lifeCycle != LifeCycle.DISABLE) { return; } // 检查 Kotlin 环境是否就绪 if (!TabooLib.isKotlinEnvironment()) { + if (lifeCycle == LifeCycle.DISABLE) { + return; + } isStopped = true; throw new RuntimeException( t( diff --git a/common/src/test/java/taboolib/common/TabooLibDisableLifecycleTest.java b/common/src/test/java/taboolib/common/TabooLibDisableLifecycleTest.java new file mode 100644 index 000000000..678d63d1a --- /dev/null +++ b/common/src/test/java/taboolib/common/TabooLibDisableLifecycleTest.java @@ -0,0 +1,30 @@ +package taboolib.common; + +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Test; + +import java.util.concurrent.atomic.AtomicInteger; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class TabooLibDisableLifecycleTest { + + @AfterEach + void restoreStoppedFlag() { + TabooLib.setStopped(false); + } + + @Test + void disableLifecycleStillRunsWhenLoadingWasStopped() { + AtomicInteger calls = new AtomicInteger(); + TabooLib.registerLifeCycleTask(LifeCycle.DISABLE, 0, calls::incrementAndGet); + TabooLib.setStopped(true); + + TabooLib.lifeCycle(LifeCycle.DISABLE); + + assertEquals(1, calls.get()); + assertEquals(LifeCycle.DISABLE, TabooLib.getCurrentLifeCycle()); + assertTrue(TabooLib.isStopped()); + } +} diff --git a/platform/platform-afybroker/build.gradle.kts b/platform/platform-afybroker/build.gradle.kts index 9e6ca2455..7093498d7 100644 --- a/platform/platform-afybroker/build.gradle.kts +++ b/platform/platform-afybroker/build.gradle.kts @@ -5,4 +5,9 @@ dependencies { compileOnly(project(":common-platform-api")) compileOnly("com.github.AfyerDev.AfyBroker:afybroker-server:f6261eab2a") compileOnly("org.slf4j:slf4j-api:1.7.32") + + testImplementation(project(":common")) + testImplementation(project(":common-util")) + testImplementation(project(":common-platform-api")) + testImplementation("com.github.AfyerDev.AfyBroker:afybroker-server:f6261eab2a") } \ No newline at end of file diff --git a/platform/platform-afybroker/src/main/java/taboolib/platform/AfyBrokerActiveGate.java b/platform/platform-afybroker/src/main/java/taboolib/platform/AfyBrokerActiveGate.java new file mode 100644 index 000000000..8756eed4e --- /dev/null +++ b/platform/platform-afybroker/src/main/java/taboolib/platform/AfyBrokerActiveGate.java @@ -0,0 +1,36 @@ +package taboolib.platform; + +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.atomic.AtomicReference; + +final class AfyBrokerActiveGate { + + private enum State { + OPEN, + ACTIVATING, + CLOSED + } + + private final AtomicReference state = new AtomicReference<>(State.OPEN); + private final CompletableFuture activationClosed = new CompletableFuture<>(); + + boolean activate(Runnable action) { + if (!state.compareAndSet(State.OPEN, State.ACTIVATING)) { + return false; + } + try { + action.run(); + return true; + } finally { + state.set(State.CLOSED); + activationClosed.complete(null); + } + } + + CompletableFuture close() { + if (state.compareAndSet(State.OPEN, State.CLOSED)) { + activationClosed.complete(null); + } + return activationClosed; + } +} diff --git a/platform/platform-afybroker/src/main/java/taboolib/platform/AfyBrokerPlugin.java b/platform/platform-afybroker/src/main/java/taboolib/platform/AfyBrokerPlugin.java index 254d3209e..89dd3aaee 100644 --- a/platform/platform-afybroker/src/main/java/taboolib/platform/AfyBrokerPlugin.java +++ b/platform/platform-afybroker/src/main/java/taboolib/platform/AfyBrokerPlugin.java @@ -13,7 +13,9 @@ import taboolib.common.platform.Plugin; import java.io.File; +import java.util.concurrent.CompletableFuture; import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicBoolean; import static taboolib.common.PrimitiveIO.t; @@ -30,6 +32,8 @@ public class AfyBrokerPlugin extends net.afyer.afybroker.server.plugin.Plugin { @Nullable private static Plugin pluginInstance; private static AfyBrokerPlugin instance; + private final AfyBrokerActiveGate activeGate = new AfyBrokerActiveGate(); + private final AtomicBoolean disabled = new AtomicBoolean(); static { PrimitiveIO.debug("AfyBroker 插件初始化完成,用时 {0} 毫秒。", TabooLib.execution(() -> { @@ -107,12 +111,20 @@ public void onEnable() { Broker.getScheduler().schedule(this, new Runnable() { @Override public void run() { - // 生命周期任务 - TabooLib.lifeCycle(LifeCycle.ACTIVE); - // 调用 Plugin 实现的 onActive() 方法 - if (pluginInstance != null) { - pluginInstance.onActive(); - } + activeGate.activate(new Runnable() { + @Override + public void run() { + if (TabooLib.isStopped()) { + return; + } + // 生命周期任务 + TabooLib.lifeCycle(LifeCycle.ACTIVE); + // 调用 Plugin 实现的 onActive() 方法 + if (pluginInstance != null) { + pluginInstance.onActive(); + } + } + }); } }, 0, TimeUnit.MILLISECONDS); } @@ -120,12 +132,67 @@ public void run() { @Override public void onDisable() { + // 第一时间关闭激活入口;若 ACTIVE 正在执行,则在其结束后再进入 DISABLE + CompletableFuture activationClosed = activeGate.close(); + if (activationClosed.isDone()) { + disable(); + return; + } + activationClosed.whenComplete((unused, failure) -> { + if (failure != null) { + reportDisableFailure(failure); + return; + } + try { + disable(); + } catch (Throwable ex) { + reportDisableFailure(ex); + } + }); + } + + private void disable() { + if (!disabled.compareAndSet(false, true)) { + return; + } + Throwable failure = null; // 在插件未关闭的前提下,执行 onDisable() 方法 if (pluginInstance != null && !TabooLib.isStopped()) { - pluginInstance.onDisable(); + try { + pluginInstance.onDisable(); + } catch (Throwable ex) { + failure = ex; + } } - // 生命周期任务 - TabooLib.lifeCycle(LifeCycle.DISABLE); + // 生命周期任务必须执行,不能被用户回调异常跳过 + try { + TabooLib.lifeCycle(LifeCycle.DISABLE); + } catch (Throwable ex) { + if (failure == null) { + failure = ex; + } else { + failure.addSuppressed(ex); + } + } + if (failure != null) { + AfyBrokerPlugin.rethrow(failure); + } + } + + private void reportDisableFailure(Throwable ex) { + try { + PrimitiveIO.error("AfyBroker 平台禁用流程执行异常:{0}", ex.getMessage() == null ? ex.getClass().getName() : ex.getMessage()); + } catch (Throwable ignored) { + } + try { + ex.printStackTrace(); + } catch (Throwable ignored) { + } + } + + @SuppressWarnings("unchecked") + private static void rethrow(Throwable throwable) throws T { + throw (T) throwable; } @NotNull diff --git a/platform/platform-afybroker/src/main/kotlin/taboolib/platform/AfyBrokerExecutor.kt b/platform/platform-afybroker/src/main/kotlin/taboolib/platform/AfyBrokerExecutor.kt index 27272d6a0..ab20a1289 100644 --- a/platform/platform-afybroker/src/main/kotlin/taboolib/platform/AfyBrokerExecutor.kt +++ b/platform/platform-afybroker/src/main/kotlin/taboolib/platform/AfyBrokerExecutor.kt @@ -4,13 +4,16 @@ import net.afyer.afybroker.server.Broker import net.afyer.afybroker.server.scheduler.ScheduledTask import taboolib.common.Inject import taboolib.common.LifeCycle +import taboolib.common.PrimitiveIO +import taboolib.common.TabooLib import taboolib.common.platform.Awake import taboolib.common.platform.Platform import taboolib.common.platform.PlatformSide import taboolib.common.platform.service.PlatformExecutor import java.io.Closeable -import java.util.concurrent.CompletableFuture +import java.util.concurrent.RejectedExecutionException import java.util.concurrent.TimeUnit +import java.util.concurrent.atomic.AtomicBoolean /** * TabooLib @@ -24,88 +27,195 @@ import java.util.concurrent.TimeUnit @PlatformSide(Platform.AFYBROKER) class AfyBrokerExecutor : PlatformExecutor { - private val tasks = ArrayList() - private var started = false + private val tasks = AfyBrokerTaskRegistry() + + init { + TabooLib.registerLifeCycleTask(LifeCycle.DISABLE, 2) { stop() } + } @Awake(LifeCycle.ENABLE) override fun start() { - started = true - // 提交列队中的任务 - tasks.forEach { - if (it.runnable.now) { - it.execute() - } else { - it.execute(it.runnable.async, it.runnable.delay, it.runnable.period) + executeAll(tasks.start()) + } + + fun stop() { + cancelAll(tasks.stop()) + } + + private fun executeAll(pendingTasks: List) { + var failure: Throwable? = null + pendingTasks.forEach { task -> + try { + execute(task) + } catch (ex: Throwable) { + if (failure == null) { + failure = ex + } else { + failure?.addSuppressed(ex) + } } } - tasks.clear() + failure?.let { throw it } + } + + private fun cancelAll(activeTasks: List) { + var failure: Throwable? = null + activeTasks.forEach { task -> + try { + task.cancel() + } catch (ex: Throwable) { + if (failure == null) { + failure = ex + } else { + failure?.addSuppressed(ex) + } + } + } + failure?.let { throw it } + } + + private fun execute(task: AfyBrokerRunningTask) { + if (task.runnable.now) { + task.execute() + } else { + task.execute(task.runnable.async, task.runnable.delay, task.runnable.period) + } } class AfyBrokerRunningTask(val runnable: PlatformExecutor.PlatformRunnable) { + private val cancellation = AfyBrokerTaskCancellation { it.cancel() } + private var onCancelled: () -> Unit = {} + private var onCompleted: () -> Unit = {} + lateinit var scheduledTask: ScheduledTask + internal fun observe(onCancelled: () -> Unit, onCompleted: () -> Unit) { + this.onCancelled = onCancelled + this.onCompleted = onCompleted + } + fun execute() { - runnable.executor(BrokerPlatformTask { }) + executeUserTask(completeAfterRun = true) } fun execute(async: Boolean, delay: Long, period: Long) { - scheduledTask = if (period < 1) { - if (async) { - Broker.getScheduler().schedule(AfyBrokerPlugin.getInstance(), { - Broker.getScheduler().runAsync(AfyBrokerPlugin.getInstance()) { - runnable.executor(platformTask()) - } - }, delay * 50L, TimeUnit.MILLISECONDS) + if (cancellation.isCancelled()) { + onCompleted() + return + } + try { + val scheduled = if (period < 1) { + scheduleOnce(async, delay) } else { - Broker.getScheduler().schedule(AfyBrokerPlugin.getInstance(), { - runnable.executor(platformTask()) - }, delay * 50L, TimeUnit.MILLISECONDS) + scheduleRepeated(async, delay, period) } + scheduledTask = scheduled + cancellation.bind(scheduled) + } catch (ex: Throwable) { + onCompleted() + throw ex + } + } + + private fun scheduleOnce(async: Boolean, delay: Long): ScheduledTask { + return if (async) { + Broker.getScheduler().schedule(AfyBrokerPlugin.getInstance(), { + if (!cancellation.isCancelled()) { + runAfyBrokerDispatch(::reportTaskFailure, onCompleted) { + Broker.getScheduler().runAsync(AfyBrokerPlugin.getInstance()) { + executeUserTask(completeAfterRun = true) + } + } + } else { + onCompleted() + } + }, delay * 50L, TimeUnit.MILLISECONDS) } else { - if (async) { - Broker.getScheduler().schedule(AfyBrokerPlugin.getInstance(), { - Broker.getScheduler().runAsync(AfyBrokerPlugin.getInstance()) { - runnable.executor(platformTask()) + Broker.getScheduler().schedule(AfyBrokerPlugin.getInstance(), { + executeUserTask(completeAfterRun = true) + }, delay * 50L, TimeUnit.MILLISECONDS) + } + } + + private fun scheduleRepeated(async: Boolean, delay: Long, period: Long): ScheduledTask { + return if (async) { + Broker.getScheduler().schedule(AfyBrokerPlugin.getInstance(), { + if (!cancellation.isCancelled()) { + runAfyBrokerDispatch(::reportTaskFailure, ::cancel) { + Broker.getScheduler().runAsync(AfyBrokerPlugin.getInstance()) { + executeUserTask(completeAfterRun = false) + } } - }, delay * 50L, period * 50L, TimeUnit.MILLISECONDS) - } else { - Broker.getScheduler().schedule(AfyBrokerPlugin.getInstance(), { + } + }, delay * 50L, period * 50L, TimeUnit.MILLISECONDS) + } else { + Broker.getScheduler().schedule(AfyBrokerPlugin.getInstance(), { + executeUserTask(completeAfterRun = false) + }, delay * 50L, period * 50L, TimeUnit.MILLISECONDS) + } + } + + private fun executeUserTask(completeAfterRun: Boolean) { + try { + cancellation.runIfActive { + runAfyBrokerTask(::reportTaskFailure) { runnable.executor(platformTask()) - }, delay * 50L, period * 50L, TimeUnit.MILLISECONDS) + } + } + } finally { + if (completeAfterRun) { + onCompleted() } } } fun platformTask(): PlatformExecutor.PlatformTask { - return BrokerPlatformTask { scheduledTask.cancel() } + return BrokerPlatformTask { cancel() } + } + + internal fun cancel() { + if (this::scheduledTask.isInitialized) { + cancellation.bind(scheduledTask) + } + cancellation.cancel(onCancelled) + } + + private fun reportTaskFailure(ex: Throwable) { + PrimitiveIO.error( + "AfyBroker 平台任务执行异常:{0}", + ex.message ?: ex.javaClass.name + ) + ex.printStackTrace() } } override fun submit(runnable: PlatformExecutor.PlatformRunnable): PlatformExecutor.PlatformTask { val task = AfyBrokerRunningTask(runnable) - return if (started) { - if (runnable.now) { - task.execute() - } else { - task.execute(runnable.async, runnable.delay, runnable.period) - } - task.platformTask() - } else { - tasks += task - BrokerPlatformTask { - if (!task.runnable.now) { - task.platformTask().cancel() - } - tasks -= task + task.observe( + onCancelled = { tasks.remove(task) }, + onCompleted = { tasks.remove(task) } + ) + val platformTask = task.platformTask() + when (tasks.register(task)) { + AfyBrokerTaskRegistration.PENDING -> Unit + AfyBrokerTaskRegistration.ACTIVE -> execute(task) + AfyBrokerTaskRegistration.REJECTED -> { + task.cancel() + throw RejectedExecutionException("AfyBrokerExecutor has been stopped") } } + return platformTask } class BrokerPlatformTask(val runnable: Closeable) : PlatformExecutor.PlatformTask { + private val cancelled = AtomicBoolean() + override fun cancel() { - runnable.close() + if (cancelled.compareAndSet(false, true)) { + runnable.close() + } } } -} \ No newline at end of file +} diff --git a/platform/platform-afybroker/src/main/kotlin/taboolib/platform/AfyBrokerExecutorLifecycle.kt b/platform/platform-afybroker/src/main/kotlin/taboolib/platform/AfyBrokerExecutorLifecycle.kt new file mode 100644 index 000000000..9899ba0a1 --- /dev/null +++ b/platform/platform-afybroker/src/main/kotlin/taboolib/platform/AfyBrokerExecutorLifecycle.kt @@ -0,0 +1,173 @@ +package taboolib.platform + +internal enum class AfyBrokerExecutorState { + NEW, + RUNNING, + STOPPED +} + +internal enum class AfyBrokerTaskRegistration { + PENDING, + ACTIVE, + REJECTED +} + +internal class AfyBrokerTaskRegistry { + + private val lock = Any() + private val pending = LinkedHashSet() + private val active = LinkedHashSet() + private var state = AfyBrokerExecutorState.NEW + + fun register(task: T): AfyBrokerTaskRegistration { + return synchronized(lock) { + when (state) { + AfyBrokerExecutorState.NEW -> { + pending += task + AfyBrokerTaskRegistration.PENDING + } + AfyBrokerExecutorState.RUNNING -> { + active += task + AfyBrokerTaskRegistration.ACTIVE + } + AfyBrokerExecutorState.STOPPED -> AfyBrokerTaskRegistration.REJECTED + } + } + } + + fun start(): List { + return synchronized(lock) { + if (state != AfyBrokerExecutorState.NEW) { + return@synchronized emptyList() + } + state = AfyBrokerExecutorState.RUNNING + val tasks = pending.toList() + pending.clear() + active += tasks + tasks + } + } + + fun remove(task: T): Boolean { + return synchronized(lock) { + pending.remove(task) || active.remove(task) + } + } + + fun stop(): List { + return synchronized(lock) { + if (state == AfyBrokerExecutorState.STOPPED) { + return@synchronized emptyList() + } + state = AfyBrokerExecutorState.STOPPED + val tasks = ArrayList(pending.size + active.size) + tasks += pending + tasks += active + pending.clear() + active.clear() + tasks + } + } + + fun state(): AfyBrokerExecutorState { + return synchronized(lock) { state } + } + + fun pendingCount(): Int { + return synchronized(lock) { pending.size } + } + + fun activeCount(): Int { + return synchronized(lock) { active.size } + } +} + +internal class AfyBrokerTaskCancellation(private val cancelDelegate: (T) -> Unit) { + + private val lock = Any() + + @Volatile + private var cancelled = false + private var delegate: T? = null + + fun bind(value: T) { + val cancelNow = synchronized(lock) { + val current = delegate + check(current == null || current === value) { "Scheduled task is already bound" } + if (current == null) { + delegate = value + cancelled + } else { + false + } + } + if (cancelNow) { + cancelDelegate(value) + } + } + + fun cancel(afterCancellation: () -> Unit = {}): Boolean { + val bound = synchronized(lock) { + if (cancelled) { + return false + } + cancelled = true + delegate + } + try { + if (bound != null) { + cancelDelegate(bound) + } + } finally { + afterCancellation() + } + return true + } + + fun isCancelled(): Boolean { + return cancelled + } + + fun runIfActive(action: () -> Unit): Boolean { + if (cancelled) { + return false + } + action() + return true + } +} + +internal inline fun runAfyBrokerDispatch( + reporter: (Throwable) -> Unit, + cleanup: () -> Unit, + action: () -> T, +): T { + try { + return action() + } catch (ex: Throwable) { + try { + reporter(ex) + } catch (reportingFailure: Throwable) { + ex.addSuppressed(reportingFailure) + } + try { + cleanup() + } catch (cleanupFailure: Throwable) { + ex.addSuppressed(cleanupFailure) + } + throw ex + } +} + +internal inline fun runAfyBrokerTask(reporter: (Throwable) -> Unit, action: () -> T): T { + try { + return action() + } catch (ex: Throwable) { + try { + reporter(ex) + } catch (reportingFailure: Throwable) { + ex.addSuppressed(reportingFailure) + } + throw ex + } +} diff --git a/platform/platform-afybroker/src/test/kotlin/taboolib/platform/AfyBrokerExecutorLifecycleTest.kt b/platform/platform-afybroker/src/test/kotlin/taboolib/platform/AfyBrokerExecutorLifecycleTest.kt new file mode 100644 index 000000000..a5c02dbf3 --- /dev/null +++ b/platform/platform-afybroker/src/test/kotlin/taboolib/platform/AfyBrokerExecutorLifecycleTest.kt @@ -0,0 +1,249 @@ +package taboolib.platform + +import net.afyer.afybroker.server.scheduler.ScheduledTask +import org.junit.jupiter.api.Assertions.assertDoesNotThrow +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertFalse +import org.junit.jupiter.api.Assertions.assertSame +import org.junit.jupiter.api.Assertions.assertThrows +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test +import taboolib.common.platform.service.PlatformExecutor +import java.io.Closeable +import java.util.concurrent.RejectedExecutionException +import java.util.concurrent.atomic.AtomicReference + +class AfyBrokerExecutorLifecycleTest { + + @Test + fun `cancel before binding cancels delegate exactly once`() { + val delegate = ManualDelegate() + val cancellation = AfyBrokerTaskCancellation { it.cancel() } + + assertTrue(cancellation.cancel()) + assertFalse(cancellation.cancel()) + cancellation.bind(delegate) + cancellation.bind(delegate) + + assertEquals(1, delegate.cancelCount) + assertTrue(cancellation.isCancelled()) + } + + @Test + fun `cancellation cleanup runs even when delegate throws`() { + val failure = IllegalStateException("cancel failed") + val delegate = ManualDelegate() + val cancellation = AfyBrokerTaskCancellation { throw failure } + var cleanupCount = 0 + cancellation.bind(delegate) + + val thrown = assertThrows(IllegalStateException::class.java) { + cancellation.cancel { cleanupCount++ } + } + + assertSame(failure, thrown) + assertEquals(1, cleanupCount) + assertTrue(cancellation.isCancelled()) + assertFalse(cancellation.cancel { cleanupCount++ }) + assertEquals(1, cleanupCount) + } + + @Test + fun `platform task cancel is idempotent`() { + var cancelCount = 0 + val task = AfyBrokerExecutor.BrokerPlatformTask(Closeable { cancelCount++ }) + + task.cancel() + task.cancel() + + assertEquals(1, cancelCount) + } + + @Test + fun `pending task cancel does not access unbound scheduled task`() { + val runningTask = AfyBrokerExecutor.AfyBrokerRunningTask( + PlatformExecutor.PlatformRunnable(false, false, 0, 0) {} + ) + + assertDoesNotThrow { runningTask.platformTask().cancel() } + } + + @Test + fun `binding before cancel is safe and idempotent`() { + val delegate = ManualDelegate() + val cancellation = AfyBrokerTaskCancellation { it.cancel() } + + cancellation.bind(delegate) + assertEquals(0, delegate.cancelCount) + assertTrue(cancellation.cancel()) + assertFalse(cancellation.cancel()) + + assertEquals(1, delegate.cancelCount) + } + + @Test + fun `cancelled task gate rejects later execution`() { + val cancellation = AfyBrokerTaskCancellation { it.cancel() } + var executions = 0 + + cancellation.cancel() + + assertFalse(cancellation.runIfActive { executions++ }) + assertEquals(0, executions) + } + + @Test + fun `registry moves pending tasks to active and completes them`() { + val registry = AfyBrokerTaskRegistry() + + assertEquals(AfyBrokerTaskRegistration.PENDING, registry.register("pending")) + assertEquals(AfyBrokerExecutorState.NEW, registry.state()) + assertEquals(1, registry.pendingCount()) + + assertEquals(listOf("pending"), registry.start()) + assertEquals(AfyBrokerExecutorState.RUNNING, registry.state()) + assertEquals(0, registry.pendingCount()) + assertEquals(1, registry.activeCount()) + assertEquals(AfyBrokerTaskRegistration.ACTIVE, registry.register("active")) + assertTrue(registry.remove("pending")) + assertEquals(1, registry.activeCount()) + } + + @Test + fun `stop drains pending and active tasks then rejects submissions`() { + val registry = AfyBrokerTaskRegistry() + registry.register("pending") + registry.start() + registry.register("active") + + assertEquals(listOf("pending", "active"), registry.stop()) + assertEquals(AfyBrokerExecutorState.STOPPED, registry.state()) + assertEquals(0, registry.pendingCount()) + assertEquals(0, registry.activeCount()) + assertEquals(AfyBrokerTaskRegistration.REJECTED, registry.register("late")) + assertTrue(registry.stop().isEmpty()) + assertTrue(registry.start().isEmpty()) + } + + @Test + fun `stopped executor rejects now and scheduled submissions`() { + val executor = AfyBrokerExecutor() + executor.stop() + + assertThrows(RejectedExecutionException::class.java) { + executor.submit(PlatformExecutor.PlatformRunnable(true, false, 0, 0) {}) + } + assertThrows(RejectedExecutionException::class.java) { + executor.submit(PlatformExecutor.PlatformRunnable(false, false, 0, 0) {}) + } + } + + @Test + fun `async dispatch failure reports and cleans up without replacing failure`() { + val failure = RejectedExecutionException("dispatch rejected") + val cleanupFailure = IllegalStateException("cleanup failed") + var reported: Throwable? = null + var cleanupCount = 0 + + val thrown = assertThrows(RejectedExecutionException::class.java) { + runAfyBrokerDispatch( + reporter = { reported = it }, + cleanup = { + cleanupCount++ + throw cleanupFailure + }, + ) { + throw failure + } + } + + assertSame(failure, thrown) + assertSame(failure, reported) + assertEquals(1, cleanupCount) + assertEquals(listOf(cleanupFailure), failure.suppressed.toList()) + } + + @Test + fun `user task failure is reported and rethrown unchanged`() { + val failure = IllegalStateException("boom") + var reported: Throwable? = null + + val thrown = assertThrows(IllegalStateException::class.java) { + runAfyBrokerTask({ reported = it }) { + throw failure + } + } + + assertSame(failure, reported) + assertSame(failure, thrown) + } + + @Test + fun `reporter failure is suppressed without replacing user failure`() { + val failure = IllegalStateException("user") + val reporterFailure = IllegalArgumentException("reporter") + + val thrown = assertThrows(IllegalStateException::class.java) { + runAfyBrokerTask({ throw reporterFailure }) { + throw failure + } + } + + assertSame(failure, thrown) + assertEquals(listOf(reporterFailure), thrown.suppressed.toList()) + } + + @Test + fun `active gate prevents callback after disable`() { + val gate = AfyBrokerActiveGate() + var activeCalls = 0 + + assertTrue(gate.close().isDone) + assertFalse(gate.activate { activeCalls++ }) + assertEquals(0, activeCalls) + } + + @Test + fun `active gate defers disable continuation without blocking`() { + val gate = AfyBrokerActiveGate() + val order = ArrayList() + val closed = AtomicReference>() + + assertTrue(gate.activate { + order += "active-start" + closed.set(gate.close()) + assertFalse(closed.get().isDone) + closed.get().thenRun { order += "disable" } + order += "active-end" + }) + + assertTrue(closed.get().isDone) + assertEquals(listOf("active-start", "active-end", "disable"), order) + assertFalse(gate.activate { order += "late-active" }) + } + + @Test + fun `public executor contract remains compatible`() { + val executorClass = AfyBrokerExecutor::class.java + val runningTaskClass = AfyBrokerExecutor.AfyBrokerRunningTask::class.java + + executorClass.getDeclaredConstructor() + assertTrue(PlatformExecutor::class.java.isAssignableFrom(executorClass)) + assertEquals(ScheduledTask::class.java, runningTaskClass.getField("scheduledTask").type) + assertEquals(ScheduledTask::class.java, runningTaskClass.getMethod("getScheduledTask").returnType) + assertEquals( + PlatformExecutor.PlatformTask::class.java, + runningTaskClass.getMethod("platformTask").returnType + ) + } + + private class ManualDelegate { + + var cancelCount = 0 + private set + + fun cancel() { + cancelCount++ + } + } +} diff --git a/platform/platform-application/build.gradle.kts b/platform/platform-application/build.gradle.kts index 561762910..10b3997f8 100644 --- a/platform/platform-application/build.gradle.kts +++ b/platform/platform-application/build.gradle.kts @@ -3,6 +3,10 @@ dependencies { compileOnly(project(":common-env")) compileOnly(project(":common-util")) compileOnly(project(":common-platform-api")) + testImplementation(project(":common")) + testImplementation(project(":common-env")) + testImplementation(project(":common-util")) + testImplementation(project(":common-platform-api")) // 工具 implementation("net.minecrell:terminalconsoleappender:1.3.0") // implementation("org.apache.logging.log4j:log4j-api:2.17.2") diff --git a/platform/platform-application/src/main/java/taboolib/platform/App.java b/platform/platform-application/src/main/java/taboolib/platform/App.java index 5fd8797c8..ae9c8ba1a 100644 --- a/platform/platform-application/src/main/java/taboolib/platform/App.java +++ b/platform/platform-application/src/main/java/taboolib/platform/App.java @@ -1,6 +1,5 @@ package taboolib.platform; -import taboolib.common.LifeCycle; import taboolib.common.PrimitiveIO; import taboolib.common.TabooLib; import taboolib.common.classloader.IsolatedClassLoader; @@ -19,6 +18,9 @@ @PlatformSide(Platform.APPLICATION) public class App { + private static final AppLifeCycle LIFE_CYCLE = new AppLifeCycle(); + private static volatile boolean running; + static { // 如果是 Application 启动,则跳过重定向 env().skipSelfRelocate(true).skipKotlinRelocate(true); @@ -46,10 +48,21 @@ public static void init() { // 初始化 IsolatedClassLoader IsolatedClassLoader.init(App.class); // 生命周期任务 - TabooLib.lifeCycle(LifeCycle.CONST); - TabooLib.lifeCycle(LifeCycle.INIT); - TabooLib.lifeCycle(LifeCycle.LOAD); - TabooLib.lifeCycle(LifeCycle.ENABLE); + running = true; + try { + running = LIFE_CYCLE.run(TabooLib::lifeCycle) && !TabooLib.isStopped(); + if (TabooLib.isStopped()) { + LIFE_CYCLE.shutdown(TabooLib::lifeCycle); + } + } catch (RuntimeException | Error ex) { + running = false; + try { + LIFE_CYCLE.shutdown(TabooLib::lifeCycle); + } catch (RuntimeException | Error cleanupFailure) { + ex.addSuppressed(cleanupFailure); + } + throw ex; + } })); } @@ -57,7 +70,12 @@ public static void init() { * 结束 */ public static void shutdown() { - TabooLib.lifeCycle(LifeCycle.DISABLE); + running = false; + LIFE_CYCLE.shutdown(TabooLib::lifeCycle); + } + + static boolean isRunning() { + return running; } /** diff --git a/platform/platform-application/src/main/java/taboolib/platform/AppLifeCycle.java b/platform/platform-application/src/main/java/taboolib/platform/AppLifeCycle.java new file mode 100644 index 000000000..cd59cf79a --- /dev/null +++ b/platform/platform-application/src/main/java/taboolib/platform/AppLifeCycle.java @@ -0,0 +1,154 @@ +package taboolib.platform; + +import taboolib.common.LifeCycle; + +import java.util.Arrays; +import java.util.Collections; +import java.util.List; +import java.util.function.Consumer; + +final class AppLifeCycle { + + private static final List INITIALIZATION = Collections.unmodifiableList(Arrays.asList( + LifeCycle.CONST, + LifeCycle.INIT, + LifeCycle.LOAD, + LifeCycle.ENABLE, + LifeCycle.ACTIVE + )); + + private enum State { + NEW, + INITIALIZING, + ACTIVE, + STOP_REQUESTED, + DISABLING, + DISABLED + } + + private final Object lock = new Object(); + private State state = State.NEW; + private boolean transitionRunning; + + static List initialization() { + return INITIALIZATION; + } + + boolean run(Consumer action) { + synchronized (lock) { + if (state == State.INITIALIZING || state == State.ACTIVE) { + return true; + } + if (state != State.NEW) { + return false; + } + state = State.INITIALIZING; + } + for (LifeCycle lifeCycle : INITIALIZATION) { + if (!beginTransition()) { + return isRunning(); + } + Throwable failure = null; + try { + action.accept(lifeCycle); + } catch (Throwable ex) { + failure = ex; + } + boolean disable = finishTransition(); + if (disable) { + try { + runDisable(action); + } catch (Throwable ex) { + if (failure == null) { + failure = ex; + } else { + failure.addSuppressed(ex); + } + } + } + if (failure != null) { + AppLifeCycle.rethrow(failure); + } + if (disable) { + return false; + } + } + synchronized (lock) { + if (state == State.INITIALIZING) { + state = State.ACTIVE; + return true; + } + return state == State.ACTIVE; + } + } + + void shutdown(Consumer action) { + boolean disable = false; + synchronized (lock) { + switch (state) { + case NEW: + case ACTIVE: + state = State.DISABLING; + disable = true; + break; + case INITIALIZING: + state = State.STOP_REQUESTED; + if (!transitionRunning) { + state = State.DISABLING; + disable = true; + } + break; + case STOP_REQUESTED: + case DISABLING: + case DISABLED: + return; + } + } + if (disable) { + runDisable(action); + } + } + + private boolean beginTransition() { + synchronized (lock) { + if (state != State.INITIALIZING) { + return false; + } + transitionRunning = true; + return true; + } + } + + private boolean finishTransition() { + synchronized (lock) { + transitionRunning = false; + if (state == State.STOP_REQUESTED) { + state = State.DISABLING; + return true; + } + return false; + } + } + + private void runDisable(Consumer action) { + try { + action.accept(LifeCycle.DISABLE); + } finally { + synchronized (lock) { + transitionRunning = false; + state = State.DISABLED; + } + } + } + + private boolean isRunning() { + synchronized (lock) { + return state == State.INITIALIZING || state == State.ACTIVE; + } + } + + @SuppressWarnings("unchecked") + private static void rethrow(Throwable throwable) throws T { + throw (T) throwable; + } +} diff --git a/platform/platform-application/src/main/kotlin/taboolib/platform/AppCommand.kt b/platform/platform-application/src/main/kotlin/taboolib/platform/AppCommand.kt index 283f06751..f71ae62a5 100644 --- a/platform/platform-application/src/main/kotlin/taboolib/platform/AppCommand.kt +++ b/platform/platform-application/src/main/kotlin/taboolib/platform/AppCommand.kt @@ -11,6 +11,7 @@ import taboolib.common.platform.command.CommandStructure import taboolib.common.platform.command.component.CommandBase import taboolib.common.platform.function.info import taboolib.common.platform.service.PlatformCommand +import java.util.concurrent.CopyOnWriteArraySet /** * @author Score2 @@ -26,15 +27,14 @@ class AppCommand : PlatformCommand { val unknownCommandMessage: String get() = System.getProperty("taboolib.application.command.unknown.message") ?: "Unknown command." - val commands = mutableSetOf() + val commands: MutableSet = CopyOnWriteArraySet() fun register(command: Command) { commands.add(command) } fun unregister(name: String) { - commands.find { it.command.aliases.contains(name) } ?: return - unregister(name) + commands.removeIf { it.matches(name) } } fun unregister(command: Command) { @@ -46,7 +46,7 @@ class AppCommand : PlatformCommand { return } val label = if (content.contains(" ")) content.substringBefore(" ") else content - val command = commands.find { it.aliases.contains(label) } ?: return info(unknownCommandMessage) + val command = commands.find { it.matches(label) } ?: return info(unknownCommandMessage) val args = if (content.contains(" ")) content.substringAfter(" ").split(" ") else listOf() command.executor.execute(AppConsole, command.command, label, args.toTypedArray()) } @@ -57,7 +57,7 @@ class AppCommand : PlatformCommand { return suggestion() } val label = if (content.contains(" ")) content.substringBefore(" ") else content - val command = commands.find { it.aliases.contains(label) } ?: return suggestion().filter { it.startsWith(label) } + val command = commands.find { it.matches(label) } ?: return suggestion().filter { it.startsWith(label, ignoreCase = true) } return if (content.contains(" ")) { command.completer.execute(AppConsole, command.command, label, content.substringAfter(" ").split(" ").toTypedArray()) ?: listOf() } else { @@ -71,6 +71,8 @@ class AppCommand : PlatformCommand { val aliases get() = listOf(command.name, *command.aliases.toTypedArray()) + fun matches(name: String) = aliases.any { it.equals(name, ignoreCase = true) } + fun register() = register(this) fun unregister() = unregister(this) @@ -85,10 +87,10 @@ class AppCommand : PlatformCommand { } override fun unregisterCommand(command: String) { - unregister(commands.find { it.command.aliases.contains(command) } ?: return) + unregister(command) } override fun unregisterCommands() { - commands.forEach { unregister(it) } + commands.clear() } } \ No newline at end of file diff --git a/platform/platform-application/src/main/kotlin/taboolib/platform/AppConsole.kt b/platform/platform-application/src/main/kotlin/taboolib/platform/AppConsole.kt index 3000aa16a..09d2d16cd 100644 --- a/platform/platform-application/src/main/kotlin/taboolib/platform/AppConsole.kt +++ b/platform/platform-application/src/main/kotlin/taboolib/platform/AppConsole.kt @@ -16,6 +16,10 @@ import taboolib.common.platform.function.info import taboolib.common.platform.function.pluginId import taboolib.common.platform.function.pluginVersion +internal fun isApplicationRunning(running: Boolean, stopped: Boolean): Boolean { + return running && !stopped +} + /** * @author Score2 * @since 2022/06/08 13:37 @@ -75,7 +79,7 @@ object AppConsole : SimpleTerminalConsole(), ProxyCommandSender { } override fun isRunning(): Boolean { - return !TabooLib.isStopped() + return isApplicationRunning(App.isRunning(), TabooLib.isStopped()) } override fun buildReader(builder: LineReaderBuilder): LineReader { diff --git a/platform/platform-application/src/main/kotlin/taboolib/platform/AppExecutor.kt b/platform/platform-application/src/main/kotlin/taboolib/platform/AppExecutor.kt index 9ee1d5602..efe0e5ac7 100644 --- a/platform/platform-application/src/main/kotlin/taboolib/platform/AppExecutor.kt +++ b/platform/platform-application/src/main/kotlin/taboolib/platform/AppExecutor.kt @@ -1,13 +1,23 @@ package taboolib.platform import taboolib.common.Inject +import taboolib.common.LifeCycle +import taboolib.common.PrimitiveIO +import taboolib.common.TabooLib import taboolib.common.platform.Awake import taboolib.common.platform.Platform import taboolib.common.platform.PlatformSide import taboolib.common.platform.service.PlatformExecutor import java.util.concurrent.CompletableFuture import java.util.concurrent.Executors +import java.util.concurrent.Future +import java.util.concurrent.RejectedExecutionException +import java.util.concurrent.ScheduledExecutorService +import java.util.concurrent.ThreadFactory import java.util.concurrent.TimeUnit +import java.util.concurrent.atomic.AtomicBoolean +import java.util.concurrent.atomic.AtomicInteger +import java.util.concurrent.atomic.AtomicReference /** * TabooLib @@ -19,42 +29,129 @@ import java.util.concurrent.TimeUnit @Awake @Inject @PlatformSide(Platform.APPLICATION) -class AppExecutor : PlatformExecutor { +class AppExecutor private constructor( + private val executor: ScheduledExecutorService, + private val exceptionReporter: (Throwable) -> Unit, + registerStopTask: Boolean, +) : PlatformExecutor { - private val tasks = ArrayList() - private val executor = Executors.newScheduledThreadPool(16) + constructor() : this(createExecutor(), ::reportTaskException, true) + internal enum class State { + NEW, RUNNING, STOPPED + } + + private val state = AtomicReference(State.NEW) + + init { + if (registerStopTask) { + TabooLib.registerLifeCycleTask(LifeCycle.DISABLE, 2) { stop() } + } + } + + @Awake(LifeCycle.ENABLE) override fun start() { + state.compareAndSet(State.NEW, State.RUNNING) } override fun submit(runnable: PlatformExecutor.PlatformRunnable): PlatformExecutor.PlatformTask { - val future = CompletableFuture() - val task = AppPlatformTask(future) - val scheduledTask = when { - runnable.now -> { - runnable.executor(task) - null - } - runnable.period > 0 -> { - executor.scheduleAtFixedRate({ runnable.executor(task) }, runnable.delay * 50L, runnable.period * 50L, TimeUnit.MILLISECONDS) - } - runnable.delay > 0 -> { - executor.schedule({ runnable.executor(task) }, runnable.delay * 50L, TimeUnit.MILLISECONDS) - } - else -> { - executor.submit { runnable.executor(task) } + rejectIfStopped() + val task = AppPlatformTask() + if (runnable.now) { + executeUserTask(task, runnable) + return task + } + val command = Runnable { + if (!task.isCancelled) { + executeUserTask(task, runnable) } } - future.thenAccept { - scheduledTask?.cancel(false) + val future = when { + runnable.period > 0 -> executor.scheduleAtFixedRate(command, runnable.delay * 50L, runnable.period * 50L, TimeUnit.MILLISECONDS) + runnable.delay > 0 -> executor.schedule(command, runnable.delay * 50L, TimeUnit.MILLISECONDS) + else -> executor.schedule(command, 0L, TimeUnit.MILLISECONDS) } + task.attach(future) return task } - class AppPlatformTask(private val future: CompletableFuture) : PlatformExecutor.PlatformTask { + fun stop() { + if (state.getAndSet(State.STOPPED) != State.STOPPED) { + executor.shutdownNow() + } + } + + internal fun currentState(): State = state.get() + + private fun rejectIfStopped() { + if (state.get() == State.STOPPED) { + throw RejectedExecutionException("AppExecutor has been stopped") + } + } + + private fun executeUserTask(task: AppPlatformTask, runnable: PlatformExecutor.PlatformRunnable) { + runAppTask(exceptionReporter) { runnable.executor(task) } + } + + class AppPlatformTask() : PlatformExecutor.PlatformTask { + + private val cancelled = AtomicBoolean(false) + private val future = AtomicReference?>() + private var cancellationSignal: CompletableFuture? = null + + constructor(cancellationSignal: CompletableFuture) : this() { + this.cancellationSignal = cancellationSignal + } + + internal val isCancelled: Boolean + get() = cancelled.get() + + internal fun attach(scheduled: Future<*>) { + check(future.compareAndSet(null, scheduled)) { "Scheduled task is already bound" } + if (cancelled.get()) { + scheduled.cancel(false) + } + } override fun cancel() { - future.complete(null) + if (cancelled.compareAndSet(false, true)) { + cancellationSignal?.complete(null) + future.get()?.cancel(false) + } + } + } + + companion object { + + private fun createExecutor(): ScheduledExecutorService { + return Executors.newScheduledThreadPool(16, AppExecutorThreadFactory()) + } + + private fun reportTaskException(ex: Throwable) { + PrimitiveIO.error("Application 平台任务执行异常:{0}", ex.message ?: ex.javaClass.name) + ex.printStackTrace() + } + } +} + +internal class AppExecutorThreadFactory : ThreadFactory { + + private val counter = AtomicInteger() + + override fun newThread(runnable: Runnable): Thread { + return Thread(runnable, "TabooLib-Application-Executor-${counter.incrementAndGet()}") + } +} + +internal inline fun runAppTask(reporter: (Throwable) -> Unit, action: () -> T): T { + try { + return action() + } catch (ex: Throwable) { + try { + reporter(ex) + } catch (reportingFailure: Throwable) { + ex.addSuppressed(reportingFailure) } + throw ex } -} \ No newline at end of file +} diff --git a/platform/platform-application/src/main/kotlin/taboolib/platform/AppIO.kt b/platform/platform-application/src/main/kotlin/taboolib/platform/AppIO.kt index c8cb79f6b..858906a5b 100644 --- a/platform/platform-application/src/main/kotlin/taboolib/platform/AppIO.kt +++ b/platform/platform-application/src/main/kotlin/taboolib/platform/AppIO.kt @@ -81,12 +81,14 @@ class AppIO : PlatformIO { if (file.exists() && !replace) { return file } - newFile(file).writeBytes(javaClass.classLoader.getResourceAsStream(source)?.readBytes() ?: error("resource not found: $source")) + val content = javaClass.classLoader.getResourceAsStream(source)?.use { it.readBytes() } + ?: error("resource not found: $source") + newFile(file).writeBytes(content) return file } override fun getJarFile(): File { - return File(AppIO::class.java.protectionDomain.codeSource.location.toURI().path) + return File(AppIO::class.java.protectionDomain.codeSource.location.toURI()) } override fun getDataFolder(): File { diff --git a/platform/platform-application/src/test/kotlin/taboolib/platform/ApplicationPlatformTest.kt b/platform/platform-application/src/test/kotlin/taboolib/platform/ApplicationPlatformTest.kt new file mode 100644 index 000000000..4d8f7ea0a --- /dev/null +++ b/platform/platform-application/src/test/kotlin/taboolib/platform/ApplicationPlatformTest.kt @@ -0,0 +1,206 @@ +package taboolib.platform + +import org.junit.jupiter.api.AfterEach +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertFalse +import org.junit.jupiter.api.Assertions.assertThrows +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test +import taboolib.common.LifeCycle +import taboolib.common.platform.command.CommandCompleter +import taboolib.common.platform.command.CommandExecutor +import taboolib.common.platform.command.CommandStructure +import taboolib.common.platform.command.PermissionDefault +import taboolib.common.platform.service.PlatformExecutor +import java.lang.reflect.Modifier +import java.util.concurrent.CompletableFuture +import java.util.concurrent.FutureTask +import java.util.concurrent.RejectedExecutionException +import java.util.concurrent.atomic.AtomicInteger + +class ApplicationPlatformTest { + + @AfterEach + fun cleanupCommands() { + AppCommand.commands.clear() + } + + @Test + fun `initialization lifecycle reaches active in order`() { + assertEquals( + listOf(LifeCycle.CONST, LifeCycle.INIT, LifeCycle.LOAD, LifeCycle.ENABLE, LifeCycle.ACTIVE), + AppLifeCycle.initialization() + ) + } + + @Test + fun `shutdown during enable prevents active lifecycle regression`() { + val lifeCycle = AppLifeCycle() + val calls = ArrayList() + + val running = lifeCycle.run { + calls += it + if (it == LifeCycle.ENABLE) { + lifeCycle.shutdown { calls += it } + } + } + lifeCycle.shutdown { calls += it } + + assertFalse(running) + assertEquals( + listOf(LifeCycle.CONST, LifeCycle.INIT, LifeCycle.LOAD, LifeCycle.ENABLE, LifeCycle.DISABLE), + calls + ) + } + + @Test + fun `console stops running at disable`() { + assertTrue(isApplicationRunning(true, false)) + assertFalse(isApplicationRunning(false, false)) + assertFalse(isApplicationRunning(true, true)) + assertTrue(Modifier.isVolatile(App::class.java.getDeclaredField("running").modifiers)) + } + + @Test + fun `command unregister matches primary name and aliases`() { + val service = AppCommand() + val primary = command("primary", listOf("alias")) + val other = command("other", listOf("secondary")) + primary.register() + other.register() + + service.unregisterCommand("PRIMARY") + assertEquals(setOf(other), AppCommand.commands) + + service.unregisterCommand("SECONDARY") + assertTrue(AppCommand.commands.isEmpty()) + } + + @Test + fun `command bulk unregister clears concurrent set`() { + val service = AppCommand() + command("one").register() + command("two").register() + + service.unregisterCommands() + + assertTrue(AppCommand.commands.isEmpty()) + assertEquals(java.util.Set::class.java, AppCommand.Companion::class.java.getMethod("getCommands").returnType) + } + + @Test + fun `executor has explicit lifecycle and rejects all tasks after stop`() { + val executor = AppExecutor() + try { + assertEquals(AppExecutor.State.NEW, executor.currentState()) + executor.start() + assertEquals(AppExecutor.State.RUNNING, executor.currentState()) + executor.stop() + assertEquals(AppExecutor.State.STOPPED, executor.currentState()) + + assertThrows(RejectedExecutionException::class.java) { + executor.submit(runnable(now = true) {}) + } + assertThrows(RejectedExecutionException::class.java) { + executor.submit(runnable(now = false) {}) + } + } finally { + executor.stop() + } + } + + @Test + fun `executor keeps immediate pre-start behavior and exposes task failures`() { + val executor = AppExecutor() + val executions = AtomicInteger() + try { + executor.submit(runnable(now = true) { executions.incrementAndGet() }) + assertEquals(1, executions.get()) + assertThrows(IllegalStateException::class.java) { + executor.submit(runnable(now = true) { error("observable") }) + } + } finally { + executor.stop() + } + } + + @Test + fun `executor task failure is reported and rethrown unchanged`() { + val failure = IllegalStateException("boom") + var reported: Throwable? = null + + val thrown = assertThrows(IllegalStateException::class.java) { + runAppTask({ reported = it }) { throw failure } + } + + assertTrue(reported === failure) + assertTrue(thrown === failure) + } + + @Test + fun `executor task cancellation is idempotent and worker threads are named`() { + val task = AppExecutor.AppPlatformTask() + val future = RecordingFuture() + task.cancel() + task.attach(future) + task.cancel() + assertTrue(task.isCancelled) + assertEquals(1, future.cancelCount) + + val cancellationSignal = CompletableFuture() + val compatibleTask = AppExecutor.AppPlatformTask(cancellationSignal) + compatibleTask.cancel() + compatibleTask.cancel() + assertTrue(cancellationSignal.isDone) + AppExecutor.AppPlatformTask::class.java.getConstructor(CompletableFuture::class.java) + + val factory = AppExecutorThreadFactory() + assertEquals("TabooLib-Application-Executor-1", factory.newThread {}.name) + assertEquals("TabooLib-Application-Executor-2", factory.newThread {}.name) + } + + private fun runnable(now: Boolean, block: PlatformExecutor.PlatformTask.() -> Unit): PlatformExecutor.PlatformRunnable { + return PlatformExecutor.PlatformRunnable(now, async = false, delay = 0, period = 0, executor = block) + } + + private class RecordingFuture : FutureTask(Runnable {}, Unit) { + + var cancelCount = 0 + + override fun cancel(mayInterruptIfRunning: Boolean): Boolean { + cancelCount++ + return super.cancel(mayInterruptIfRunning) + } + } + + private fun command(name: String, aliases: List = emptyList()): AppCommand.Command { + val structure = CommandStructure( + name, + aliases, + "", + "", + "", + "", + PermissionDefault.TRUE, + emptyMap(), + false + ) + val executor = object : CommandExecutor { + override fun execute( + sender: taboolib.common.platform.ProxyCommandSender, + command: CommandStructure, + name: String, + args: Array + ): Boolean = true + } + val completer = object : CommandCompleter { + override fun execute( + sender: taboolib.common.platform.ProxyCommandSender, + command: CommandStructure, + name: String, + args: Array + ): List = emptyList() + } + return AppCommand.Command(structure, executor, completer) {} + } +} diff --git a/platform/platform-bukkit-impl/src/main/kotlin/taboolib/platform/BukkitCommand.kt b/platform/platform-bukkit-impl/src/main/kotlin/taboolib/platform/BukkitCommand.kt index e68cda3f4..4749b03d2 100644 --- a/platform/platform-bukkit-impl/src/main/kotlin/taboolib/platform/BukkitCommand.kt +++ b/platform/platform-bukkit-impl/src/main/kotlin/taboolib/platform/BukkitCommand.kt @@ -31,6 +31,25 @@ import taboolib.common.platform.service.PlatformCommand import taboolib.common.util.unsafeLazy import java.lang.reflect.Constructor +internal fun commandLabelMatches(name: String, aliases: List, input: String, namespace: String): Boolean { + val separator = input.indexOf(':') + val label = if (separator >= 0) { + if (!input.substring(0, separator).equals(namespace, ignoreCase = true)) { + return false + } + input.substring(separator + 1) + } else { + input + } + return label.equals(name, ignoreCase = true) || aliases.any { it.equals(label, ignoreCase = true) } +} + +internal fun removeMappingsByIdentity(commands: MutableMap, target: T): Boolean { + val keys = commands.filterValues { it === target }.keys.toList() + keys.forEach(commands::remove) + return keys.isNotEmpty() +} + /** * TabooLib * taboolib.platform.BukkitCommand @@ -62,8 +81,12 @@ class BukkitCommand : PlatformCommand { val registeredCommands = ArrayList() + private val commandLock = Any() + private val registeredCommandBindings = ArrayList() private var isSupportedUnknownCommand = false + private data class RegisteredCommand(val structure: CommandStructure, val command: PluginCommand) + override fun registerCommand( command: CommandStructure, executor: CommandExecutor, @@ -109,14 +132,6 @@ class BukkitCommand : PlatformCommand { command.permissionChildren.forEach { registerPermission(it.key, it.value) } - // 注册命令 - knownCommands.remove(command.name) - knownCommands["${plugin.name.lowercase()}:${pluginCommand.name}"] = pluginCommand - knownCommands[pluginCommand.name] = pluginCommand - pluginCommand.aliases.forEach { - knownCommands[it] = pluginCommand - } - pluginCommand.register(commandMap) // 1.8 patch runCatching { if (pluginCommand.getProperty("timings") == null) { @@ -124,19 +139,55 @@ class BukkitCommand : PlatformCommand { pluginCommand.setProperty("timings", timingsManager.invokeMethod("getCommandTiming", plugin.name, pluginCommand, isStatic = true)) } } + // 注册命令及身份记录作为同一个事务;同名重注册前先清理旧实例的全部映射 + synchronized(commandLock) { + registeredCommandBindings + .filter { it.structure.name.equals(command.name, ignoreCase = true) } + .toList() + .forEach(::unregisterBinding) + knownCommands["${plugin.name.lowercase()}:${pluginCommand.name}"] = pluginCommand + knownCommands[pluginCommand.name] = pluginCommand + pluginCommand.aliases.forEach { + knownCommands[it] = pluginCommand + } + pluginCommand.register(commandMap) + registeredCommands.add(command) + registeredCommandBindings.add(RegisteredCommand(command, pluginCommand)) + } sync() - registeredCommands.add(command) } } override fun unregisterCommand(command: String) { - knownCommands.remove(command) - sync() + val removed = synchronized(commandLock) { + registeredCommandBindings + .filter { commandLabelMatches(it.structure.name, it.structure.aliases, command, plugin.name.lowercase()) } + .toList() + .also { it.forEach(::unregisterBinding) } + .isNotEmpty() + } + if (removed) { + sync() + } } override fun unregisterCommands() { - registeredCommands.forEach { taboolib.common.platform.function.unregisterCommand(it) } - sync() + val removed = synchronized(commandLock) { + registeredCommandBindings.toList().also { it.forEach(::unregisterBinding) }.isNotEmpty() + } + if (removed) { + sync() + } + } + + private fun unregisterBinding(binding: RegisteredCommand) { + removeMappingsByIdentity(knownCommands, binding.command) + binding.command.unregister(commandMap) + registeredCommandBindings.remove(binding) + val index = registeredCommands.indexOfFirst { it === binding.structure } + if (index >= 0) { + registeredCommands.removeAt(index) + } } override fun unknownCommand(sender: ProxyCommandSender, command: String, state: Int) { diff --git a/platform/platform-bukkit-impl/src/test/kotlin/taboolib/platform/BukkitCommandRegistryTest.kt b/platform/platform-bukkit-impl/src/test/kotlin/taboolib/platform/BukkitCommandRegistryTest.kt new file mode 100644 index 000000000..233b80bc4 --- /dev/null +++ b/platform/platform-bukkit-impl/src/test/kotlin/taboolib/platform/BukkitCommandRegistryTest.kt @@ -0,0 +1,60 @@ +package taboolib.platform + +import org.junit.jupiter.api.Assertions.assertFalse +import org.junit.jupiter.api.Assertions.assertSame +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test + +class BukkitCommandRegistryTest { + + @Test + fun `matches primary aliases and own namespace`() { + assertTrue(commandLabelMatches("main", listOf("alias"), "main", "plugin")) + assertTrue(commandLabelMatches("main", listOf("alias"), "alias", "plugin")) + assertTrue(commandLabelMatches("main", listOf("alias"), "plugin:main", "plugin")) + assertTrue(commandLabelMatches("main", listOf("alias"), "plugin:alias", "plugin")) + assertTrue(commandLabelMatches("main", listOf("alias"), "PLUGIN:MAIN", "plugin")) + assertTrue(commandLabelMatches("main", listOf("alias"), "ALIAS", "plugin")) + assertFalse(commandLabelMatches("main", listOf("alias"), "other:main", "plugin")) + assertFalse(commandLabelMatches("main", listOf("alias"), "missing", "plugin")) + } + + @Test + fun `re-registration cleanup removes old and new aliases by identity`() { + val old = Any() + val replacement = Any() + val commands = linkedMapOf( + "main" to old, + "old-alias" to old, + "plugin:main" to old, + ) + + assertTrue(removeMappingsByIdentity(commands, old)) + commands["main"] = replacement + commands["new-alias"] = replacement + commands["plugin:main"] = replacement + assertFalse(commands.containsKey("old-alias")) + + assertTrue(removeMappingsByIdentity(commands, replacement)) + assertTrue(commands.isEmpty()) + } + + @Test + fun `removes every mapping for the same command instance`() { + val target = Any() + val other = Any() + val commands = linkedMapOf( + "main" to target, + "alias" to target, + "plugin:main" to target, + "other" to other, + ) + + assertTrue(removeMappingsByIdentity(commands, target)) + assertSame(other, commands["other"]) + assertFalse(commands.containsKey("main")) + assertFalse(commands.containsKey("alias")) + assertFalse(commands.containsKey("plugin:main")) + assertFalse(removeMappingsByIdentity(commands, target)) + } +} diff --git a/platform/platform-velocity-impl/build.gradle.kts b/platform/platform-velocity-impl/build.gradle.kts index bed6616b4..db2936291 100644 --- a/platform/platform-velocity-impl/build.gradle.kts +++ b/platform/platform-velocity-impl/build.gradle.kts @@ -8,4 +8,10 @@ dependencies { compileOnly(project(":common-platform-api")) compileOnly(project(":platform:platform-velocity")) compileOnly("com.velocitypowered:velocity-api:3.1.1") + + testImplementation(project(":common")) + testImplementation(project(":common-util")) + testImplementation(project(":common-platform-api")) + testImplementation(project(":platform:platform-velocity")) + testImplementation("com.velocitypowered:velocity-api:3.1.1") } \ No newline at end of file diff --git a/platform/platform-velocity-impl/src/main/kotlin/taboolib/platform/VelocityExecutor.kt b/platform/platform-velocity-impl/src/main/kotlin/taboolib/platform/VelocityExecutor.kt index cfd1e2744..431cd9448 100644 --- a/platform/platform-velocity-impl/src/main/kotlin/taboolib/platform/VelocityExecutor.kt +++ b/platform/platform-velocity-impl/src/main/kotlin/taboolib/platform/VelocityExecutor.kt @@ -1,18 +1,24 @@ package taboolib.platform import com.velocitypowered.api.scheduler.ScheduledTask +import org.slf4j.LoggerFactory import taboolib.common.Inject import taboolib.common.LifeCycle import taboolib.common.platform.Awake import taboolib.common.platform.Platform import taboolib.common.platform.PlatformSide +import taboolib.common.platform.function.registerLifeCycleTask import taboolib.common.platform.service.PlatformExecutor import taboolib.common.util.unsafeLazy import java.io.Closeable -import java.util.concurrent.CompletableFuture +import java.util.concurrent.ExecutorService import java.util.concurrent.Executors +import java.util.concurrent.RejectedExecutionException +import java.util.concurrent.ThreadFactory import java.util.concurrent.TimeUnit -import kotlin.text.repeat +import java.util.concurrent.atomic.AtomicBoolean +import java.util.concurrent.atomic.AtomicInteger +import java.util.concurrent.atomic.AtomicReference /** * TabooLib @@ -24,110 +30,325 @@ import kotlin.text.repeat @Awake @Inject @PlatformSide(Platform.VELOCITY) -class VelocityExecutor : PlatformExecutor { +class VelocityExecutor internal constructor( + private val taskScheduler: VelocityTaskScheduler?, + private val asyncExecutor: ExecutorService, + private val exceptionReporter: (Throwable) -> Unit, + registerStopTask: Boolean, +) : PlatformExecutor { - private val tasks = ArrayList() - private var started = false - private val executor = Executors.newFixedThreadPool(16) + constructor() : this(null, createAsyncExecutor(), ::reportTaskException, true) + + internal enum class State { + NEW, RUNNING, STOPPED + } + + private val lock = Any() + private val pendingTasks = LinkedHashSet() + private val activeTasks = LinkedHashSet() + + @Volatile + private var state = State.NEW val plugin by unsafeLazy { VelocityPlugin.getInstance() } + init { + if (registerStopTask) { + registerLifeCycleTask(LifeCycle.DISABLE, 2) { stop() } + } + } + @Awake(LifeCycle.ENABLE) override fun start() { - started = true + val tasks = synchronized(lock) { + when (state) { + State.NEW -> { + state = State.RUNNING + pendingTasks.filterNotTo(ArrayList()) { it.isCancelled }.also { + pendingTasks.clear() + activeTasks.addAll(it) + } + } + State.RUNNING, State.STOPPED -> return + } + } + var failure: Throwable? = null + tasks.forEach { + try { + launch(it) + } catch (ex: Throwable) { + if (failure == null) { + failure = ex + } else { + failure?.addSuppressed(ex) + } + } + } + failure?.let { throw it } + } + + fun stop() { + val tasks = synchronized(lock) { + if (state == State.STOPPED) { + return + } + state = State.STOPPED + LinkedHashSet().also { + it.addAll(pendingTasks) + it.addAll(activeTasks) + pendingTasks.clear() + activeTasks.clear() + } + } + var failure: Throwable? = null tasks.forEach { - if (it.runnable.now) { - it.executeNow() + try { + it.cancel() + } catch (ex: Throwable) { + if (failure == null) { + failure = ex + } else { + failure?.addSuppressed(ex) + } + } + } + try { + asyncExecutor.shutdownNow() + } catch (ex: Throwable) { + if (failure == null) { + failure = ex } else { - it.execute() + failure?.addSuppressed(ex) } } - tasks.clear() + failure?.let { throw it } } fun execute(velocityRunningTask: VelocityRunningTask, runnable: PlatformExecutor.PlatformRunnable): ScheduledTask { - + val action = Runnable { executeScheduled(velocityRunningTask, runnable) } + taskScheduler?.let { return it.schedule(velocityRunningTask, runnable, action) } return when { runnable.period > 0 -> plugin.server.scheduler - .buildTask(plugin) { - if (runnable.async) { - executor.submit { runnable.executor(velocityRunningTask.platformTask()) } - } else { - runnable.executor(velocityRunningTask.platformTask()) - } - } + .buildTask(plugin, action) .delay(runnable.delay * 50, TimeUnit.MILLISECONDS) .repeat(runnable.period * 50, TimeUnit.MILLISECONDS) .schedule() runnable.delay > 0 -> plugin.server.scheduler - .buildTask(plugin) { - if (runnable.async) { - executor.submit { runnable.executor(velocityRunningTask.platformTask()) } - } else { - runnable.executor(velocityRunningTask.platformTask()) - } - } + .buildTask(plugin, action) .delay(runnable.delay * 50, TimeUnit.MILLISECONDS) .schedule() - else -> plugin.server.scheduler - .buildTask(plugin) { - if (runnable.async) { - executor.submit { runnable.executor(velocityRunningTask.platformTask()) } - } else { - runnable.executor(velocityRunningTask.platformTask()) + else -> plugin.server.scheduler.buildTask(plugin, action).schedule() + } + } + + override fun submit(runnable: PlatformExecutor.PlatformRunnable): PlatformExecutor.PlatformTask { + val task = VelocityRunningTask(this, runnable) + val launchNow = synchronized(lock) { + when (state) { + State.NEW -> { + pendingTasks += task + false + } + State.RUNNING -> { + activeTasks += task + true + } + State.STOPPED -> throw RejectedExecutionException("VelocityExecutor has been stopped") + } + } + if (launchNow) { + launch(task) + } + return task.platformTask() + } + + private fun launch(task: VelocityRunningTask) { + if (task.isCancelled) { + taskFinished(task) + return + } + if (task.runnable.now) { + try { + task.executeNow() + } finally { + taskFinished(task) + } + } else { + try { + task.execute() + } catch (ex: Throwable) { + taskFinished(task) + throw ex + } + } + } + + private fun executeScheduled(task: VelocityRunningTask, runnable: PlatformExecutor.PlatformRunnable) { + if (task.isCancelled) { + return + } + if (runnable.async) { + val started = AtomicBoolean(false) + try { + asyncExecutor.execute { + started.set(true) + executeUserTask(task, runnable) + } + } catch (ex: Throwable) { + if (!started.get()) { + reportTaskFailure(ex) + try { + task.cancel() + } catch (cancellationFailure: Throwable) { + ex.addSuppressed(cancellationFailure) } - }.schedule() + } + throw ex + } + } else { + executeUserTask(task, runnable) + } + } + + private fun executeUserTask(task: VelocityRunningTask, runnable: PlatformExecutor.PlatformRunnable) { + if (task.isCancelled) { + return + } + try { + runnable.executor(task.platformTask()) + } catch (ex: Throwable) { + reportTaskFailure(ex) + throw ex + } finally { + if (runnable.period <= 0) { + taskFinished(task) + } + } + } + + private fun reportTaskFailure(ex: Throwable) { + try { + exceptionReporter(ex) + } catch (reportingFailure: Throwable) { + ex.addSuppressed(reportingFailure) + } + } + + private fun taskFinished(task: VelocityRunningTask) { + synchronized(lock) { + pendingTasks -= task + activeTasks -= task } } + internal fun taskCancelled(task: VelocityRunningTask) { + taskFinished(task) + } + + internal fun currentState(): State = state + + internal fun pendingTaskCount(): Int = synchronized(lock) { pendingTasks.size } + + internal fun activeTaskCount(): Int = synchronized(lock) { activeTasks.size } + class VelocityRunningTask(val executor: VelocityExecutor, val runnable: PlatformExecutor.PlatformRunnable) { lateinit var scheduledTask: ScheduledTask + private val cancelled = AtomicBoolean(false) + private val scheduledTaskReference = AtomicReference() + private val scheduledTaskCancelled = AtomicBoolean(false) + + internal val isCancelled: Boolean + get() = cancelled.get() + fun executeNow() { - runnable.executor(VelocityPlatformTask { }) + if (!isCancelled) { + executor.executeUserTask(this, runnable) + } } fun execute() { - scheduledTask = executor.execute(this, runnable) + if (isCancelled) { + return + } + val task = executor.execute(this, runnable) + scheduledTask = task + bind(task) } fun platformTask(): PlatformExecutor.PlatformTask { - return VelocityPlatformTask { scheduledTask.cancel() } + return VelocityPlatformTask { cancel() } } - } - override fun submit(runnable: PlatformExecutor.PlatformRunnable): PlatformExecutor.PlatformTask { - - val task = VelocityRunningTask(this, runnable) + fun cancel() { + if (cancelled.compareAndSet(false, true)) { + try { + val scheduled = scheduledTaskReference.get() + ?: if (this::scheduledTask.isInitialized) scheduledTask else null + scheduled?.let(::cancelScheduledTask) + } finally { + executor.taskCancelled(this) + } + } + } - return if (started) { - if (runnable.now) { - task.executeNow() - VelocityPlatformTask { } - } else { - task.execute() - task.platformTask() + private fun bind(task: ScheduledTask) { + check(scheduledTaskReference.compareAndSet(null, task)) { "Scheduled task is already bound" } + if (isCancelled) { + cancelScheduledTask(task) } - } else { - tasks += task - VelocityPlatformTask { - if (!runnable.now) { - task.platformTask().cancel() - } - tasks -= task + } + + private fun cancelScheduledTask(task: ScheduledTask) { + if (scheduledTaskCancelled.compareAndSet(false, true)) { + task.cancel() } } } class VelocityPlatformTask(val runnable: Closeable) : PlatformExecutor.PlatformTask { + private val cancelled = AtomicBoolean(false) + override fun cancel() { - runnable.close() + if (cancelled.compareAndSet(false, true)) { + runnable.close() + } } } -} \ No newline at end of file + + companion object { + + private fun createAsyncExecutor(): ExecutorService { + return Executors.newFixedThreadPool(16, VelocityAsyncThreadFactory()) + } + + private fun reportTaskException(ex: Throwable) { + val logger = try { + VelocityPlugin.getInstance().logger + } catch (_: Throwable) { + LoggerFactory.getLogger(VelocityExecutor::class.java) + } + logger.error("Unhandled exception in a TabooLib Velocity task", ex) + } + } +} + +internal interface VelocityTaskScheduler { + + fun schedule(task: VelocityExecutor.VelocityRunningTask, runnable: PlatformExecutor.PlatformRunnable, action: Runnable): ScheduledTask +} + +internal class VelocityAsyncThreadFactory : ThreadFactory { + + private val counter = AtomicInteger() + + override fun newThread(runnable: Runnable): Thread { + return Thread(runnable, "TabooLib-Velocity-Async-${counter.incrementAndGet()}") + } +} diff --git a/platform/platform-velocity-impl/src/test/kotlin/taboolib/platform/VelocityExecutorTest.kt b/platform/platform-velocity-impl/src/test/kotlin/taboolib/platform/VelocityExecutorTest.kt new file mode 100644 index 000000000..1e730b794 --- /dev/null +++ b/platform/platform-velocity-impl/src/test/kotlin/taboolib/platform/VelocityExecutorTest.kt @@ -0,0 +1,285 @@ +package taboolib.platform + +import com.velocitypowered.api.scheduler.ScheduledTask +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertFalse +import org.junit.jupiter.api.Assertions.assertSame +import org.junit.jupiter.api.Assertions.assertThrows +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test +import taboolib.common.platform.service.PlatformExecutor +import java.lang.reflect.Proxy +import java.util.concurrent.AbstractExecutorService +import java.util.concurrent.ExecutorService +import java.util.concurrent.RejectedExecutionException +import java.util.concurrent.TimeUnit + +class VelocityExecutorTest { + + @Test + fun `cancelled pending task never reads lateinit or gets scheduled`() { + val scheduler = RecordingScheduler() + val executor = executor(scheduler) + val task = executor.submit(runnable()) + + task.cancel() + task.cancel() + executor.start() + + assertEquals(0, scheduler.scheduled.size) + assertEquals(0, executor.pendingTaskCount()) + assertEquals(0, executor.activeTaskCount()) + } + + @Test + fun `cancellation before scheduled handle binding cancels handle exactly once`() { + val scheduler = RecordingScheduler { task, _ -> task.cancel() } + val executor = executor(scheduler) + executor.start() + + val task = executor.submit(runnable()) + task.cancel() + + assertEquals(1, scheduler.scheduled.single().cancelCount) + assertEquals(0, executor.activeTaskCount()) + } + + @Test + fun `cancellation after scheduled handle binding is idempotent`() { + val scheduler = RecordingScheduler() + val executor = executor(scheduler) + executor.start() + + val task = executor.submit(runnable()) + task.cancel() + task.cancel() + + assertEquals(1, scheduler.scheduled.single().cancelCount) + assertEquals(0, executor.activeTaskCount()) + } + + @Test + fun `start moves pending tasks and stop is terminal`() { + val scheduler = RecordingScheduler() + val asyncExecutor = DirectExecutorService() + val executor = executor(scheduler, asyncExecutor) + executor.submit(runnable()) + + assertEquals(VelocityExecutor.State.NEW, executor.currentState()) + assertEquals(1, executor.pendingTaskCount()) + + executor.start() + assertEquals(VelocityExecutor.State.RUNNING, executor.currentState()) + assertEquals(0, executor.pendingTaskCount()) + assertEquals(1, executor.activeTaskCount()) + + executor.stop() + executor.stop() + + assertEquals(VelocityExecutor.State.STOPPED, executor.currentState()) + assertEquals(0, executor.activeTaskCount()) + assertEquals(1, scheduler.scheduled.single().cancelCount) + assertEquals(1, asyncExecutor.shutdownNowCount) + assertThrows(RejectedExecutionException::class.java) { executor.submit(runnable(now = true)) } + assertThrows(RejectedExecutionException::class.java) { executor.submit(runnable()) } + } + + @Test + fun `stop continues cleanup when one scheduled cancellation fails`() { + val scheduler = RecordingScheduler() + val asyncExecutor = DirectExecutorService() + val executor = executor(scheduler, asyncExecutor) + val failure = IllegalStateException("cancel failed") + executor.start() + executor.submit(runnable()) + executor.submit(runnable()) + scheduler.scheduled.first().cancelFailure = failure + + val thrown = assertThrows(IllegalStateException::class.java) { executor.stop() } + + assertSame(failure, thrown) + assertEquals(1, scheduler.scheduled.first().cancelCount) + assertEquals(1, scheduler.scheduled.last().cancelCount) + assertEquals(1, asyncExecutor.shutdownNowCount) + assertEquals(VelocityExecutor.State.STOPPED, executor.currentState()) + assertEquals(0, executor.activeTaskCount()) + } + + @Test + fun `now task queued before start runs without velocity scheduler`() { + val scheduler = RecordingScheduler() + val executor = executor(scheduler) + var executions = 0 + executor.submit(runnable(now = true) { executions++ }) + + executor.start() + + assertEquals(1, executions) + assertTrue(scheduler.scheduled.isEmpty()) + assertEquals(0, executor.activeTaskCount()) + } + + @Test + fun `user exception is reported and rethrown`() { + val scheduler = RecordingScheduler() + val reports = ArrayList() + val executor = executor(scheduler, reporter = reports::add) + val failure = IllegalStateException("boom") + executor.start() + executor.submit(runnable(async = true) { throw failure }) + + val thrown = assertThrows(IllegalStateException::class.java) { + scheduler.scheduled.single().action.run() + } + + assertSame(failure, thrown) + assertEquals(listOf(failure), reports) + assertEquals(0, executor.activeTaskCount()) + } + + @Test + fun `public task contract keeps scheduled task field`() { + val runningTask = VelocityExecutor.VelocityRunningTask::class.java + + assertEquals(ScheduledTask::class.java, runningTask.getField("scheduledTask").type) + assertEquals(ScheduledTask::class.java, runningTask.getMethod("getScheduledTask").returnType) + VelocityExecutor::class.java.getDeclaredConstructor() + } + + @Test + fun `async dispatch rejection reports failure and completes task`() { + val scheduler = RecordingScheduler() + val failure = RejectedExecutionException("dispatch rejected") + val reports = ArrayList() + val executor = executor(scheduler, RejectingExecutorService(failure)) { reports.add(it) } + executor.start() + executor.submit(runnable(async = true)) + + val thrown = assertThrows(RejectedExecutionException::class.java) { + scheduler.scheduled.single().action.run() + } + + assertSame(failure, thrown) + assertEquals(listOf(failure), reports) + assertEquals(1, scheduler.scheduled.single().cancelCount) + assertEquals(0, executor.activeTaskCount()) + } + + @Test + fun `async thread names use velocity prefix`() { + val factory = VelocityAsyncThreadFactory() + + val first = factory.newThread {} + val second = factory.newThread {} + + assertEquals("TabooLib-Velocity-Async-1", first.name) + assertEquals("TabooLib-Velocity-Async-2", second.name) + assertFalse(first.isAlive) + assertFalse(second.isAlive) + } + + private fun executor( + scheduler: RecordingScheduler, + asyncExecutor: ExecutorService = DirectExecutorService(), + reporter: (Throwable) -> Unit = {}, + ): VelocityExecutor { + return VelocityExecutor(scheduler, asyncExecutor, reporter, false) + } + + private fun runnable( + now: Boolean = false, + async: Boolean = false, + delay: Long = 0, + period: Long = 0, + block: PlatformExecutor.PlatformTask.() -> Unit = {}, + ): PlatformExecutor.PlatformRunnable { + return PlatformExecutor.PlatformRunnable(now, async, delay, period, block) + } + + private class RecordingScheduler( + private val beforeReturn: (VelocityExecutor.VelocityRunningTask, RecordedTask) -> Unit = { _, _ -> }, + ) : VelocityTaskScheduler { + + val scheduled = ArrayList() + + override fun schedule( + task: VelocityExecutor.VelocityRunningTask, + runnable: PlatformExecutor.PlatformRunnable, + action: Runnable, + ): ScheduledTask { + val recorded = RecordedTask(action) + scheduled += recorded + beforeReturn(task, recorded) + return recorded.handle + } + } + + private class RecordedTask(val action: Runnable) { + + var cancelCount = 0 + var cancelFailure: Throwable? = null + + val handle: ScheduledTask = Proxy.newProxyInstance( + ScheduledTask::class.java.classLoader, + arrayOf(ScheduledTask::class.java), + ) { proxy, method, args -> + when (method.name) { + "cancel" -> { + cancelCount++ + cancelFailure?.let { throw it } + null + } + "toString" -> "RecordedScheduledTask" + "hashCode" -> System.identityHashCode(proxy) + "equals" -> proxy === args?.get(0) + else -> null + } + } as ScheduledTask + } + + private class RejectingExecutorService(private val failure: RejectedExecutionException) : AbstractExecutorService() { + + override fun shutdown() = Unit + + override fun shutdownNow(): MutableList = ArrayList() + + override fun isShutdown(): Boolean = false + + override fun isTerminated(): Boolean = false + + override fun awaitTermination(timeout: Long, unit: TimeUnit): Boolean = false + + override fun execute(command: Runnable) { + throw failure + } + } + + private class DirectExecutorService : AbstractExecutorService() { + + private var shutdown = false + var shutdownNowCount = 0 + + override fun shutdown() { + shutdown = true + } + + override fun shutdownNow(): MutableList { + shutdown = true + shutdownNowCount++ + return ArrayList() + } + + override fun isShutdown(): Boolean = shutdown + + override fun isTerminated(): Boolean = shutdown + + override fun awaitTermination(timeout: Long, unit: TimeUnit): Boolean = shutdown + + override fun execute(command: Runnable) { + if (shutdown) { + throw RejectedExecutionException() + } + command.run() + } + } +} diff --git a/platform/platform-velocity/build.gradle.kts b/platform/platform-velocity/build.gradle.kts index 941a4099f..37705bbed 100644 --- a/platform/platform-velocity/build.gradle.kts +++ b/platform/platform-velocity/build.gradle.kts @@ -6,4 +6,8 @@ dependencies { compileOnly(project(":common")) compileOnly(project(":common-platform-api")) compileOnly("com.velocitypowered:velocity-api:3.1.1") + + testImplementation(project(":common")) + testImplementation(project(":common-platform-api")) + testImplementation("com.velocitypowered:velocity-api:3.1.1") } \ No newline at end of file diff --git a/platform/platform-velocity/src/main/java/taboolib/platform/VelocityActivationGate.java b/platform/platform-velocity/src/main/java/taboolib/platform/VelocityActivationGate.java new file mode 100644 index 000000000..5ea84cf2e --- /dev/null +++ b/platform/platform-velocity/src/main/java/taboolib/platform/VelocityActivationGate.java @@ -0,0 +1,39 @@ +package taboolib.platform; + +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.atomic.AtomicReference; + +/** + * Coordinates zero-delay activation with shutdown without blocking either thread. + */ +final class VelocityActivationGate { + + private enum State { + OPEN, + ACTIVATING, + CLOSED + } + + private final AtomicReference state = new AtomicReference<>(State.OPEN); + private final CompletableFuture activationClosed = new CompletableFuture<>(); + + boolean activate(Runnable action) { + if (!state.compareAndSet(State.OPEN, State.ACTIVATING)) { + return false; + } + try { + action.run(); + return true; + } finally { + state.set(State.CLOSED); + activationClosed.complete(null); + } + } + + CompletableFuture close() { + if (state.compareAndSet(State.OPEN, State.CLOSED)) { + activationClosed.complete(null); + } + return activationClosed; + } +} diff --git a/platform/platform-velocity/src/main/java/taboolib/platform/VelocityPlugin.java b/platform/platform-velocity/src/main/java/taboolib/platform/VelocityPlugin.java index 743a6ca12..5f55f6c76 100644 --- a/platform/platform-velocity/src/main/java/taboolib/platform/VelocityPlugin.java +++ b/platform/platform-velocity/src/main/java/taboolib/platform/VelocityPlugin.java @@ -1,6 +1,7 @@ package taboolib.platform; import com.google.inject.Inject; +import com.velocitypowered.api.event.EventTask; import com.velocitypowered.api.event.Subscribe; import com.velocitypowered.api.event.proxy.ProxyInitializeEvent; import com.velocitypowered.api.event.proxy.ProxyShutdownEvent; @@ -19,6 +20,8 @@ import taboolib.common.platform.Plugin; import java.nio.file.Path; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.atomic.AtomicReference; import static taboolib.common.PrimitiveIO.t; @@ -84,6 +87,8 @@ public class VelocityPlugin { private final ProxyServer server; private final Logger logger; private final Path configDirectory; + private final VelocityActivationGate activationGate = new VelocityActivationGate(); + private final AtomicReference> disableFuture = new AtomicReference<>(); @Inject public VelocityPlugin(final ProxyServer server, final Logger logger, @DataDirectory final Path configDirectory) { @@ -116,25 +121,99 @@ public void e(ProxyInitializeEvent e) { // 因为插件可能在 onEnable() 下关闭 if (!TabooLib.isStopped()) { // 创建调度器,执行 onActive() 方法 - server.getScheduler().buildTask(this, () -> { + server.getScheduler().buildTask(this, () -> activationGate.activate(() -> { + if (TabooLib.isStopped()) { + return; + } // 生命周期任务 TabooLib.lifeCycle(LifeCycle.ACTIVE); // 调用 Plugin 实现的 onActive() 方法 if (pluginInstance != null) { pluginInstance.onActive(); } - }).schedule(); + })).schedule(); } } - @Subscribe + /** + * 保留旧同步入口;该入口无法向调用方表达异步完成,只负责观察失败。 + */ public void e(ProxyShutdownEvent e) { + observeDisable(disableAfterActivation()); + } + + @Subscribe + public EventTask eAsync(ProxyShutdownEvent e) { + return EventTask.resumeWhenComplete(disableAfterActivation()); + } + + private CompletableFuture disableAfterActivation() { + CompletableFuture current = disableFuture.get(); + if (current != null) { + return current; + } + CompletableFuture created = new CompletableFuture<>(); + if (!disableFuture.compareAndSet(null, created)) { + return disableFuture.get(); + } + activationGate.close().whenComplete((unused, failure) -> { + if (failure != null) { + created.completeExceptionally(failure); + return; + } + try { + disable(); + created.complete(null); + } catch (Throwable ex) { + created.completeExceptionally(ex); + } + }); + return created; + } + + private void observeDisable(CompletableFuture future) { + future.whenComplete((unused, failure) -> { + if (failure != null) { + try { + logger.error("Failed to disable the TabooLib Velocity plugin", failure); + } catch (Throwable ignored) { + try { + failure.printStackTrace(); + } catch (Throwable ignoredAgain) { + } + } + } + }); + } + + private void disable() { + Throwable failure = null; // 在插件未关闭的前提下,执行 onDisable() 方法 if (pluginInstance != null && !TabooLib.isStopped()) { - pluginInstance.onDisable(); + try { + pluginInstance.onDisable(); + } catch (Throwable ex) { + failure = ex; + } } - // 生命周期任务 - TabooLib.lifeCycle(LifeCycle.DISABLE); + // 生命周期任务必须执行,不能被用户回调异常跳过 + try { + TabooLib.lifeCycle(LifeCycle.DISABLE); + } catch (Throwable ex) { + if (failure == null) { + failure = ex; + } else { + failure.addSuppressed(ex); + } + } + if (failure != null) { + VelocityPlugin.rethrow(failure); + } + } + + @SuppressWarnings("unchecked") + private static void rethrow(Throwable throwable) throws T { + throw (T) throwable; } @Nullable diff --git a/platform/platform-velocity/src/main/kotlin/taboolib/platform/type/VelocityProxyEvent.kt b/platform/platform-velocity/src/main/kotlin/taboolib/platform/type/VelocityProxyEvent.kt index 02b98a71c..c83a588dd 100644 --- a/platform/platform-velocity/src/main/kotlin/taboolib/platform/type/VelocityProxyEvent.kt +++ b/platform/platform-velocity/src/main/kotlin/taboolib/platform/type/VelocityProxyEvent.kt @@ -2,12 +2,15 @@ package taboolib.platform.type import com.velocitypowered.api.event.ResultedEvent import com.velocitypowered.api.event.ResultedEvent.GenericResult +import org.slf4j.LoggerFactory import taboolib.common.PrimitiveIO.t import taboolib.platform.VelocityPlugin +import java.util.concurrent.CompletableFuture import java.util.function.Consumer open class VelocityProxyEvent : ResultedEvent { + @Volatile private var isCancelled = false private val cancelCallbacks = mutableListOf>>() @@ -39,8 +42,58 @@ open class VelocityProxyEvent : ResultedEvent { return this } + /** + * 调用事件,并在所有监听器完成后返回事件是否未被取消。 + */ + fun callAsync(): CompletableFuture { + return fireEvent().thenApply { !isCancelled } + } + + /** + * 调用事件但不等待异步监听器。 + * + * 若事件已同步完成,则返回最终状态;否则返回调用时可见的取消状态快照。 + */ fun call(): Boolean { - VelocityPlugin.getInstance().server.eventManager.fire(this) - return !isCancelled + val future = fireEvent() + val snapshot = !isCancelled + future.whenComplete { _, throwable -> + if (throwable != null) { + reportCallFailure(throwable) + } + } + return if (future.isDone) !isCancelled else snapshot + } + + /** + * 为测试保留的事件派发接缝。 + */ + protected open fun fireEvent(): CompletableFuture { + return VelocityPlugin.getInstance().server.eventManager.fire(this) + } + + private fun reportCallFailure(throwable: Throwable) { + try { + onCallFailure(throwable) + } catch (reportingFailure: Throwable) { + throwable.addSuppressed(reportingFailure) + try { + LoggerFactory.getLogger(VelocityProxyEvent::class.java) + .error("Failed to report an asynchronous Velocity event failure", throwable) + } catch (fallbackFailure: Throwable) { + throwable.addSuppressed(fallbackFailure) + try { + throwable.printStackTrace() + } catch (_: Throwable) { + } + } + } + } + + /** + * 兼容调用无法向调用方传播异步异常,因此至少将其记录下来。 + */ + protected open fun onCallFailure(throwable: Throwable) { + VelocityPlugin.getInstance().logger.error("Failed to fire Velocity event ${javaClass.name}", throwable) } } \ No newline at end of file diff --git a/platform/platform-velocity/src/test/java/taboolib/platform/VelocityActivationGateTest.java b/platform/platform-velocity/src/test/java/taboolib/platform/VelocityActivationGateTest.java new file mode 100644 index 000000000..12d4d0b43 --- /dev/null +++ b/platform/platform-velocity/src/test/java/taboolib/platform/VelocityActivationGateTest.java @@ -0,0 +1,73 @@ +package taboolib.platform; + +import com.velocitypowered.api.event.EventTask; +import com.velocitypowered.api.event.Subscribe; +import com.velocitypowered.api.event.proxy.ProxyShutdownEvent; +import org.junit.jupiter.api.Test; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.concurrent.atomic.AtomicReference; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class VelocityActivationGateTest { + + @Test + void shutdownClosesGateBeforeLateActivation() { + VelocityActivationGate gate = new VelocityActivationGate(); + AtomicInteger activeCalls = new AtomicInteger(); + + CompletableFuture closed = gate.close(); + + assertTrue(closed.isDone()); + assertFalse(gate.activate(activeCalls::incrementAndGet)); + assertEquals(0, activeCalls.get()); + } + + @Test + void disableContinuationWaitsForClaimedActivation() { + VelocityActivationGate gate = new VelocityActivationGate(); + List order = new ArrayList<>(); + AtomicReference> closed = new AtomicReference<>(); + + assertTrue(gate.activate(() -> { + order.add("active-start"); + closed.set(gate.close()); + assertFalse(closed.get().isDone()); + closed.get().thenRun(() -> order.add("disable")); + order.add("active-end"); + })); + + assertTrue(closed.get().isDone()); + assertEquals(Arrays.asList("active-start", "active-end", "disable"), order); + } + + @Test + void activationCanOnlyBeClaimedOnce() { + VelocityActivationGate gate = new VelocityActivationGate(); + AtomicInteger activeCalls = new AtomicInteger(); + + assertTrue(gate.activate(activeCalls::incrementAndGet)); + assertFalse(gate.activate(activeCalls::incrementAndGet)); + assertTrue(gate.close().isDone()); + assertEquals(1, activeCalls.get()); + } + + @Test + void shutdownKeepsLegacyDescriptorAndUsesAsyncEventContract() throws NoSuchMethodException { + java.lang.reflect.Method legacy = VelocityPlugin.class.getDeclaredMethod("e", ProxyShutdownEvent.class); + java.lang.reflect.Method async = VelocityPlugin.class.getDeclaredMethod("eAsync", ProxyShutdownEvent.class); + + assertEquals(void.class, legacy.getReturnType()); + assertNull(legacy.getAnnotation(Subscribe.class)); + assertEquals(EventTask.class, async.getReturnType()); + assertTrue(async.isAnnotationPresent(Subscribe.class)); + } +} diff --git a/platform/platform-velocity/src/test/kotlin/taboolib/platform/type/VelocityProxyEventTest.kt b/platform/platform-velocity/src/test/kotlin/taboolib/platform/type/VelocityProxyEventTest.kt new file mode 100644 index 000000000..287a6a00e --- /dev/null +++ b/platform/platform-velocity/src/test/kotlin/taboolib/platform/type/VelocityProxyEventTest.kt @@ -0,0 +1,125 @@ +package taboolib.platform.type + +import com.velocitypowered.api.event.ResultedEvent.GenericResult +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertFalse +import org.junit.jupiter.api.Assertions.assertSame +import org.junit.jupiter.api.Assertions.assertThrows +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test +import java.util.concurrent.CompletableFuture +import java.util.concurrent.CompletionException + +class VelocityProxyEventTest { + + @Test + fun `callAsync completes with final cancellation state`() { + val fired = CompletableFuture() + val event = TestEvent(fired) + + val result = event.callAsync() + + assertEquals(1, event.fireCount) + assertFalse(result.isDone) + + event.result = GenericResult.denied() + fired.complete(event) + + assertFalse(result.getNow(true)) + } + + @Test + fun `callAsync propagates exceptional completion`() { + val fired = CompletableFuture() + val event = TestEvent(fired) + val failure = IllegalStateException("fire failed") + + val result = event.callAsync() + fired.completeExceptionally(failure) + + val thrown = assertThrows(CompletionException::class.java) { + result.getNow(true) + } + assertSame(failure, thrown.cause) + } + + @Test + fun `call returns current snapshot without waiting for unfinished fire`() { + val fired = CompletableFuture() + val event = TestEvent(fired) + + assertTrue(event.call()) + assertEquals(1, event.fireCount) + assertFalse(fired.isDone) + + event.result = GenericResult.denied() + fired.complete(event) + } + + @Test + fun `call returns final state when fire completes synchronously`() { + lateinit var event: TestEvent + event = TestEvent { + event.result = GenericResult.denied() + CompletableFuture.completedFuture(event) + } + + assertFalse(event.call()) + assertEquals(1, event.fireCount) + } + + @Test + fun `call observes later asynchronous failure`() { + val fired = CompletableFuture() + val event = TestEvent(fired) + val failure = IllegalArgumentException("listener failed") + + assertTrue(event.call()) + fired.completeExceptionally(failure) + + assertSame(failure, event.observedFailure) + } + + @Test + fun `call contains reporter failures without losing original failure`() { + val fired = CompletableFuture() + val reporterFailure = IllegalStateException("reporter failed") + val event = TestEvent(fired).also { it.reporterFailure = reporterFailure } + val failure = IllegalArgumentException("listener failed") + + assertTrue(event.call()) + fired.completeExceptionally(failure) + + assertSame(failure, event.observedFailure) + assertEquals(listOf(reporterFailure), failure.suppressed.toList()) + } + + @Test + fun `call keeps primitive boolean JVM signature`() { + val method = VelocityProxyEvent::class.java.getDeclaredMethod("call") + + assertEquals(Boolean::class.javaPrimitiveType, method.returnType) + assertEquals(0, method.parameterCount) + } + + private class TestEvent( + private val fire: () -> CompletableFuture + ) : VelocityProxyEvent() { + + constructor(future: CompletableFuture) : this({ future }) + + var fireCount = 0 + var observedFailure: Throwable? = null + var reporterFailure: Throwable? = null + + override fun fireEvent(): CompletableFuture { + fireCount++ + return fire() + } + + override fun onCallFailure(throwable: Throwable) { + observedFailure = throwable + reporterFailure?.let { throw it } + } + } +} From fa22c89766e19ecc2b2cd2ac353499c4a2ae0791 Mon Sep 17 00:00:00 2001 From: zhibei <785740487@qq.com> Date: Tue, 14 Jul 2026 04:15:38 +0800 Subject: [PATCH 20/37] =?UTF-8?q?fix(platform):=20=E4=BF=AE=E5=A4=8D?= =?UTF-8?q?=E5=A4=9A=E5=B9=B3=E5=8F=B0=E9=80=82=E9=85=8D=E6=AD=A3=E7=A1=AE?= =?UTF-8?q?=E6=80=A7?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../common/platform/command/SimpleCommand.kt | 20 +- .../platform/command/SimpleCommandTest.kt | 58 +++ .../platform-bukkit-impl/build.gradle.kts | 8 + .../taboolib/platform/type/BukkitPlayer.kt | 2 +- .../platform/type/BukkitPlayerTest.kt | 42 +++ .../platform-bungee-impl/build.gradle.kts | 2 + .../kotlin/taboolib/platform/BungeeCommand.kt | 39 +- .../taboolib/platform/type/BungeePlayer.kt | 9 +- .../platform/BungeeCompatibilityTest.kt | 47 +++ platform/platform-hytale/build.gradle.kts | 9 + .../kotlin/taboolib/platform/HytaleAdapter.kt | 8 +- .../kotlin/taboolib/platform/HytaleCommand.kt | 83 ++-- .../taboolib/platform/HytaleExecutor.kt | 353 ++++++++++++++---- .../taboolib/platform/HytaleListener.kt | 36 +- .../platform/type/HytaleCommandSender.kt | 79 +++- .../taboolib/platform/type/HytalePlayer.kt | 18 +- .../platform/HytaleCompatibilityTest.kt | 283 ++++++++++++++ .../taboolib/platform/HytaleExecutorTest.kt | 265 +++++++++++++ .../taboolib/platform/HytaleListenerTest.kt | 44 +++ .../platform/type/HytaleCommandSenderTest.kt | 95 +++++ .../taboolib/platform/VelocityAdapter.kt | 8 +- .../taboolib/platform/VelocityAdapterTest.kt | 80 ++++ 22 files changed, 1447 insertions(+), 141 deletions(-) create mode 100644 common-platform-api/src/test/kotlin/taboolib/common/platform/command/SimpleCommandTest.kt create mode 100644 platform/platform-bukkit-impl/src/test/kotlin/taboolib/platform/type/BukkitPlayerTest.kt create mode 100644 platform/platform-bungee-impl/src/test/kotlin/taboolib/platform/BungeeCompatibilityTest.kt create mode 100644 platform/platform-hytale/src/test/kotlin/taboolib/platform/HytaleCompatibilityTest.kt create mode 100644 platform/platform-hytale/src/test/kotlin/taboolib/platform/HytaleExecutorTest.kt create mode 100644 platform/platform-hytale/src/test/kotlin/taboolib/platform/HytaleListenerTest.kt create mode 100644 platform/platform-hytale/src/test/kotlin/taboolib/platform/type/HytaleCommandSenderTest.kt create mode 100644 platform/platform-velocity-impl/src/test/kotlin/taboolib/platform/VelocityAdapterTest.kt diff --git a/common-platform-api/src/main/kotlin/taboolib/common/platform/command/SimpleCommand.kt b/common-platform-api/src/main/kotlin/taboolib/common/platform/command/SimpleCommand.kt index be77d6a6f..d64acf4c0 100644 --- a/common-platform-api/src/main/kotlin/taboolib/common/platform/command/SimpleCommand.kt +++ b/common-platform-api/src/main/kotlin/taboolib/common/platform/command/SimpleCommand.kt @@ -64,6 +64,13 @@ class SimpleCommandBody(val func: CommandComponent.() -> Unit = {}) { } } +private fun SimpleCommandBody.registerTo(component: CommandComponent) { + component.literal(name, *aliases, optional = optional, permission = permission, hidden = hidden, description = description) { + func(this) + this@registerTo.children.forEach { it.registerTo(this) } + } +} + @Suppress("DuplicatedCode") @Inject @Awake @@ -138,18 +145,7 @@ class SimpleCommandRegister : ClassVisitor(0) { command(name, alias, description, usage, permission, permissionMessage, permissionDefault, permissionChildren, newParser) { main[clazz.name]?.func?.invoke(this) body[clazz.name]?.forEach { body -> - fun register(body: SimpleCommandBody, component: CommandComponent) { - component.literal(body.name, *body.aliases, optional = body.optional, permission = body.permission, hidden = body.hidden, description = body.description) { - if (body.children.isEmpty()) { - body.func(this) - } else { - body.children.forEach { children -> - register(children, this) - } - } - } - } - register(body, this) + body.registerTo(this) } } } diff --git a/common-platform-api/src/test/kotlin/taboolib/common/platform/command/SimpleCommandTest.kt b/common-platform-api/src/test/kotlin/taboolib/common/platform/command/SimpleCommandTest.kt new file mode 100644 index 000000000..5b7b3aae1 --- /dev/null +++ b/common-platform-api/src/test/kotlin/taboolib/common/platform/command/SimpleCommandTest.kt @@ -0,0 +1,58 @@ +package taboolib.common.platform.command + +import org.junit.jupiter.api.Assertions.assertArrayEquals +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Test +import taboolib.common.platform.command.component.CommandBase +import taboolib.common.platform.command.component.CommandComponent +import taboolib.common.platform.command.component.CommandComponentLiteral + +class SimpleCommandTest { + + @Test + fun `empty body tree still applies body function`() { + val body = SimpleCommandBody { + literal("declared") + }.apply { + name = "root" + } + val command = CommandBase() + + register(body, command) + + val root = command.children.single() as CommandComponentLiteral + assertArrayEquals(arrayOf("root"), root.aliases) + assertArrayEquals(arrayOf("declared"), (root.children.single() as CommandComponentLiteral).aliases) + } + + @Test + fun `body function and nested bodies register consistently`() { + val body = SimpleCommandBody { + literal("declared") + }.apply { + name = "root" + children += SimpleCommandBody { + literal("leaf") + }.apply { + name = "nested" + } + } + val command = CommandBase() + + register(body, command) + + val root = command.children.single() as CommandComponentLiteral + assertEquals(2, root.children.size) + assertArrayEquals(arrayOf("declared"), (root.children[0] as CommandComponentLiteral).aliases) + val nested = root.children[1] as CommandComponentLiteral + assertArrayEquals(arrayOf("nested"), nested.aliases) + assertArrayEquals(arrayOf("leaf"), (nested.children.single() as CommandComponentLiteral).aliases) + } + + private fun register(body: SimpleCommandBody, component: CommandComponent) { + val method = Class.forName("taboolib.common.platform.command.SimpleCommandKt") + .getDeclaredMethod("registerTo", SimpleCommandBody::class.java, CommandComponent::class.java) + method.isAccessible = true + method.invoke(null, body, component) + } +} diff --git a/platform/platform-bukkit-impl/build.gradle.kts b/platform/platform-bukkit-impl/build.gradle.kts index ba010f351..9943c68a2 100644 --- a/platform/platform-bukkit-impl/build.gradle.kts +++ b/platform/platform-bukkit-impl/build.gradle.kts @@ -33,4 +33,12 @@ dependencies { // XSeries compileOnly("com.google.code.findbugs:jsr305:3.0.2") compileOnly("org.apache.logging.log4j:log4j-api:2.14.1") + + testImplementation(project(":common")) + testImplementation(project(":common-platform-api")) + testImplementation(project(":common-util")) + testImplementation("io.paper:folia-api:1.21.4") + testImplementation("net.kyori:adventure-api:4.17.0") + testImplementation("net.kyori:adventure-text-minimessage:4.17.0") + testImplementation("net.md-5:bungeecord-chat:1.20") } \ No newline at end of file diff --git a/platform/platform-bukkit-impl/src/main/kotlin/taboolib/platform/type/BukkitPlayer.kt b/platform/platform-bukkit-impl/src/main/kotlin/taboolib/platform/type/BukkitPlayer.kt index e996d6413..0d9812d78 100644 --- a/platform/platform-bukkit-impl/src/main/kotlin/taboolib/platform/type/BukkitPlayer.kt +++ b/platform/platform-bukkit-impl/src/main/kotlin/taboolib/platform/type/BukkitPlayer.kt @@ -77,7 +77,7 @@ class BukkitPlayer(val player: Player) : ProxyPlayer { override var bedSpawnLocation: Location? get() = player.bedSpawnLocation?.toProxyLocation() set(value) { - player.bedSpawnLocation = value!!.toBukkitLocation() + player.bedSpawnLocation = value?.toBukkitLocation() } override var displayName: String? diff --git a/platform/platform-bukkit-impl/src/test/kotlin/taboolib/platform/type/BukkitPlayerTest.kt b/platform/platform-bukkit-impl/src/test/kotlin/taboolib/platform/type/BukkitPlayerTest.kt new file mode 100644 index 000000000..535c7fbe6 --- /dev/null +++ b/platform/platform-bukkit-impl/src/test/kotlin/taboolib/platform/type/BukkitPlayerTest.kt @@ -0,0 +1,42 @@ +package taboolib.platform.type + +import org.bukkit.entity.Player +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertNull +import org.junit.jupiter.api.Test +import java.lang.reflect.Proxy + +class BukkitPlayerTest { + + @Test + fun `null bed spawn location reaches bukkit setter`() { + var calls = 0 + val player = Proxy.newProxyInstance(Player::class.java.classLoader, arrayOf(Player::class.java)) { _, method, args -> + if (method.name == "setBedSpawnLocation" && method.parameterCount == 1) { + calls++ + assertNull(args?.firstOrNull()) + null + } else { + defaultValue(method.returnType) + } + } as Player + + BukkitPlayer(player).bedSpawnLocation = null + + assertEquals(1, calls) + } + + private fun defaultValue(type: Class<*>): Any? { + return when (type) { + java.lang.Boolean.TYPE -> false + java.lang.Byte.TYPE -> 0.toByte() + java.lang.Short.TYPE -> 0.toShort() + java.lang.Integer.TYPE -> 0 + java.lang.Long.TYPE -> 0L + java.lang.Float.TYPE -> 0F + java.lang.Double.TYPE -> 0.0 + java.lang.Character.TYPE -> '\u0000' + else -> null + } + } +} diff --git a/platform/platform-bungee-impl/build.gradle.kts b/platform/platform-bungee-impl/build.gradle.kts index b4922ef02..2c27b0e2e 100644 --- a/platform/platform-bungee-impl/build.gradle.kts +++ b/platform/platform-bungee-impl/build.gradle.kts @@ -4,4 +4,6 @@ dependencies { compileOnly(project(":common-platform-api")) compileOnly(project(":platform:platform-bungee")) compileOnly("net.md_5.bungee:BungeeCord:1") + + testImplementation("net.md_5.bungee:BungeeCord:1") } \ No newline at end of file diff --git a/platform/platform-bungee-impl/src/main/kotlin/taboolib/platform/BungeeCommand.kt b/platform/platform-bungee-impl/src/main/kotlin/taboolib/platform/BungeeCommand.kt index b33a3ca4b..fa326e7f9 100644 --- a/platform/platform-bungee-impl/src/main/kotlin/taboolib/platform/BungeeCommand.kt +++ b/platform/platform-bungee-impl/src/main/kotlin/taboolib/platform/BungeeCommand.kt @@ -43,20 +43,20 @@ class BungeeCommand : PlatformCommand { commandBuilder: CommandBase.() -> Unit, ) { val permission = command.permission.ifEmpty { "${plugin.description.name}.command.use" } - BungeeCord.getInstance().pluginManager.registerCommand(BungeePlugin.getInstance(), object : Command(command.name, permission), TabExecutor { - - override fun execute(sender: CommandSender, args: Array) { - executor.execute(adaptCommandSender(sender), command, command.name, args) - } - - override fun onTabComplete(sender: CommandSender, args: Array): MutableIterable { - return completer.execute(adaptCommandSender(sender), command, command.name, args)?.toMutableList() ?: ArrayList() + val registeredCommand = RegisteredBungeeCommand( + command.name, + permission, + command.aliases, + execute = { sender, args -> executor.execute(adaptCommandSender(sender), command, command.name, args) }, + complete = { sender, args -> + completer.execute(adaptCommandSender(sender), command, command.name, args)?.toMutableList() ?: ArrayList() } - }) + ) + BungeeCord.getInstance().pluginManager.registerCommand(BungeePlugin.getInstance(), registeredCommand) } override fun unregisterCommand(command: String) { - val instance = BungeeCord.getInstance().pluginManager.getProperty>("commandMap")!![command] ?: return + val instance = BungeeCord.getInstance().pluginManager.getProperty>("commandMap")?.get(command) ?: return BungeeCord.getInstance().pluginManager.unregisterCommand(instance) } @@ -82,4 +82,21 @@ class BungeeCommand : PlatformCommand { } sender.cast().sendMessage(*components.toTypedArray()) } -} \ No newline at end of file +} + +private class RegisteredBungeeCommand( + name: String, + permission: String, + aliases: List, + private val execute: (CommandSender, Array) -> Unit, + private val complete: (CommandSender, Array) -> MutableIterable, +) : Command(name, permission, *aliases.toTypedArray()), TabExecutor { + + override fun execute(sender: CommandSender, args: Array) { + execute.invoke(sender, args) + } + + override fun onTabComplete(sender: CommandSender, args: Array): MutableIterable { + return complete.invoke(sender, args) + } +} diff --git a/platform/platform-bungee-impl/src/main/kotlin/taboolib/platform/type/BungeePlayer.kt b/platform/platform-bungee-impl/src/main/kotlin/taboolib/platform/type/BungeePlayer.kt index ea4aaca0b..81975885b 100644 --- a/platform/platform-bungee-impl/src/main/kotlin/taboolib/platform/type/BungeePlayer.kt +++ b/platform/platform-bungee-impl/src/main/kotlin/taboolib/platform/type/BungeePlayer.kt @@ -277,9 +277,10 @@ class BungeePlayer(val player: ProxiedPlayer) : ProxyPlayer { } override fun sendTitle(title: String?, subtitle: String?, fadein: Int, stay: Int, fadeout: Int) { + val (titleComponent, subtitleComponent) = bungeeTitleComponents(title, subtitle) val titleMessage = BungeePlugin.getInstance().proxy.createTitle().also { - it.title(TextComponent(title ?: "")) - it.subTitle(TextComponent(title ?: "")) + it.title(titleComponent) + it.subTitle(subtitleComponent) it.fadeIn(fadein) it.stay(stay) it.fadeOut(fadeout) @@ -332,4 +333,8 @@ class BungeePlayer(val player: ProxiedPlayer) : ProxyPlayer { BungeePlayer(e.player).quitCallback.forEach { it.run() } } } +} + +private fun bungeeTitleComponents(title: String?, subtitle: String?): Pair { + return TextComponent(title ?: "") to TextComponent(subtitle ?: "") } \ No newline at end of file diff --git a/platform/platform-bungee-impl/src/test/kotlin/taboolib/platform/BungeeCompatibilityTest.kt b/platform/platform-bungee-impl/src/test/kotlin/taboolib/platform/BungeeCompatibilityTest.kt new file mode 100644 index 000000000..2e0bf36f6 --- /dev/null +++ b/platform/platform-bungee-impl/src/test/kotlin/taboolib/platform/BungeeCompatibilityTest.kt @@ -0,0 +1,47 @@ +package taboolib.platform + +import net.md_5.bungee.api.CommandSender +import net.md_5.bungee.api.chat.TextComponent +import net.md_5.bungee.api.plugin.Command +import org.junit.jupiter.api.Assertions.assertArrayEquals +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Test + +class BungeeCompatibilityTest { + + @Test + fun `registered command keeps configured aliases`() { + val command = registeredCommand(listOf("alias", "short")) + + assertEquals("main", command.name) + assertEquals("plugin.command.main", command.permission) + assertArrayEquals(arrayOf("alias", "short"), command.aliases) + } + + @Test + fun `title components keep title and subtitle independent`() { + val (title, subtitle) = titleComponents("Title", "Subtitle") + assertEquals("Title", title.text) + assertEquals("Subtitle", subtitle.text) + + val (emptyTitle, emptySubtitle) = titleComponents(null, null) + assertEquals("", emptyTitle.text) + assertEquals("", emptySubtitle.text) + } + + private fun registeredCommand(aliases: List): Command { + val constructor = Class.forName("taboolib.platform.RegisteredBungeeCommand").declaredConstructors.single() + constructor.isAccessible = true + val execute: (CommandSender, Array) -> Unit = { _, _ -> } + val complete: (CommandSender, Array) -> MutableIterable = { _, _ -> mutableListOf() } + return constructor.newInstance("main", "plugin.command.main", aliases, execute, complete) as Command + } + + @Suppress("UNCHECKED_CAST") + private fun titleComponents(title: String?, subtitle: String?): Pair { + val method = Class.forName("taboolib.platform.type.BungeePlayerKt") + .getDeclaredMethod("bungeeTitleComponents", String::class.java, String::class.java) + method.isAccessible = true + return method.invoke(null, title, subtitle) as Pair + } +} diff --git a/platform/platform-hytale/build.gradle.kts b/platform/platform-hytale/build.gradle.kts index 10ed0498b..5450d7550 100644 --- a/platform/platform-hytale/build.gradle.kts +++ b/platform/platform-hytale/build.gradle.kts @@ -3,4 +3,13 @@ dependencies { compileOnly(project(":common-util")) compileOnly(project(":common-platform-api")) compileOnly("com.hypixel:hytale-server:1.0.0") + + testImplementation(project(":common")) + testImplementation(project(":common-util")) + testImplementation(project(":common-platform-api")) + testImplementation("com.hypixel:hytale-server:1.0.0") +} + +tasks.test { + systemProperty("java.util.logging.manager", "com.hypixel.hytale.logger.backend.HytaleLogManager") } diff --git a/platform/platform-hytale/src/main/kotlin/taboolib/platform/HytaleAdapter.kt b/platform/platform-hytale/src/main/kotlin/taboolib/platform/HytaleAdapter.kt index 45e3efc48..5782326f0 100644 --- a/platform/platform-hytale/src/main/kotlin/taboolib/platform/HytaleAdapter.kt +++ b/platform/platform-hytale/src/main/kotlin/taboolib/platform/HytaleAdapter.kt @@ -41,11 +41,15 @@ class HytaleAdapter : PlatformAdapter { } override fun adaptPlayer(any: Any): ProxyPlayer { - return HytalePlayer(any as Player) + return if (any is ProxyPlayer) any else HytalePlayer(any as Player) } override fun adaptCommandSender(any: Any): ProxyCommandSender { - return if (any is Player) adaptPlayer(any) else HytaleCommandSender(any as CommandSender) + return when (any) { + is ProxyCommandSender -> any + is Player -> adaptPlayer(any) + else -> HytaleCommandSender(any as CommandSender) + } } override fun adaptLocation(any: Any): Location { diff --git a/platform/platform-hytale/src/main/kotlin/taboolib/platform/HytaleCommand.kt b/platform/platform-hytale/src/main/kotlin/taboolib/platform/HytaleCommand.kt index 6985a6b96..8912624c6 100644 --- a/platform/platform-hytale/src/main/kotlin/taboolib/platform/HytaleCommand.kt +++ b/platform/platform-hytale/src/main/kotlin/taboolib/platform/HytaleCommand.kt @@ -2,7 +2,10 @@ package taboolib.platform import com.hypixel.hytale.server.core.command.system.CommandContext import com.hypixel.hytale.server.core.command.system.CommandRegistration +import com.hypixel.hytale.server.core.command.system.CommandSender +import com.hypixel.hytale.server.core.command.system.arguments.types.ArgTypes import com.hypixel.hytale.server.core.command.system.basecommands.CommandBase +import com.hypixel.hytale.server.core.entity.entities.Player import taboolib.common.Inject import taboolib.common.platform.Awake import taboolib.common.platform.Platform @@ -11,9 +14,10 @@ import taboolib.common.platform.ProxyCommandSender import taboolib.common.platform.command.CommandCompleter import taboolib.common.platform.command.CommandExecutor import taboolib.common.platform.command.CommandStructure -import taboolib.common.platform.function.adaptCommandSender import taboolib.common.platform.service.PlatformCommand import taboolib.common.util.unsafeLazy +import taboolib.platform.type.HytaleCommandSender +import taboolib.platform.type.HytalePlayer import java.util.concurrent.ConcurrentHashMap import taboolib.common.platform.command.component.CommandBase as TabooLibCommandBase @@ -93,35 +97,70 @@ class HytaleCommand : PlatformCommand { private val completer: CommandCompleter, private val structure: CommandStructure ) : CommandBase(name, description) { - + init { - // 允许额外参数(TabooLib 自己处理参数解析) + val permission = commandPermission(structure.permission) setAllowsExtraArguments(true) - - // 添加别名 + withRequiredArg("argument", "", ArgTypes.STRING).suggest { sender, input, _, result -> + commandSuggestions(input) { args -> + completer.execute(adaptNativeCommandSender(sender), structure, structure.name, args) + }.forEach { result.suggest(it) } + } + permission?.let { requirePermission(it) } + addUsageVariant(object : CommandBase(description) { + + init { + permission?.let { requirePermission(it) } + } + + override fun executeSync(context: CommandContext) { + executeCommand(context, emptyArray()) + } + }) if (structure.aliases.isNotEmpty()) { addAliases(*structure.aliases.toTypedArray()) } } override fun executeSync(context: CommandContext) { - val sender = adaptCommandSender(context.sender()) - // 直接从输入字符串解析参数 - // inputString 格式: "commandName arg1 arg2 arg3" - val inputString = context.inputString - val args = if (inputString.isBlank()) { - emptyArray() - } else { - // 移除命令名,只保留参数 - val parts = inputString.split(" ").filter { it.isNotBlank() } - if (parts.size > 1) { - parts.drop(1).toTypedArray() - } else { - emptyArray() - } - } - - executor.execute(sender, structure, structure.name, args) + executeCommand(context, commandArguments(context.inputString)) } + + private fun executeCommand(context: CommandContext, args: Array) { + executor.execute(adaptNativeCommandSender(context.sender()), structure, structure.name, args) + } + } +} + +private fun adaptNativeCommandSender(sender: CommandSender): ProxyCommandSender { + return if (sender is Player) HytalePlayer(sender) else HytaleCommandSender(sender) +} + +@JvmSynthetic +internal fun commandPermission(permission: String): String? { + return permission.ifEmpty { null } +} + +@JvmSynthetic +internal fun commandArguments(input: String): Array { + val parts = input.trim().split(Regex("\\s+")).filter { it.isNotEmpty() } + return if (parts.size > 1) parts.drop(1).toTypedArray() else emptyArray() +} + +@JvmSynthetic +internal fun commandSuggestions(input: String, completer: (Array) -> List?): List { + + return completer(completionArguments(input)) ?: emptyList() +} + +@JvmSynthetic +internal fun completionArguments(input: String): Array { + if (input.isEmpty()) { + return arrayOf("") + } + val arguments = input.trim().split(Regex("\\s+")).filter { it.isNotEmpty() }.toMutableList() + if (input.last().isWhitespace()) { + arguments += "" } + return arguments.toTypedArray() } diff --git a/platform/platform-hytale/src/main/kotlin/taboolib/platform/HytaleExecutor.kt b/platform/platform-hytale/src/main/kotlin/taboolib/platform/HytaleExecutor.kt index 8c0002ecc..357015186 100644 --- a/platform/platform-hytale/src/main/kotlin/taboolib/platform/HytaleExecutor.kt +++ b/platform/platform-hytale/src/main/kotlin/taboolib/platform/HytaleExecutor.kt @@ -3,15 +3,23 @@ package taboolib.platform import com.hypixel.hytale.server.core.HytaleServer import taboolib.common.Inject import taboolib.common.LifeCycle +import taboolib.common.PrimitiveIO import taboolib.common.platform.Awake import taboolib.common.platform.Platform import taboolib.common.platform.PlatformSide +import taboolib.common.platform.function.registerLifeCycleTask import taboolib.common.platform.service.PlatformExecutor import taboolib.common.util.unsafeLazy import java.io.Closeable +import java.util.concurrent.ExecutorService import java.util.concurrent.Executors +import java.util.concurrent.RejectedExecutionException import java.util.concurrent.ScheduledFuture +import java.util.concurrent.ThreadFactory import java.util.concurrent.TimeUnit +import java.util.concurrent.atomic.AtomicBoolean +import java.util.concurrent.atomic.AtomicInteger +import java.util.concurrent.atomic.AtomicReference /** * TabooLib @@ -23,113 +31,330 @@ import java.util.concurrent.TimeUnit @Awake @Inject @PlatformSide(Platform.HYTALE) -class HytaleExecutor : PlatformExecutor { +class HytaleExecutor private constructor( + private val taskScheduler: HytaleTaskScheduler?, + private val asyncExecutor: ExecutorService, + private val exceptionReporter: (Throwable) -> Unit, + registerStopTask: Boolean, +) : PlatformExecutor { - private val tasks = ArrayList() - private var started = false - private val executor = Executors.newFixedThreadPool(16) + constructor() : this(null, createAsyncExecutor(), ::reportTaskException, true) + + private enum class State { + NEW, RUNNING, STOPPED + } + + private val lock = Any() + private val pendingTasks = LinkedHashSet() + private val activeTasks = LinkedHashSet() + + @Volatile + private var state = State.NEW val plugin by unsafeLazy { HytalePlugin.getInstance() } + init { + if (registerStopTask) { + registerLifeCycleTask(LifeCycle.DISABLE, 2) { stop() } + } + } + @Awake(LifeCycle.ENABLE) override fun start() { - started = true + val tasks = synchronized(lock) { + when (state) { + State.NEW -> { + state = State.RUNNING + pendingTasks.filterNotTo(ArrayList()) { it.isCancelled }.also { + pendingTasks.clear() + activeTasks.addAll(it) + } + } + State.RUNNING, State.STOPPED -> return + } + } + var failure: Throwable? = null tasks.forEach { - if (it.runnable.now) { - it.executeNow() + try { + launch(it) + } catch (ex: Throwable) { + if (failure == null) { + failure = ex + } else { + failure?.addSuppressed(ex) + } + } + } + failure?.let { throw it } + } + + private fun stop() { + val tasks = synchronized(lock) { + if (state == State.STOPPED) { + return + } + state = State.STOPPED + LinkedHashSet().also { + it.addAll(pendingTasks) + it.addAll(activeTasks) + pendingTasks.clear() + activeTasks.clear() + } + } + var failure: Throwable? = null + tasks.forEach { + try { + it.platformTask().cancel() + } catch (ex: Throwable) { + if (failure == null) { + failure = ex + } else { + failure?.addSuppressed(ex) + } + } + } + try { + asyncExecutor.shutdownNow() + } catch (ex: Throwable) { + if (failure == null) { + failure = ex } else { - it.execute() + failure?.addSuppressed(ex) } } - tasks.clear() + failure?.let { throw it } } fun execute(hytaleRunningTask: HytaleRunningTask, runnable: PlatformExecutor.PlatformRunnable): ScheduledFuture<*> { - return when { - runnable.period > 0 -> HytaleServer.SCHEDULED_EXECUTOR.scheduleAtFixedRate( - { - if (runnable.async) { - executor.submit { runnable.executor(hytaleRunningTask.platformTask()) } - } else { - runnable.executor(hytaleRunningTask.platformTask()) - } - }, - runnable.delay * 50, - runnable.period * 50, - TimeUnit.MILLISECONDS - ) + val action = Runnable { executeScheduled(hytaleRunningTask, runnable) } + taskScheduler?.let { return it.schedule(runnable, action) } + return HytaleServerTaskScheduler.schedule(runnable, action) + } - runnable.delay > 0 -> HytaleServer.SCHEDULED_EXECUTOR.schedule( - { - if (runnable.async) { - executor.submit { runnable.executor(hytaleRunningTask.platformTask()) } - } else { - runnable.executor(hytaleRunningTask.platformTask()) - } - }, - runnable.delay * 50, - TimeUnit.MILLISECONDS - ) + override fun submit(runnable: PlatformExecutor.PlatformRunnable): PlatformExecutor.PlatformTask { + val task = HytaleRunningTask(this, runnable) + val launchNow = synchronized(lock) { + when (state) { + State.NEW -> { + pendingTasks += task + false + } + State.RUNNING -> { + activeTasks += task + true + } + State.STOPPED -> throw RejectedExecutionException("HytaleExecutor has been stopped") + } + } + if (launchNow) { + launch(task) + } + return task.platformTask() + } - else -> HytaleServer.SCHEDULED_EXECUTOR.schedule( - { - if (runnable.async) { - executor.submit { runnable.executor(hytaleRunningTask.platformTask()) } - } else { - runnable.executor(hytaleRunningTask.platformTask()) + private fun launch(task: HytaleRunningTask) { + if (task.isCancelled) { + taskFinished(task) + return + } + if (task.runnable.now) { + try { + task.executeNow() + } finally { + taskFinished(task) + } + } else { + try { + task.execute() + } catch (ex: Throwable) { + taskFinished(task) + throw ex + } + } + } + + private fun executeScheduled(task: HytaleRunningTask, runnable: PlatformExecutor.PlatformRunnable) { + if (task.isCancelled) { + return + } + if (runnable.async) { + val started = AtomicBoolean(false) + try { + asyncExecutor.execute { + started.set(true) + executeUserTask(task, runnable) + } + } catch (ex: Throwable) { + if (!started.get()) { + reportTaskFailure(ex) + try { + task.platformTask().cancel() + } catch (cancellationFailure: Throwable) { + ex.addSuppressed(cancellationFailure) } - }, - 0, - TimeUnit.MILLISECONDS - ) + } + throw ex + } + } else { + executeUserTask(task, runnable) } } + private fun executeUserTask(task: HytaleRunningTask, runnable: PlatformExecutor.PlatformRunnable) { + if (task.isCancelled) { + return + } + try { + runnable.executor(task.platformTask()) + } catch (ex: Throwable) { + reportTaskFailure(ex) + if (!runnable.async) { + taskFinished(task) + } + throw ex + } finally { + if (runnable.period <= 0) { + taskFinished(task) + } + } + } + + private fun reportTaskFailure(ex: Throwable) { + try { + exceptionReporter(ex) + } catch (reportingFailure: Throwable) { + ex.addSuppressed(reportingFailure) + } + } + + private fun taskFinished(task: HytaleRunningTask) { + synchronized(lock) { + pendingTasks -= task + activeTasks -= task + } + } + + private fun taskCancelled(task: HytaleRunningTask) { + taskFinished(task) + } + class HytaleRunningTask(val executor: HytaleExecutor, val runnable: PlatformExecutor.PlatformRunnable) { lateinit var scheduledTask: ScheduledFuture<*> + private val cancelled = AtomicBoolean(false) + private val scheduledTaskReference = AtomicReference?>() + private val scheduledTaskCancelled = AtomicBoolean(false) + + @get:JvmSynthetic + internal val isCancelled: Boolean + get() = cancelled.get() + fun executeNow() { - runnable.executor(HytalePlatformTask { }) + if (!isCancelled) { + executor.executeUserTask(this, runnable) + } } fun execute() { - scheduledTask = executor.execute(this, runnable) + if (isCancelled) { + return + } + val task = executor.execute(this, runnable) + scheduledTask = task + bind(task) } fun platformTask(): PlatformExecutor.PlatformTask { - return HytalePlatformTask { scheduledTask.cancel(false) } + return HytalePlatformTask { cancel() } } - } - override fun submit(runnable: PlatformExecutor.PlatformRunnable): PlatformExecutor.PlatformTask { - val task = HytaleRunningTask(this, runnable) + private fun cancel() { + if (cancelled.compareAndSet(false, true)) { + try { + val scheduled = scheduledTaskReference.get() + ?: if (this::scheduledTask.isInitialized) scheduledTask else null + scheduled?.let(::cancelScheduledTask) + } finally { + executor.taskCancelled(this) + } + } + } - return if (started) { - if (runnable.now) { - task.executeNow() - HytalePlatformTask { } - } else { - task.execute() - task.platformTask() + private fun bind(task: ScheduledFuture<*>) { + check(scheduledTaskReference.compareAndSet(null, task)) { "Scheduled task is already bound" } + if (isCancelled) { + cancelScheduledTask(task) } - } else { - tasks += task - HytalePlatformTask { - if (!runnable.now) { - task.platformTask().cancel() - } - tasks -= task + } + + private fun cancelScheduledTask(task: ScheduledFuture<*>) { + if (scheduledTaskCancelled.compareAndSet(false, true)) { + task.cancel(false) } } } class HytalePlatformTask(val runnable: Closeable) : PlatformExecutor.PlatformTask { + private val cancelled = AtomicBoolean(false) + override fun cancel() { - runnable.close() + if (cancelled.compareAndSet(false, true)) { + runnable.close() + } } } + + companion object { + + private fun createAsyncExecutor(): ExecutorService { + return Executors.newFixedThreadPool(16, HytaleAsyncThreadFactory()) + } + + private fun reportTaskException(ex: Throwable) { + try { + HytalePlugin.getInstance().logger.atSevere().withCause(ex) + .log("Unhandled exception in a TabooLib Hytale task") + } catch (_: Throwable) { + PrimitiveIO.error("Unhandled exception in a TabooLib Hytale task: ${ex.message}") + ex.printStackTrace() + } + } + } +} + +private fun interface HytaleTaskScheduler { + + fun schedule(runnable: PlatformExecutor.PlatformRunnable, action: Runnable): ScheduledFuture<*> +} + +private object HytaleServerTaskScheduler : HytaleTaskScheduler { + + override fun schedule(runnable: PlatformExecutor.PlatformRunnable, action: Runnable): ScheduledFuture<*> { + return when { + runnable.period > 0 -> HytaleServer.SCHEDULED_EXECUTOR.scheduleAtFixedRate( + action, + runnable.delay * 50, + runnable.period * 50, + TimeUnit.MILLISECONDS + ) + else -> HytaleServer.SCHEDULED_EXECUTOR.schedule( + action, + runnable.delay * 50, + TimeUnit.MILLISECONDS + ) + } + } +} + +private class HytaleAsyncThreadFactory : ThreadFactory { + + private val counter = AtomicInteger() + + override fun newThread(runnable: Runnable): Thread { + return Thread(runnable, "TabooLib-Hytale-Async-${counter.incrementAndGet()}") + } } diff --git a/platform/platform-hytale/src/main/kotlin/taboolib/platform/HytaleListener.kt b/platform/platform-hytale/src/main/kotlin/taboolib/platform/HytaleListener.kt index 10312497d..e2e03a7e8 100644 --- a/platform/platform-hytale/src/main/kotlin/taboolib/platform/HytaleListener.kt +++ b/platform/platform-hytale/src/main/kotlin/taboolib/platform/HytaleListener.kt @@ -5,8 +5,10 @@ import com.hypixel.hytale.component.system.ISystem import com.hypixel.hytale.event.EventRegistration import com.hypixel.hytale.event.IAsyncEvent import com.hypixel.hytale.event.IBaseEvent +import com.hypixel.hytale.server.core.event.events.player.PlayerDisconnectEvent import com.hypixel.hytale.server.core.universe.world.storage.EntityStore import taboolib.common.Inject +import taboolib.common.LifeCycle import taboolib.common.platform.Awake import taboolib.common.platform.Platform import taboolib.common.platform.PlatformSide @@ -16,6 +18,7 @@ import taboolib.common.platform.event.PostOrder import taboolib.common.platform.event.ProxyListener import taboolib.common.platform.service.PlatformListener import taboolib.common.util.unsafeLazy +import taboolib.platform.type.HytaleCommandSender import java.util.concurrent.CompletableFuture import java.util.function.Consumer import java.util.function.Function @@ -34,6 +37,18 @@ class HytaleListener : PlatformListener { val plugin by unsafeLazy { HytalePlugin.getInstance() } + @Awake(LifeCycle.ENABLE) + private fun registerPlayerDisconnectListener() { + plugin.eventRegistry.register(PlayerDisconnectEvent::class.java, Consumer { event -> + HytaleCommandSender.fireQuitCallbacks(event.playerRef) + }) + } + + @Awake(LifeCycle.DISABLE) + private fun clearPlayerQuitCallbacks() { + HytaleCommandSender.clearQuitCallbacks() + } + override fun registerListener(event: Class, priority: EventPriority, ignoreCancelled: Boolean, func: (T) -> Unit): ProxyListener { error("Unsupported") } @@ -76,8 +91,11 @@ class HytaleListener : PlatformListener { val priority = handler.priority val key = handler.key val eventClass = event as Class> - val function = Function>, CompletableFuture>> { cf -> - (handler.func as Function, CompletableFuture>).apply(cf as CompletableFuture) as CompletableFuture> + val function = Function>, CompletableFuture>> { future -> + invokeAsyncHandler( + future, + handler.func as Function>, CompletableFuture>> + ) } val registration: EventRegistration<*, *>? = when (handler) { is HytaleEventHandler.Async -> if (key != null) { @@ -109,3 +127,17 @@ class HytaleListener : PlatformListener { class HytaleEcsProxyListener(val system: HytaleEcsEventSystem<*>) : ProxyListener } + +@JvmSynthetic +internal fun invokeAsyncHandler( + future: CompletableFuture, + handler: Function, CompletableFuture>, +): CompletableFuture { + return try { + (handler.apply(future) as CompletableFuture?) ?: CompletableFuture().also { + it.completeExceptionally(NullPointerException("Async event handler returned null")) + } + } catch (ex: Throwable) { + CompletableFuture().also { it.completeExceptionally(ex) } + } +} diff --git a/platform/platform-hytale/src/main/kotlin/taboolib/platform/type/HytaleCommandSender.kt b/platform/platform-hytale/src/main/kotlin/taboolib/platform/type/HytaleCommandSender.kt index 911f19836..95c4af59c 100644 --- a/platform/platform-hytale/src/main/kotlin/taboolib/platform/type/HytaleCommandSender.kt +++ b/platform/platform-hytale/src/main/kotlin/taboolib/platform/type/HytaleCommandSender.kt @@ -6,6 +6,8 @@ import com.hypixel.hytale.server.core.command.system.CommandSender import com.hypixel.hytale.server.core.console.ConsoleSender import com.hypixel.hytale.server.core.permissions.PermissionsModule import taboolib.common.platform.ProxyCommandSender +import java.util.WeakHashMap +import java.util.concurrent.CompletableFuture /** * TabooLib @@ -21,6 +23,67 @@ open class HytaleCommandSender(val sender: CommandSender) : ProxyCommandSender { private val COLOR_PATTERN = Regex("§.") fun stripColor(message: String): String = message.replace(COLOR_PATTERN, "") + + @JvmSynthetic + internal fun dispatchCommand(dispatch: () -> CompletableFuture): Boolean { + dispatch() + return true + } + + private val quitLock = Any() + private val quitCallbacks = WeakHashMap>() + private val completedQuitSessions = WeakHashMap() + + @JvmSynthetic + internal fun activateQuitSession(session: Any) { + synchronized(quitLock) { + completedQuitSessions.remove(session) + } + } + + @JvmSynthetic + internal fun registerQuitCallback(session: Any, callback: Runnable) { + val runImmediately = synchronized(quitLock) { + if (completedQuitSessions.containsKey(session)) { + true + } else { + quitCallbacks.getOrPut(session) { LinkedHashSet() }.add(callback) + false + } + } + if (runImmediately) { + callback.run() + } + } + + @JvmSynthetic + internal fun fireQuitCallbacks(session: Any) { + val registered = synchronized(quitLock) { + completedQuitSessions[session] = true + quitCallbacks.remove(session)?.toList().orEmpty() + } + var failure: Throwable? = null + registered.forEach { + try { + it.run() + } catch (ex: Throwable) { + if (failure == null) { + failure = ex + } else { + failure?.addSuppressed(ex) + } + } + } + failure?.let { throw it } + } + + @JvmSynthetic + internal fun clearQuitCallbacks() { + synchronized(quitLock) { + quitCallbacks.clear() + completedQuitSessions.clear() + } + } } override val origin: Any @@ -52,13 +115,7 @@ open class HytaleCommandSender(val sender: CommandSender) : ProxyCommandSender { } 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) } } override fun hasPermission(permission: String): Boolean { @@ -92,13 +149,7 @@ open class HytaleCommandSender(val sender: CommandSender) : ProxyCommandSender { } override fun performCommand(command: String): Boolean { - val future = CommandManager.get().handleCommand(console, command) - return try { - future.get() - true - } catch (e: Exception) { - false - } + return dispatchCommand { CommandManager.get().handleCommand(console, command) } } override fun hasPermission(permission: String): Boolean { diff --git a/platform/platform-hytale/src/main/kotlin/taboolib/platform/type/HytalePlayer.kt b/platform/platform-hytale/src/main/kotlin/taboolib/platform/type/HytalePlayer.kt index ef51661ed..316f6f7b3 100644 --- a/platform/platform-hytale/src/main/kotlin/taboolib/platform/type/HytalePlayer.kt +++ b/platform/platform-hytale/src/main/kotlin/taboolib/platform/type/HytalePlayer.kt @@ -3,7 +3,6 @@ package taboolib.platform.type import com.hypixel.hytale.protocol.GameMode import com.hypixel.hytale.protocol.packets.connection.PongType import com.hypixel.hytale.server.core.Message -import com.hypixel.hytale.server.core.command.system.CommandManager import com.hypixel.hytale.server.core.entity.entities.Player import com.hypixel.hytale.server.core.permissions.PermissionsModule import taboolib.common.platform.ProxyGameMode @@ -23,6 +22,12 @@ import java.util.* @Suppress("removal") class HytalePlayer(val player: Player) : ProxyPlayer { + init { + if (isOnline()) { + HytaleCommandSender.activateQuitSession(player.playerRef) + } + } + override val origin: Any get() = player @@ -323,13 +328,8 @@ class HytalePlayer(val player: Player) : ProxyPlayer { } override fun performCommand(command: String): Boolean { - // 使用 CommandManager 执行命令 - val future = CommandManager.get().handleCommand(player, command) - return try { - future.get() // 等待命令执行完成 - true - } catch (e: Exception) { - false + return HytaleCommandSender.dispatchCommand { + com.hypixel.hytale.server.core.command.system.CommandManager.get().handleCommand(player, command) } } @@ -346,6 +346,6 @@ class HytalePlayer(val player: Player) : ProxyPlayer { } override fun onQuit(callback: Runnable) { - // TODO: 实现退出回调 + HytaleCommandSender.registerQuitCallback(player.playerRef, callback) } } diff --git a/platform/platform-hytale/src/test/kotlin/taboolib/platform/HytaleCompatibilityTest.kt b/platform/platform-hytale/src/test/kotlin/taboolib/platform/HytaleCompatibilityTest.kt new file mode 100644 index 000000000..32c637d4c --- /dev/null +++ b/platform/platform-hytale/src/test/kotlin/taboolib/platform/HytaleCompatibilityTest.kt @@ -0,0 +1,283 @@ +package taboolib.platform + +import com.hypixel.hytale.server.core.command.system.AbstractCommand +import com.hypixel.hytale.server.core.command.system.CommandSender +import com.hypixel.hytale.server.core.command.system.ParseResult +import com.hypixel.hytale.server.core.command.system.ParserContext +import com.hypixel.hytale.server.core.command.system.Tokenizer +import org.junit.jupiter.api.Assertions.assertArrayEquals +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertFalse +import org.junit.jupiter.api.Assertions.assertSame +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test +import taboolib.common.platform.ProxyCommandSender +import taboolib.common.platform.ProxyPlayer +import taboolib.common.platform.command.CommandCompleter +import taboolib.common.platform.command.CommandExecutor +import taboolib.common.platform.command.CommandStructure +import taboolib.common.platform.command.PermissionDefault +import taboolib.platform.type.HytaleCommandSender +import java.lang.reflect.Proxy +import java.util.UUID +import java.util.concurrent.CompletableFuture +import java.util.concurrent.atomic.AtomicInteger +import java.util.function.Function + +class HytaleCompatibilityTest { + + @Test + fun `command keeps explicit permission and leaves empty permission native`() { + assertEquals("plugin.command.root", commandPermission("plugin.command.root")) + assertEquals(null, commandPermission("")) + } + + @Test + fun `native first positional argument owns completer and zero argument variant`() { + var received = emptyArray() + val command = HytaleCommand.TabooLibHytaleCommand( + "root", + "description", + executor(), + completer { + received = it + listOf("two") + }, + structure(permission = "plugin.command.root", aliases = listOf("alias")), + ) + val argument = command.requiredArguments.single() + val variantsField = Class.forName("com.hypixel.hytale.server.core.command.system.AbstractCommand") + .getDeclaredField("variantCommands") + variantsField.isAccessible = true + val variants = variantsField.get(command) as Map<*, *> + + assertEquals("plugin.command.root", command.permission) + assertTrue(command.aliases.contains("alias")) + assertFalse(argument.argumentType.isListArgument) + assertEquals(listOf("two"), argument.getSuggestions(nativeSender(), arrayOf("tw"))) + assertArrayEquals(arrayOf("tw"), received) + assertEquals("plugin.command.root", (variants[0] as AbstractCommand).permission) + } + + @Test + fun `command arguments keep positional input semantics`() { + assertArrayEquals(emptyArray(), commandArguments("root")) + assertArrayEquals(arrayOf("one", "two"), commandArguments("root one two")) + assertArrayEquals(arrayOf("one"), commandArguments(" root one ")) + } + + @Test + fun `native command accepts zero and ordinary multi positional arguments`() { + val executions = ArrayList>() + val command = HytaleCommand.TabooLibHytaleCommand( + "root", + "description", + executor { executions += it }, + completer(), + structure(), + ) + + accept(command, "root") + accept(command, "root one two") + + assertEquals(2, executions.size) + assertArrayEquals(emptyArray(), executions[0]) + assertArrayEquals(arrayOf("one", "two"), executions[1]) + } + + @Test + fun `completion preserves current empty argument and invokes completer`() { + assertArrayEquals(arrayOf(""), completionArguments("")) + assertArrayEquals(arrayOf("one", ""), completionArguments("one ")) + assertArrayEquals(arrayOf("one", "two"), completionArguments("one two")) + + var received = emptyArray() + val suggestions = commandSuggestions("one ") { + received = it + listOf("two") + } + + assertArrayEquals(arrayOf("one", ""), received) + assertEquals(listOf("two"), suggestions) + } + + @Test + fun `existing proxy senders keep identity`() { + val adapter = HytaleAdapter() + val player = proxy() + val sender = proxy() + + assertSame(player, adapter.adaptPlayer(player)) + assertSame(player, adapter.adaptCommandSender(player)) + assertSame(sender, adapter.adaptCommandSender(sender)) + } + + @Test + fun `native command sender uses hytale wrapper`() { + val adapter = HytaleAdapter() + val sender = proxy() + + val adapted = adapter.adaptCommandSender(sender) + + assertTrue(adapted is HytaleCommandSender) + assertSame(sender, adapted.origin) + } + + @Test + fun `command dispatch never waits for incomplete future`() { + val future = CompletableFuture() + + assertTrue(HytaleCommandSender.dispatchCommand { future }) + assertFalse(future.isDone) + + future.completeExceptionally(IllegalStateException("late failure")) + assertTrue(future.isCompletedExceptionally) + } + + @Test + fun `command dispatch uses stable submission result`() { + val failed = CompletableFuture().also { + it.completeExceptionally(IllegalStateException("failed")) + } + val cancelled = CompletableFuture().also { it.cancel(false) } + + assertTrue(HytaleCommandSender.dispatchCommand { failed }) + assertTrue(HytaleCommandSender.dispatchCommand { cancelled }) + } + + @Test + fun `async listener converts synchronous throw to failed future`() { + val failure = IllegalStateException("boom") + val result = invokeAsyncHandler(CompletableFuture(), Function { throw failure }) + var observed: Throwable? = null + result.whenComplete { _, ex -> observed = ex } + + assertTrue(result.isCompletedExceptionally) + assertSame(failure, observed) + } + + @Test + fun `async listener rejects null future without blocking`() { + @Suppress("UNCHECKED_CAST") + val nullHandler = Proxy.newProxyInstance( + HytaleCompatibilityTest::class.java.classLoader, + arrayOf(Function::class.java), + ) { _, method, _ -> if (method.name == "apply") null else defaultValue(method.returnType) } + as Function, CompletableFuture> + val result = invokeAsyncHandler(CompletableFuture(), nullHandler) + var observed: Throwable? = null + result.whenComplete { _, ex -> observed = ex } + + assertTrue(result.isCompletedExceptionally) + assertTrue(observed is NullPointerException) + } + + @Test + fun `quit callbacks run once and are removed`() { + val session = Any() + val first = AtomicInteger() + val second = AtomicInteger() + HytaleCommandSender.registerQuitCallback(session, Runnable(first::incrementAndGet)) + HytaleCommandSender.registerQuitCallback(session, Runnable(second::incrementAndGet)) + + HytaleCommandSender.fireQuitCallbacks(session) + HytaleCommandSender.fireQuitCallbacks(session) + + assertEquals(1, first.get()) + assertEquals(1, second.get()) + } + + private fun structure(permission: String = "", aliases: List = emptyList()): CommandStructure { + return CommandStructure( + "root", + aliases, + "description", + "", + permission, + "", + PermissionDefault.TRUE, + emptyMap(), + false, + ) + } + + private fun accept(command: HytaleCommand.TabooLibHytaleCommand, input: String) { + val result = ParseResult() + val tokens = requireNotNull(Tokenizer.parseArguments(input, result)) + val parser = ParserContext.of(tokens, result) + val future = command.acceptCall(nativeSender(), parser, result) + + assertFalse(result.failed()) + future?.let { + assertTrue(it.isDone) + assertFalse(it.isCompletedExceptionally) + } + } + + private fun executor(block: (Array) -> Unit = {}): CommandExecutor { + return object : CommandExecutor { + override fun execute( + sender: ProxyCommandSender, + command: CommandStructure, + name: String, + args: Array, + ): Boolean { + block(args) + return true + } + } + } + + private fun completer(block: (Array) -> List = { emptyList() }): CommandCompleter { + return object : CommandCompleter { + override fun execute( + sender: ProxyCommandSender, + command: CommandStructure, + name: String, + args: Array, + ): List { + return block(args) + } + } + } + + private fun nativeSender(): CommandSender { + val uuid = UUID.randomUUID() + return Proxy.newProxyInstance(CommandSender::class.java.classLoader, arrayOf(CommandSender::class.java)) { instance, method, args -> + when (method.name) { + "hasPermission" -> true + "getDisplayName" -> "sender" + "getUuid" -> uuid + "equals" -> instance === args?.firstOrNull() + "hashCode" -> System.identityHashCode(instance) + "toString" -> "CommandSenderProxy" + else -> defaultValue(method.returnType) + } + } as CommandSender + } + + private inline fun proxy(): T { + return Proxy.newProxyInstance(T::class.java.classLoader, arrayOf(T::class.java)) { instance, method, args -> + when (method.name) { + "equals" -> instance === args?.firstOrNull() + "hashCode" -> System.identityHashCode(instance) + "toString" -> "${T::class.java.simpleName}Proxy" + else -> defaultValue(method.returnType) + } + } as T + } + + private fun defaultValue(type: Class<*>): Any? { + return when (type) { + java.lang.Boolean.TYPE -> false + java.lang.Byte.TYPE -> 0.toByte() + java.lang.Short.TYPE -> 0.toShort() + java.lang.Integer.TYPE -> 0 + java.lang.Long.TYPE -> 0L + java.lang.Float.TYPE -> 0F + java.lang.Double.TYPE -> 0.0 + java.lang.Character.TYPE -> '\u0000' + else -> null + } + } +} diff --git a/platform/platform-hytale/src/test/kotlin/taboolib/platform/HytaleExecutorTest.kt b/platform/platform-hytale/src/test/kotlin/taboolib/platform/HytaleExecutorTest.kt new file mode 100644 index 000000000..f83b130e0 --- /dev/null +++ b/platform/platform-hytale/src/test/kotlin/taboolib/platform/HytaleExecutorTest.kt @@ -0,0 +1,265 @@ +package taboolib.platform + +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertSame +import org.junit.jupiter.api.Assertions.assertThrows +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test +import taboolib.common.platform.service.PlatformExecutor +import java.lang.reflect.InvocationTargetException +import java.lang.reflect.Proxy +import java.util.ArrayDeque +import java.util.concurrent.AbstractExecutorService +import java.util.concurrent.ExecutorService +import java.util.concurrent.RejectedExecutionException +import java.util.concurrent.ScheduledFuture +import java.util.concurrent.TimeUnit + +class HytaleExecutorTest { + + @Test + fun `cancelled pending task never reaches scheduler`() { + val scheduler = RecordingScheduler() + val executor = executor(scheduler) + val task = executor.submit(runnable()) + + task.cancel() + executor.start() + + assertEquals(0, scheduler.scheduleCount) + } + + @Test + fun `synchronous scheduled action propagates and reports user exception`() { + val scheduler = RecordingScheduler() + val failures = ArrayList() + val executor = executor(scheduler, failures = failures) + val failure = IllegalStateException("boom") + executor.start() + executor.submit(runnable { throw failure }) + + val thrown = assertThrows(IllegalStateException::class.java) { + scheduler.action.run() + } + + assertSame(failure, thrown) + assertEquals(listOf(failure), failures) + } + + @Test + fun `async task remains offloaded from scheduler action`() { + val scheduler = RecordingScheduler() + val async = RecordingExecutorService() + val executor = executor(scheduler, async) + var calls = 0 + executor.start() + executor.submit(runnable(async = true) { calls++ }) + + scheduler.action.run() + + assertEquals(0, calls) + assertEquals(1, async.queuedTaskCount) + async.runNext() + assertEquals(1, calls) + } + + @Test + fun `periodic synchronous failure removes active task`() { + val scheduler = RecordingScheduler() + val failures = ArrayList() + val executor = executor(scheduler, failures = failures) + val failure = IllegalStateException("boom") + executor.start() + executor.submit(runnable(period = 1) { throw failure }) + + assertSame(failure, assertThrows(IllegalStateException::class.java) { scheduler.action.run() }) + stop(executor) + + assertEquals(listOf(failure), failures) + assertEquals(0, scheduler.cancelCount) + } + + @Test + fun `periodic async failure does not stop scheduler trigger`() { + val scheduler = RecordingScheduler() + val async = RecordingExecutorService() + val failures = ArrayList() + val executor = executor(scheduler, async, failures) + val failure = IllegalStateException("boom") + executor.start() + executor.submit(runnable(async = true, period = 1) { throw failure }) + + scheduler.action.run() + scheduler.action.run() + + assertEquals(2, async.queuedTaskCount) + repeat(2) { + assertSame(failure, assertThrows(IllegalStateException::class.java) { async.runNext() }) + } + assertEquals(listOf(failure, failure), failures) + } + + @Test + fun `task cancellation reaches scheduled future once`() { + val scheduler = RecordingScheduler() + val executor = executor(scheduler) + executor.start() + + val task = executor.submit(runnable(delay = 2, period = 3)) + task.cancel() + task.cancel() + + assertEquals(1, scheduler.cancelCount) + assertEquals(2, scheduler.runnable.delay) + assertEquals(3, scheduler.runnable.period) + } + + @Test + fun `stop cancels active tasks shuts down executor and rejects submissions`() { + val scheduler = RecordingScheduler() + val async = RecordingExecutorService() + val executor = executor(scheduler, async) + executor.start() + executor.submit(runnable(delay = 1)) + + stop(executor) + + assertEquals(1, scheduler.cancelCount) + assertTrue(async.isShutdown) + assertThrows(RejectedExecutionException::class.java) { + executor.submit(runnable()) + } + } + + @Test + fun `public constructor and scheduled task field remain available`() { + HytaleExecutor::class.java.getConstructor() + assertEquals(ScheduledFuture::class.java, HytaleExecutor.HytaleRunningTask::class.java.getField("scheduledTask").type) + } + + private fun executor( + scheduler: RecordingScheduler, + async: RecordingExecutorService = RecordingExecutorService(), + failures: MutableList = ArrayList(), + ): HytaleExecutor { + val schedulerType = Class.forName("taboolib.platform.HytaleTaskScheduler") + val schedulerProxy = Proxy.newProxyInstance( + schedulerType.classLoader, + arrayOf(schedulerType), + ) { proxy, method, args -> + when (method.name) { + "schedule" -> { + val callArgs = requireNotNull(args) + scheduler.schedule( + callArgs[0] as PlatformExecutor.PlatformRunnable, + callArgs[1] as Runnable, + ) + } + "toString" -> "RecordingSchedulerProxy" + "hashCode" -> System.identityHashCode(proxy) + "equals" -> proxy === args?.firstOrNull() + else -> null + } + } + val constructor = HytaleExecutor::class.java.getDeclaredConstructor( + schedulerType, + ExecutorService::class.java, + Class.forName("kotlin.jvm.functions.Function1"), + java.lang.Boolean.TYPE, + ) + constructor.isAccessible = true + val reporter: (Throwable) -> Unit = { failures.add(it) } + return constructor.newInstance(schedulerProxy, async, reporter, false) + } + + private fun stop(executor: HytaleExecutor) { + val method = HytaleExecutor::class.java.getDeclaredMethod("stop") + method.isAccessible = true + try { + method.invoke(executor) + } catch (ex: InvocationTargetException) { + throw ex.cause ?: ex + } + } + + private fun runnable( + now: Boolean = false, + async: Boolean = false, + delay: Long = 0, + period: Long = 0, + block: PlatformExecutor.PlatformTask.() -> Unit = {}, + ): PlatformExecutor.PlatformRunnable { + return PlatformExecutor.PlatformRunnable(now, async, delay, period, block) + } + + private class RecordingScheduler { + + var scheduleCount = 0 + var cancelCount = 0 + lateinit var action: Runnable + lateinit var runnable: PlatformExecutor.PlatformRunnable + + fun schedule(runnable: PlatformExecutor.PlatformRunnable, action: Runnable): ScheduledFuture<*> { + scheduleCount++ + this.runnable = runnable + this.action = action + return Proxy.newProxyInstance( + ScheduledFuture::class.java.classLoader, + arrayOf(ScheduledFuture::class.java), + ) { proxy, method, args -> + when (method.name) { + "cancel" -> { + cancelCount++ + true + } + "isCancelled", "isDone" -> false + "toString" -> "RecordedScheduledFuture" + "hashCode" -> System.identityHashCode(proxy) + "equals" -> proxy === args?.firstOrNull() + else -> 0 + } + } as ScheduledFuture<*> + } + } + + private class RecordingExecutorService : AbstractExecutorService() { + + private val tasks = ArrayDeque() + private var stopped = false + + val queuedTaskCount: Int + get() = tasks.size + + override fun execute(command: Runnable) { + if (stopped) { + throw RejectedExecutionException("executor stopped") + } + tasks += command + } + + fun runNext() { + tasks.removeFirst().run() + } + + override fun shutdown() { + stopped = true + } + + override fun shutdownNow(): MutableList { + stopped = true + return ArrayList(tasks).also { tasks.clear() } + } + + override fun isShutdown(): Boolean { + return stopped + } + + override fun isTerminated(): Boolean { + return stopped && tasks.isEmpty() + } + + override fun awaitTermination(timeout: Long, unit: TimeUnit): Boolean { + return isTerminated + } + } +} diff --git a/platform/platform-hytale/src/test/kotlin/taboolib/platform/HytaleListenerTest.kt b/platform/platform-hytale/src/test/kotlin/taboolib/platform/HytaleListenerTest.kt new file mode 100644 index 000000000..80b540998 --- /dev/null +++ b/platform/platform-hytale/src/test/kotlin/taboolib/platform/HytaleListenerTest.kt @@ -0,0 +1,44 @@ +package taboolib.platform + +import org.junit.jupiter.api.Assertions.assertSame +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test +import java.util.concurrent.CompletableFuture +import java.util.function.Function + +class HytaleListenerTest { + + @Test + fun `synchronous async handler failure becomes exceptional future`() { + val source = CompletableFuture() + val failure = IllegalStateException("boom") + + val result = invokeAsyncHandler(source, Function { throw failure }) + var captured: Throwable? = null + result.whenComplete { _, throwable -> captured = throwable } + + assertTrue(result.isCompletedExceptionally) + assertSame(failure, captured) + } + + @Test + fun `cancelled future is returned without replacement`() { + val source = CompletableFuture() + source.cancel(false) + + val result = invokeAsyncHandler(source, Function { it }) + + assertSame(source, result) + assertTrue(result.isCancelled) + } + + @Test + fun `handler result future is preserved`() { + val source = CompletableFuture() + val transformed = CompletableFuture() + + val result = invokeAsyncHandler(source, Function { transformed }) + + assertSame(transformed, result) + } +} diff --git a/platform/platform-hytale/src/test/kotlin/taboolib/platform/type/HytaleCommandSenderTest.kt b/platform/platform-hytale/src/test/kotlin/taboolib/platform/type/HytaleCommandSenderTest.kt new file mode 100644 index 000000000..e17c17aaf --- /dev/null +++ b/platform/platform-hytale/src/test/kotlin/taboolib/platform/type/HytaleCommandSenderTest.kt @@ -0,0 +1,95 @@ +package taboolib.platform.type + +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertSame +import org.junit.jupiter.api.Assertions.assertThrows +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test +import java.util.concurrent.CompletableFuture + +class HytaleCommandSenderTest { + + @Test + fun `command dispatch never waits for pending future`() { + val pending = CompletableFuture() + + assertTrue(HytaleCommandSender.dispatchCommand { pending }) + } + + @Test + fun `command dispatch reports successful submission regardless of later state`() { + val cancelled = CompletableFuture() + cancelled.cancel(false) + val failed = CompletableFuture() + failed.completeExceptionally(IllegalStateException("boom")) + + assertTrue(HytaleCommandSender.dispatchCommand { cancelled }) + assertTrue(HytaleCommandSender.dispatchCommand { failed }) + } + + @Test + fun `command dispatch propagates synchronous failure`() { + val failure = IllegalStateException("boom") + + val thrown = assertThrows(IllegalStateException::class.java) { + HytaleCommandSender.dispatchCommand { throw failure } + } + + assertSame(failure, thrown) + } + + @Test + fun `quit callbacks run once across wrapper instances`() { + val session = Any() + var calls = 0 + HytaleCommandSender.registerQuitCallback(session, Runnable { calls++ }) + HytaleCommandSender.registerQuitCallback(session, Runnable { calls++ }) + + HytaleCommandSender.fireQuitCallbacks(session) + HytaleCommandSender.fireQuitCallbacks(session) + + assertEquals(2, calls) + } + + @Test + fun `late quit registration runs immediately until a new session activates`() { + val session = Any() + var calls = 0 + HytaleCommandSender.fireQuitCallbacks(session) + + HytaleCommandSender.registerQuitCallback(session, Runnable { calls++ }) + HytaleCommandSender.activateQuitSession(session) + HytaleCommandSender.registerQuitCallback(session, Runnable { calls++ }) + HytaleCommandSender.fireQuitCallbacks(session) + + assertEquals(2, calls) + } + + @Test + fun `clearing quit callbacks releases pending registrations`() { + val session = Any() + var calls = 0 + HytaleCommandSender.registerQuitCallback(session, Runnable { calls++ }) + + HytaleCommandSender.clearQuitCallbacks() + HytaleCommandSender.fireQuitCallbacks(session) + + assertEquals(0, calls) + } + + @Test + fun `quit callback failure does not skip remaining callbacks`() { + val session = Any() + val failure = IllegalStateException("boom") + var calls = 0 + HytaleCommandSender.registerQuitCallback(session, Runnable { throw failure }) + HytaleCommandSender.registerQuitCallback(session, Runnable { calls++ }) + + val thrown = assertThrows(IllegalStateException::class.java) { + HytaleCommandSender.fireQuitCallbacks(session) + } + + assertSame(failure, thrown) + assertEquals(1, calls) + } +} diff --git a/platform/platform-velocity-impl/src/main/kotlin/taboolib/platform/VelocityAdapter.kt b/platform/platform-velocity-impl/src/main/kotlin/taboolib/platform/VelocityAdapter.kt index c26fb447f..d432f2052 100644 --- a/platform/platform-velocity-impl/src/main/kotlin/taboolib/platform/VelocityAdapter.kt +++ b/platform/platform-velocity-impl/src/main/kotlin/taboolib/platform/VelocityAdapter.kt @@ -33,11 +33,15 @@ class VelocityAdapter : PlatformAdapter { } override fun adaptPlayer(any: Any): ProxyPlayer { - return VelocityPlayer(any as Player) + return if (any is ProxyPlayer) any else VelocityPlayer(any as Player) } override fun adaptCommandSender(any: Any): ProxyCommandSender { - return if (any is Player) adaptPlayer(any) else VelocityCommandSender(any as CommandSource) + return when (any) { + is ProxyCommandSender -> any + is Player -> adaptPlayer(any) + else -> VelocityCommandSender(any as CommandSource) + } } override fun adaptLocation(any: Any): Location { diff --git a/platform/platform-velocity-impl/src/test/kotlin/taboolib/platform/VelocityAdapterTest.kt b/platform/platform-velocity-impl/src/test/kotlin/taboolib/platform/VelocityAdapterTest.kt new file mode 100644 index 000000000..7b9fdcc6f --- /dev/null +++ b/platform/platform-velocity-impl/src/test/kotlin/taboolib/platform/VelocityAdapterTest.kt @@ -0,0 +1,80 @@ +package taboolib.platform + +import com.velocitypowered.api.command.CommandSource +import com.velocitypowered.api.proxy.Player +import org.junit.jupiter.api.Assertions.assertSame +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test +import taboolib.common.platform.ProxyCommandSender +import taboolib.common.platform.ProxyPlayer +import taboolib.platform.type.VelocityCommandSender +import taboolib.platform.type.VelocityPlayer +import java.lang.reflect.Proxy + +class VelocityAdapterTest { + + private val adapter = VelocityAdapter() + + @Test + fun `existing proxy player keeps identity in both adapter paths`() { + val player = proxy() + + assertSame(player, adapter.adaptPlayer(player)) + assertSame(player, adapter.adaptCommandSender(player)) + } + + @Test + fun `existing proxy command sender keeps identity`() { + val sender = proxy() + + assertSame(sender, adapter.adaptCommandSender(sender)) + } + + @Test + fun `velocity player remains a proxy player through sender adapter`() { + val player = proxy() + + val adaptedPlayer = adapter.adaptPlayer(player) + val adaptedSender = adapter.adaptCommandSender(player) + + assertTrue(adaptedPlayer is VelocityPlayer) + assertTrue(adaptedSender is VelocityPlayer) + assertSame(player, adaptedPlayer.origin) + assertSame(player, adaptedSender.origin) + } + + @Test + fun `non-player command source uses command sender adapter`() { + val sender = proxy() + + val adapted = adapter.adaptCommandSender(sender) + + assertTrue(adapted is VelocityCommandSender) + assertSame(sender, adapted.origin) + } + + private inline fun proxy(): T { + return Proxy.newProxyInstance(T::class.java.classLoader, arrayOf(T::class.java)) { instance, method, args -> + when (method.name) { + "equals" -> instance === args?.firstOrNull() + "hashCode" -> System.identityHashCode(instance) + "toString" -> "${T::class.java.simpleName}Proxy" + else -> defaultValue(method.returnType) + } + } as T + } + + private fun defaultValue(type: Class<*>): Any? { + return when (type) { + java.lang.Boolean.TYPE -> false + java.lang.Byte.TYPE -> 0.toByte() + java.lang.Short.TYPE -> 0.toShort() + java.lang.Integer.TYPE -> 0 + java.lang.Long.TYPE -> 0L + java.lang.Float.TYPE -> 0F + java.lang.Double.TYPE -> 0.0 + java.lang.Character.TYPE -> '\u0000' + else -> null + } + } +} From faad63a338c8352b01bf9487969c69549e33492c Mon Sep 17 00:00:00 2001 From: zhibei <785740487@qq.com> Date: Tue, 14 Jul 2026 04:30:57 +0800 Subject: [PATCH 21/37] =?UTF-8?q?fix(navigation):=20=E4=BF=AE=E5=A4=8D?= =?UTF-8?q?=E4=B8=96=E7=95=8C=E8=BE=B9=E7=95=8C=E4=B8=8E=E8=B7=AF=E5=BE=84?= =?UTF-8?q?=E5=B9=B3=E6=BB=91=E5=88=A4=E5=AE=9A?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../taboolib/module/navigation/Fluid.kt | 10 ++- .../taboolib/module/navigation/NodeEntity.kt | 8 +- .../taboolib/module/navigation/NodeReader.kt | 55 +++++++++---- .../module/navigation/PathSmoothing.kt | 81 ++++++++++++++----- .../navigation/RandomPositionGenerator.kt | 11 ++- .../taboolib/module/navigation/Utils.kt | 15 +++- .../navigation/NavigationCorrectnessTest.kt | 67 +++++++++++++++ 7 files changed, 203 insertions(+), 44 deletions(-) create mode 100644 module/bukkit/bukkit-navigation/src/test/kotlin/taboolib/module/navigation/NavigationCorrectnessTest.kt diff --git a/module/bukkit/bukkit-navigation/src/main/kotlin/taboolib/module/navigation/Fluid.kt b/module/bukkit/bukkit-navigation/src/main/kotlin/taboolib/module/navigation/Fluid.kt index 928c7e790..41dd254c2 100644 --- a/module/bukkit/bukkit-navigation/src/main/kotlin/taboolib/module/navigation/Fluid.kt +++ b/module/bukkit/bukkit-navigation/src/main/kotlin/taboolib/module/navigation/Fluid.kt @@ -1,6 +1,8 @@ package taboolib.module.navigation import org.bukkit.block.Block +import org.bukkit.block.data.Waterlogged +import taboolib.module.nms.MinecraftVersion /** * Navigation @@ -26,7 +28,13 @@ enum class Fluid { "WATER" -> WATER "STATIONARY_WATER" -> WATER "FLOWING_WATER" -> FLOWING_WATER - else -> EMPTY + else -> { + if (MinecraftVersion.isHigherOrEqual(MinecraftVersion.V1_13)) { + (blockData as? Waterlogged)?.takeIf { it.isWaterlogged }?.let { WATER } ?: EMPTY + } else { + EMPTY + } + } } fun String.getFluid() = when (this) { diff --git a/module/bukkit/bukkit-navigation/src/main/kotlin/taboolib/module/navigation/NodeEntity.kt b/module/bukkit/bukkit-navigation/src/main/kotlin/taboolib/module/navigation/NodeEntity.kt index 64a022736..988ffb8fc 100644 --- a/module/bukkit/bukkit-navigation/src/main/kotlin/taboolib/module/navigation/NodeEntity.kt +++ b/module/bukkit/bukkit-navigation/src/main/kotlin/taboolib/module/navigation/NodeEntity.kt @@ -5,6 +5,7 @@ import org.bukkit.Location import org.bukkit.World import org.bukkit.block.BlockFace import org.bukkit.util.Vector +import taboolib.module.navigation.Fluid.Companion.getFluid import taboolib.platform.util.callRegion import java.util.* @@ -70,8 +71,9 @@ open class NodeEntity( } fun getWalkTargetValue(pos: Vector): Double { - return location.callRegion { - this.getWalkTargetValue(pos, location.world!!) + val world = location.world!! + return pos.toLocation(world).callRegion { + this.getWalkTargetValue(pos, world) } } @@ -98,7 +100,7 @@ open class NodeEntity( open fun isInWater(): Boolean { return location.callRegion { - location.block.isLiquid + location.block.getFluid().isWater() } } diff --git a/module/bukkit/bukkit-navigation/src/main/kotlin/taboolib/module/navigation/NodeReader.kt b/module/bukkit/bukkit-navigation/src/main/kotlin/taboolib/module/navigation/NodeReader.kt index 22b44774a..8f74652ba 100644 --- a/module/bukkit/bukkit-navigation/src/main/kotlin/taboolib/module/navigation/NodeReader.kt +++ b/module/bukkit/bukkit-navigation/src/main/kotlin/taboolib/module/navigation/NodeReader.kt @@ -34,7 +34,7 @@ open class NodeReader(val entity: NodeEntity) { } fun getNode(x: Int, y: Int, z: Int): Node { - return nodes.computeIfAbsent(Node.createHash(x, y, z)) { Node(x, y, z) } + return getOrCreateNavigationNode(nodes, x, y, z) } fun getCachedBlockType(x: Int, y: Int, z: Int): PathType { @@ -58,37 +58,41 @@ open class NodeReader(val entity: NodeEntity) { private fun getStartAtRegion(): Node { val position = Vector(0, 0, 0) - var y = entity.location.blockY + val minHeight = world.navigationMinHeight() + val maxHeight = world.maxHeight + var y = entity.location.blockY.coerceIn(minHeight, maxHeight - 1) var block = world.getBlockAt(position.set(entity.location.blockX, y, entity.location.blockZ)) var blockposition: Vector if (!entity.canStandOnFluid(block.getFluid())) { if (entity.canFloat && entity.isInWater()) { - while (true) { - if (!block.isLiquid) { - --y - break - } + while (block.getFluid().isWater() && y < maxHeight - 1) { ++y block = world.getBlockAt(position.set(entity.location.blockX, y, entity.location.blockZ)) } + if (!block.getFluid().isWater()) { + --y + } } else if (entity.isOnGround()) { y = NumberConversions.floor(entity.location.y + 0.5) } else { - blockposition = entity.location.toVector() - while (!blockposition.toBlock(block.world).type.isSolid && blockposition.y > 0) { + blockposition = entity.location.toVector().apply { + setY(blockY.coerceIn(minHeight, maxHeight - 1).toDouble()) + } + var ground = blockposition.toBlock(block.world) + while (!ground.type.isSolid && blockposition.blockY > minHeight) { blockposition = blockposition.down() + ground = blockposition.toBlock(block.world) } - y = blockposition.up().blockY + y = if (ground.type.isSolid) blockposition.up().blockY.coerceAtMost(maxHeight - 1) else minHeight } } else { - while (true) { - if (!entity.canStandOnFluid(block.getFluid())) { - --y - break - } + while (entity.canStandOnFluid(block.getFluid()) && y < maxHeight - 1) { ++y block = world.getBlockAt(position.set(entity.location.blockX, y, entity.location.blockZ)) } + if (!entity.canStandOnFluid(block.getFluid())) { + --y + } } blockposition = entity.location.toVector() val blockPathType = getCachedBlockType(blockposition.blockX, y, blockposition.blockZ) @@ -164,7 +168,7 @@ open class NodeReader(val entity: NodeEntity) { if (getCachedBlockType(x, h - 1, z) != PathType.WATER) { return node } - while (h > 0) { + while (h > world.navigationMinHeight()) { --h pathTypes = getCachedBlockType(x, h, z) if (pathTypes != PathType.WATER) { @@ -181,7 +185,7 @@ open class NodeReader(val entity: NodeEntity) { var air = h while (pathTypes == PathType.OPEN) { --air - if (air < 0) { + if (air < world.navigationMinHeight()) { val node1 = getNode(x, air, z) node1.type = PathType.BLOCKED node1.costMalus = -1.0f @@ -318,3 +322,20 @@ open class NodeReader(val entity: NodeEntity) { return neighbors } } + +@JvmSynthetic +internal fun getOrCreateNavigationNode(nodes: MutableMap, x: Int, y: Int, z: Int): Node { + val initialKey = Node.createHash(x, y, z) + var key = initialKey + while (true) { + val existing = nodes[key] + if (existing == null) { + return Node(x, y, z).also { nodes[key] = it } + } + if (existing.x == x && existing.y == y && existing.z == z) { + return existing + } + key = key * 31 + 1 + check(key != initialKey) { "Unable to resolve navigation node hash collision" } + } +} diff --git a/module/bukkit/bukkit-navigation/src/main/kotlin/taboolib/module/navigation/PathSmoothing.kt b/module/bukkit/bukkit-navigation/src/main/kotlin/taboolib/module/navigation/PathSmoothing.kt index 1eddd2602..cf8f22ccc 100644 --- a/module/bukkit/bukkit-navigation/src/main/kotlin/taboolib/module/navigation/PathSmoothing.kt +++ b/module/bukkit/bukkit-navigation/src/main/kotlin/taboolib/module/navigation/PathSmoothing.kt @@ -4,9 +4,11 @@ import org.bukkit.Location import org.bukkit.World import org.bukkit.util.Vector import taboolib.platform.util.callRegion +import kotlin.math.abs import kotlin.math.ceil import kotlin.math.floor -import kotlin.math.sqrt +import kotlin.math.max +import kotlin.math.min /** * 路径平滑后处理(String Pulling / 拉绳法) @@ -19,14 +21,14 @@ import kotlin.math.sqrt */ object PathSmoothing { - /** 视线检测采样步长(格) */ - private const val SAMPLE_STEP = 0.5 - /** * 对 A* 路径进行平滑处理 * 返回平滑后的世界坐标点列表(方块中心) */ fun smooth(path: Path, entity: NodeEntity): List { + if (path.nodes.isEmpty()) { + return emptyList() + } return entity.location.callRegion { smoothAtRegion(path, entity) } @@ -68,18 +70,37 @@ object PathSmoothing { private fun hasLineOfSightAtRegion(from: Vector, to: Vector, entity: NodeEntity, world: World): Boolean { val dx = to.x - from.x val dz = to.z - from.z - val dist = sqrt(dx * dx + dz * dz) - if (dist < 1e-6) return true - val steps = ceil(dist / SAMPLE_STEP).toInt() - for (i in 0..steps) { - val t = i.toDouble() / steps - val x = from.x + dx * t - val z = from.z + dz * t - if (!isStandableAtRegion(x, from.y, z, entity, world)) return false + if (abs(dx) < 1.0E-6 && abs(dz) < 1.0E-6) return true + val boundaries = sortedSetOf(0.0, 1.0) + addSweepBoundaries(from.x - entity.width / 2.0, dx, boundaries) + addSweepBoundaries(from.x + entity.width / 2.0, dx, boundaries) + addSweepBoundaries(from.z - entity.depth / 2.0, dz, boundaries) + addSweepBoundaries(from.z + entity.depth / 2.0, dz, boundaries) + val samples = boundaries.toList() + for (index in samples.indices) { + val t = samples[index] + if (!isStandableAtRegion(from.x + dx * t, from.y, from.z + dz * t, entity, world)) return false + if (index + 1 < samples.size) { + val midpoint = (t + samples[index + 1]) / 2.0 + if (!isStandableAtRegion(from.x + dx * midpoint, from.y, from.z + dz * midpoint, entity, world)) return false + } } return true } + private fun addSweepBoundaries(start: Double, delta: Double, boundaries: MutableSet) { + if (abs(delta) < 1.0E-6) return + val end = start + delta + val first = floor(min(start, end)).toInt() + val last = ceil(max(start, end)).toInt() + for (boundary in first..last) { + val t = (boundary - start) / delta + if (t > 0.0 && t < 1.0) { + boundaries += t + } + } + } + /** * 检查某个世界坐标位置是否可供实体站立 * - 脚下有支撑(非空气) @@ -95,26 +116,48 @@ object PathSmoothing { val halfWidth = entity.width / 2.0 val halfDepth = entity.depth / 2.0 val minBx = floor(x - halfWidth).toInt() - val maxBx = floor(x + halfWidth).toInt() + val maxBx = ceil(x + halfWidth).toInt() - 1 val minBz = floor(z - halfDepth).toInt() - val maxBz = floor(z + halfDepth).toInt() + val maxBz = ceil(z + halfDepth).toInt() - 1 val by = floor(y).toInt() val heightBlocks = ceil(entity.height).toInt() + if (!isWithinNavigationHeight(by, world.navigationMinHeight(), world.maxHeight) + || !isWithinNavigationHeight(by + heightBlocks - 1, world.navigationMinHeight(), world.maxHeight)) { + return false + } + val typeFactory = PathTypeFactory(entity) for (bx in minBx..maxBx) { for (bz in minBz..maxBz) { - // 脚下方块必须有支撑 val below = world.getBlockAtIfLoaded(Vector(bx, by - 1, bz)) ?: return false - if (below.type.isAirLegacy()) return false - // 实体身体占据的空间必须可通行 + val supportY = below.y + NMS.instance.getBlockHeight(below) + if (abs(supportY - y) > 1.0E-3) { + return false + } + val feetType = typeFactory.getTypeAsWalkable(world, Vector(bx, by, bz)) + if (!isSafeSmoothingFeetType(feetType, entity.getPathfindingMalus(feetType))) { + return false + } for (oy in 0 until heightBlocks) { - val block = world.getBlockAtIfLoaded(Vector(bx, by + oy, bz)) ?: return false - if (block.type.isSolid) return false + val bodyType = typeFactory.evaluateType(PathTypeFactory.getRawType(world, Vector(bx, by + oy, bz))) + if (!isSafeSmoothingBodyType(entity.getPathfindingMalus(bodyType))) { + return false + } } } } return true } + @JvmSynthetic + internal fun isSafeSmoothingFeetType(pathType: PathType, malus: Float): Boolean { + return pathType != PathType.OPEN && malus == 0.0f + } + + @JvmSynthetic + internal fun isSafeSmoothingBodyType(malus: Float): Boolean { + return malus == 0.0f + } + private fun nodeCenter(node: Node): Vector { return Vector(node.x + 0.5, node.y.toDouble(), node.z + 0.5) } diff --git a/module/bukkit/bukkit-navigation/src/main/kotlin/taboolib/module/navigation/RandomPositionGenerator.kt b/module/bukkit/bukkit-navigation/src/main/kotlin/taboolib/module/navigation/RandomPositionGenerator.kt index cf0b8a58e..24314c953 100644 --- a/module/bukkit/bukkit-navigation/src/main/kotlin/taboolib/module/navigation/RandomPositionGenerator.kt +++ b/module/bukkit/bukkit-navigation/src/main/kotlin/taboolib/module/navigation/RandomPositionGenerator.kt @@ -136,7 +136,7 @@ object RandomPositionGenerator { } } var result = Vector((x + nodeEntity.x).toInt(), (y + nodeEntity.y).toInt(), (z + nodeEntity.z).toInt()) - if (result.y < 0) { + if (!isWithinNavigationHeight(result.blockY, world.navigationMinHeight(), world.maxHeight)) { return@repeat } if (hasRestriction && !nodeEntity.isWithinRestriction(result)) { @@ -146,7 +146,7 @@ object RandomPositionGenerator { return@repeat } if (aboveLand) { - result = moveUp(result, 0, 256) { + result = moveUp(result, 0, world.maxHeight) { if (Folia.isFolia) { world.getBlockAtIfLoaded(it)?.type?.isSolid == true } else { @@ -159,7 +159,7 @@ object RandomPositionGenerator { } else { world.getBlockAt(result.toLocation(world)).type } - if (onWater || blockType?.isWater() == true) { + if (acceptsNavigationSurface(onWater, blockType?.isWater() == true)) { val type = navigation.getTypeAsWalkable(world, result) if (nodeEntity.getPathfindingMalus(type) == 0.0f) { val walk = nodeEntity.getWalkTargetValue(result) @@ -200,6 +200,11 @@ object RandomPositionGenerator { } } + @JvmSynthetic + internal fun acceptsNavigationSurface(allowWater: Boolean, isWater: Boolean): Boolean { + return allowWater || !isWater + } + private fun randomDelta(random: Random, restrictX: Int, restrictY: Int, vector: Vector?): Vector? { return if (vector != null) { val size = atan2(vector.z, vector.x) - PI_OF_TWO diff --git a/module/bukkit/bukkit-navigation/src/main/kotlin/taboolib/module/navigation/Utils.kt b/module/bukkit/bukkit-navigation/src/main/kotlin/taboolib/module/navigation/Utils.kt index 5ef74bd76..434685ae7 100644 --- a/module/bukkit/bukkit-navigation/src/main/kotlin/taboolib/module/navigation/Utils.kt +++ b/module/bukkit/bukkit-navigation/src/main/kotlin/taboolib/module/navigation/Utils.kt @@ -21,6 +21,9 @@ fun World.getBlockAtIfLoaded(position: Vector): Block? { val x = position.blockX val y = position.blockY val z = position.blockZ + if (!isWithinNavigationHeight(y, navigationMinHeight(), maxHeight)) { + return null + } return callRegion(x, y, z) { if (ChunkAccess.instance.isChunkLoaded(this, x shr 4, z shr 4)) { getBlockAt(x, y, z) @@ -30,6 +33,16 @@ fun World.getBlockAtIfLoaded(position: Vector): Block? { } } +@JvmSynthetic +internal fun World.navigationMinHeight(): Int { + return if (MinecraftVersion.isHigherOrEqual(MinecraftVersion.V1_17)) minHeight else 0 +} + +@JvmSynthetic +internal fun isWithinNavigationHeight(y: Int, minHeight: Int, maxHeight: Int): Boolean { + return y >= minHeight && y < maxHeight +} + fun Vector.toBlock(world: World) = toLocation(world).block fun Vector.down() = Vector(x, y - 1, z) @@ -115,7 +128,7 @@ fun Material.isAirLegacy(): Boolean { } fun Material.isWater(): Boolean { - return name.contains("WATER") + return name == "WATER" || name == "STATIONARY_WATER" || name == "FLOWING_WATER" } fun Block.isTrapdoorOpen(): Boolean { diff --git a/module/bukkit/bukkit-navigation/src/test/kotlin/taboolib/module/navigation/NavigationCorrectnessTest.kt b/module/bukkit/bukkit-navigation/src/test/kotlin/taboolib/module/navigation/NavigationCorrectnessTest.kt new file mode 100644 index 000000000..62e10cc5e --- /dev/null +++ b/module/bukkit/bukkit-navigation/src/test/kotlin/taboolib/module/navigation/NavigationCorrectnessTest.kt @@ -0,0 +1,67 @@ +package taboolib.module.navigation + +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertFalse +import org.junit.jupiter.api.Assertions.assertNotSame +import org.junit.jupiter.api.Assertions.assertSame +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test + +class NavigationCorrectnessTest { + + @Test + fun `world height bounds include negative build height and exclude max height`() { + assertFalse(isWithinNavigationHeight(-65, -64, 320)) + assertTrue(isWithinNavigationHeight(-64, -64, 320)) + assertTrue(isWithinNavigationHeight(319, -64, 320)) + assertFalse(isWithinNavigationHeight(320, -64, 320)) + } + + @Test + fun `node cache resolves legacy hash collisions across modern world heights`() { + val nodes = HashMap() + assertEquals(Node.createHash(4, -64, 8), Node.createHash(4, 192, 8)) + + val low = getOrCreateNavigationNode(nodes, 4, -64, 8) + val high = getOrCreateNavigationNode(nodes, 4, 192, 8) + + assertNotSame(low, high) + assertEquals(-64, low.y) + assertEquals(192, high.y) + assertSame(low, getOrCreateNavigationNode(nodes, 4, -64, 8)) + assertSame(high, getOrCreateNavigationNode(nodes, 4, 192, 8)) + } + + @Test + fun `surface selection only rejects water when water is disabled`() { + assertTrue(RandomPositionGenerator.acceptsNavigationSurface(false, false)) + assertFalse(RandomPositionGenerator.acceptsNavigationSurface(false, true)) + assertTrue(RandomPositionGenerator.acceptsNavigationSurface(true, false)) + assertTrue(RandomPositionGenerator.acceptsNavigationSurface(true, true)) + } + + @Test + fun `fluid categories keep water and lava distinct`() { + assertTrue(Fluid.WATER.isWater()) + assertTrue(Fluid.FLOWING_WATER.isWater()) + assertFalse(Fluid.LAVA.isWater()) + assertFalse(Fluid.FLOWING_LAVA.isWater()) + assertTrue(Fluid.LAVA.isLava()) + assertTrue(Fluid.FLOWING_LAVA.isLava()) + assertFalse(Fluid.WATER.isLava()) + } + + @Test + fun `path smoothing rejects unsupported liquid dangerous and blocked cells`() { + assertTrue(PathSmoothing.isSafeSmoothingFeetType(PathType.WALKABLE, 0.0f)) + assertFalse(PathSmoothing.isSafeSmoothingFeetType(PathType.OPEN, 0.0f)) + assertFalse(PathSmoothing.isSafeSmoothingFeetType(PathType.WATER, PathType.WATER.malus)) + assertFalse(PathSmoothing.isSafeSmoothingFeetType(PathType.LAVA, PathType.LAVA.malus)) + assertFalse(PathSmoothing.isSafeSmoothingFeetType(PathType.DANGER_FIRE, PathType.DANGER_FIRE.malus)) + + assertTrue(PathSmoothing.isSafeSmoothingBodyType(PathType.OPEN.malus)) + assertFalse(PathSmoothing.isSafeSmoothingBodyType(PathType.WATER.malus)) + assertFalse(PathSmoothing.isSafeSmoothingBodyType(PathType.DAMAGE_FIRE.malus)) + assertFalse(PathSmoothing.isSafeSmoothingBodyType(PathType.BLOCKED.malus)) + } +} From 8d33d67d318ab583546a8ce3315f8cb20c9d9256 Mon Sep 17 00:00:00 2001 From: zhibei <785740487@qq.com> Date: Tue, 14 Jul 2026 04:53:06 +0800 Subject: [PATCH 22/37] =?UTF-8?q?fix(lang):=20=E4=BF=AE=E5=A4=8D=E8=AF=AD?= =?UTF-8?q?=E8=A8=80=E9=87=8D=E8=BD=BD=E4=B8=8E=E9=A2=9C=E8=89=B2=E8=A7=A3?= =?UTF-8?q?=E6=9E=90=E8=BE=B9=E7=95=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../minecraft/minecraft-chat/build.gradle.kts | 1 + .../java/taboolib/module/chat/HexColor.java | 94 ++--- .../main/kotlin/taboolib/module/chat/Util.kt | 19 +- .../taboolib/module/chat/ColorParsingTest.kt | 46 +++ .../minecraft/minecraft-i18n/build.gradle.kts | 4 + .../taboolib/module/lang/SnapshotHashMap.java | 365 ++++++++++++++++++ .../kotlin/taboolib/module/lang/Language.kt | 22 +- .../taboolib/module/lang/LanguageFile.kt | 10 +- .../taboolib/module/lang/ResourceReader.kt | 42 +- .../kotlin/taboolib/module/lang/TypeJson.kt | 41 +- .../module/lang/LanguageBoundaryTest.kt | 94 +++++ 11 files changed, 655 insertions(+), 83 deletions(-) create mode 100644 module/minecraft/minecraft-chat/src/test/kotlin/taboolib/module/chat/ColorParsingTest.kt create mode 100644 module/minecraft/minecraft-i18n/src/main/java/taboolib/module/lang/SnapshotHashMap.java create mode 100644 module/minecraft/minecraft-i18n/src/test/kotlin/taboolib/module/lang/LanguageBoundaryTest.kt diff --git a/module/minecraft/minecraft-chat/build.gradle.kts b/module/minecraft/minecraft-chat/build.gradle.kts index b090f0409..f96a00579 100644 --- a/module/minecraft/minecraft-chat/build.gradle.kts +++ b/module/minecraft/minecraft-chat/build.gradle.kts @@ -9,4 +9,5 @@ dependencies { compileOnly(project(":common-env")) compileOnly(project(":common-platform-api")) compileOnly(project(":common-util")) + testImplementation("net.md-5:bungeecord-chat:1.21-R0.4") } \ No newline at end of file diff --git a/module/minecraft/minecraft-chat/src/main/java/taboolib/module/chat/HexColor.java b/module/minecraft/minecraft-chat/src/main/java/taboolib/module/chat/HexColor.java index 2d486b47e..2ca6a7b64 100644 --- a/module/minecraft/minecraft-chat/src/main/java/taboolib/module/chat/HexColor.java +++ b/module/minecraft/minecraft-chat/src/main/java/taboolib/module/chat/HexColor.java @@ -2,6 +2,7 @@ import net.md_5.bungee.api.ChatColor; import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; import java.awt.*; import java.util.Optional; @@ -46,34 +47,25 @@ public static String translate(String in) { return ChatColor.translateAlternateColorCodes('&', in); } StringBuilder builder = new StringBuilder(); - char[] chars = in.toCharArray(); - for (int i = 0; i < chars.length; i++) { - if (i + 1 < chars.length && chars[i] == '&' && chars[i + 1] == '{') { - ChatColor chatColor = null; - char[] match = new char[0]; - for (int j = i + 2; j < chars.length && chars[j] != '}'; j++) { - match = arrayAppend(match, chars[j]); - } - if (match.length == 11 && (match[3] == ',' || match[3] == '-') && (match[7] == ',' || match[7] == '-')) { - chatColor = ChatColor.of(new Color(toInt(match, 0, 3), toInt(match, 4, 7), toInt(match, 8, 11))); - } else if (match.length == 7 && match[0] == '#') { - try { - chatColor = ChatColor.of(toString(match)); - } catch (IllegalArgumentException ignored) { - } - } else { - Optional knownColor = StandardColors.match(toString(match)); + for (int i = 0; i < in.length(); i++) { + if (i + 1 < in.length() && in.charAt(i) == '&' && in.charAt(i + 1) == '{') { + int end = in.indexOf('}', i + 2); + if (end >= 0) { + String expression = in.substring(i + 2, end).trim(); + Optional knownColor = StandardColors.match(expression); + Integer color = parseColor(expression); if (knownColor.isPresent()) { - chatColor = knownColor.get().toChatColor(); + builder.append(knownColor.get().toChatColor()); + i = end; + continue; + } else if (color != null) { + builder.append(ChatColor.of(new Color(color))); + i = end; + continue; } } - if (chatColor != null) { - builder.append(chatColor); - i += match.length + 2; - } - } else { - builder.append(chars[i]); } + builder.append(in.charAt(i)); } String colorString = builder.toString(); // 1.20.4 不再支持该写法,该模块无法判断版本,因此全部替换为白色 @@ -86,26 +78,42 @@ public static String getColorCode(int color) { return ChatColor.of(new Color(color)).toString(); } - private static char[] arrayAppend(char[] chars, char in) { - char[] newChars = new char[chars.length + 1]; - System.arraycopy(chars, 0, newChars, 0, chars.length); - newChars[chars.length] = in; - return newChars; - } - - private static String toString(char[] chars) { - StringBuilder builder = new StringBuilder(); - for (char c : chars) { - builder.append(c); + @Nullable + static Integer parseColor(String source) { + String value = source.trim(); + if (value.matches("#[0-9a-fA-F]{6}")) { + return Integer.parseInt(value.substring(1), 16); } - return builder.toString(); - } - - private static int toInt(char[] chars, int start, int end) { - StringBuilder builder = new StringBuilder(); - for (int i = start; i < end; i++) { - builder.append(chars[i]); + Character separator = null; + if (value.indexOf(',') >= 0) { + separator = ','; + } else if (value.indexOf('-') >= 0) { + separator = '-'; + } + if (separator != null) { + String[] parts = value.split("\\" + separator, -1); + if (parts.length != 3) { + return null; + } + int color = 0; + for (String part : parts) { + int component; + try { + component = Integer.parseInt(part.trim()); + } catch (NumberFormatException ignored) { + return null; + } + if (component < 0 || component > 255) { + return null; + } + color = color << 8 | component; + } + return color; + } + Optional knownColor = StandardColors.match(value); + if (knownColor.isPresent() && knownColor.get().toChatColor().getColor() != null) { + return knownColor.get().toChatColor().getColor().getRGB() & 0xFFFFFF; } - return Integer.parseInt(builder.toString()); + return null; } } diff --git a/module/minecraft/minecraft-chat/src/main/kotlin/taboolib/module/chat/Util.kt b/module/minecraft/minecraft-chat/src/main/kotlin/taboolib/module/chat/Util.kt index 763b74c39..0569404ce 100644 --- a/module/minecraft/minecraft-chat/src/main/kotlin/taboolib/module/chat/Util.kt +++ b/module/minecraft/minecraft-chat/src/main/kotlin/taboolib/module/chat/Util.kt @@ -2,7 +2,6 @@ package taboolib.module.chat import net.md_5.bungee.api.ChatColor import taboolib.common.platform.function.warning -import taboolib.common.util.orNull import taboolib.common.util.t import kotlin.math.ceil @@ -53,23 +52,7 @@ fun List.uncolored() = map { it.uncolored() } * 获取颜色 */ fun String.parseToHexColor(): Int { - // HEX: #ffffff - if (startsWith('#')) { - return substring(1).toIntOrNull(16) ?: 0 - } - // RGB: 255,255,255 - if (contains(',')) { - return split(',').map { it.toIntOrNull() ?: 0 }.let { (r, g, b) -> (r shl 16) or (g shl 8) or b } - } - // RGB: 255-255-255 - if (contains('-')) { - return split('-').map { it.toIntOrNull() ?: 0 }.let { (r, g, b) -> (r shl 16) or (g shl 8) or b } - } - // NAMED: white - val knownColor = StandardColors.match(this) - if (knownColor.orNull()?.chatColor?.color != null) { - return knownColor.get().chatColor.color.rgb - } + HexColor.parseColor(this)?.let { return it } warning( """ $this 不是一个颜色。 diff --git a/module/minecraft/minecraft-chat/src/test/kotlin/taboolib/module/chat/ColorParsingTest.kt b/module/minecraft/minecraft-chat/src/test/kotlin/taboolib/module/chat/ColorParsingTest.kt new file mode 100644 index 000000000..85dfbf4e7 --- /dev/null +++ b/module/minecraft/minecraft-chat/src/test/kotlin/taboolib/module/chat/ColorParsingTest.kt @@ -0,0 +1,46 @@ +package taboolib.module.chat + +import net.md_5.bungee.api.ChatColor +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertNull +import org.junit.jupiter.api.Test +import java.awt.Color + +class ColorParsingTest { + + @Test + fun `strict color parser preserves black and leading zero colors`() { + assertEquals(0x000000, HexColor.parseColor("#000000")) + assertEquals(0x000001, HexColor.parseColor("#000001")) + assertEquals(0x00ff0a, HexColor.parseColor("#00ff0a")) + assertEquals(0xffffff, HexColor.parseColor("#FFFFFF")) + } + + @Test + fun `strict color parser rejects malformed hex and rgb`() { + listOf("#fff", "#00000", "#0000000", "#gggggg", "#", "##000000").forEach { + assertNull(HexColor.parseColor(it), it) + } + listOf("1,2", "1,2,3,4", "255,x,255", "256,0,0", "-1,0,0", "1--2-3", "1,,3").forEach { + assertNull(HexColor.parseColor(it), it) + } + } + + @Test + fun `strict color parser accepts variable width rgb components`() { + assertEquals(0x000000, HexColor.parseColor("0,0,0")) + assertEquals(0x0114ff, HexColor.parseColor("1,20,255")) + assertEquals(0xffffff, HexColor.parseColor("255-255-255")) + assertEquals(0x0114ff, HexColor.parseColor("1 - 20 - 255")) + } + + @Test + fun `hex translation preserves invalid expressions and parses valid rgb`() { + assertEquals("&{999,0,0}x", HexColor.translate("&{999,0,0}x")) + assertEquals("&{abc,def,ghi}x", HexColor.translate("&{abc,def,ghi}x")) + assertEquals("&{1,2", HexColor.translate("&{1,2")) + assertEquals("${ChatColor.of(Color(1, 20, 255))}x", HexColor.translate("&{1,20,255}x")) + assertEquals("${ChatColor.BLUE}x", HexColor.translate("&{BLUE}x")) + assertEquals("${ChatColor.WHITE}x", HexColor.translate("&{RESET}x")) + } +} diff --git a/module/minecraft/minecraft-i18n/build.gradle.kts b/module/minecraft/minecraft-i18n/build.gradle.kts index c33b879c1..82b5a4551 100644 --- a/module/minecraft/minecraft-i18n/build.gradle.kts +++ b/module/minecraft/minecraft-i18n/build.gradle.kts @@ -6,4 +6,8 @@ dependencies { compileOnly(project(":common-util")) compileOnly(project(":module:minecraft:minecraft-chat")) compileOnly(project(":module:basic:basic-configuration")) + testImplementation(project(":common-platform-api")) + testImplementation(project(":common-util")) + testImplementation(project(":module:minecraft:minecraft-chat")) + testImplementation(project(":module:basic:basic-configuration")) } \ No newline at end of file diff --git a/module/minecraft/minecraft-i18n/src/main/java/taboolib/module/lang/SnapshotHashMap.java b/module/minecraft/minecraft-i18n/src/main/java/taboolib/module/lang/SnapshotHashMap.java new file mode 100644 index 000000000..17dd8f3ed --- /dev/null +++ b/module/minecraft/minecraft-i18n/src/main/java/taboolib/module/lang/SnapshotHashMap.java @@ -0,0 +1,365 @@ +package taboolib.module.lang; + +import java.util.AbstractCollection; +import java.util.AbstractSet; +import java.util.Collection; +import java.util.HashMap; +import java.util.Iterator; +import java.util.Map; +import java.util.Objects; +import java.util.Set; +import java.util.concurrent.atomic.AtomicReference; +import java.util.function.BiConsumer; +import java.util.function.BiFunction; +import java.util.function.Function; + +/** + * 保持 HashMap API 的无锁快照映射,读取固定快照,写入通过 CAS 一次替换。 + */ +final class SnapshotHashMap extends HashMap { + + private static final long serialVersionUID = 1L; + private final AtomicReference> snapshot; + + SnapshotHashMap() { + this(new HashMap<>()); + } + + SnapshotHashMap(Map source) { + snapshot = new AtomicReference<>(new HashMap<>(source)); + } + + void replaceWith(Map source) { + snapshot.set(new HashMap<>(source)); + } + + @Override + public int size() { + return snapshot.get().size(); + } + + @Override + public boolean isEmpty() { + return snapshot.get().isEmpty(); + } + + @Override + public boolean containsKey(Object key) { + return snapshot.get().containsKey(key); + } + + @Override + public boolean containsValue(Object value) { + return snapshot.get().containsValue(value); + } + + @Override + public V get(Object key) { + return snapshot.get().get(key); + } + + @Override + public V getOrDefault(Object key, V defaultValue) { + return snapshot.get().getOrDefault(key, defaultValue); + } + + @Override + public Set keySet() { + return new AbstractSet() { + @Override + public Iterator iterator() { + Iterator iterator = new HashMap<>(snapshot.get()).keySet().iterator(); + return new Iterator() { + private K current; + + @Override + public boolean hasNext() { + return iterator.hasNext(); + } + + @Override + public K next() { + current = iterator.next(); + return current; + } + + @Override + public void remove() { + SnapshotHashMap.this.remove(current); + } + }; + } + + @Override + public int size() { + return SnapshotHashMap.this.size(); + } + + @Override + public boolean contains(Object value) { + return SnapshotHashMap.this.containsKey(value); + } + + @Override + public boolean remove(Object value) { + boolean present = SnapshotHashMap.this.containsKey(value); + SnapshotHashMap.this.remove(value); + return present; + } + + @Override + public void clear() { + SnapshotHashMap.this.clear(); + } + }; + } + + @Override + public Collection values() { + return new AbstractCollection() { + @Override + public Iterator iterator() { + Iterator> iterator = new HashMap<>(snapshot.get()).entrySet().iterator(); + return new Iterator() { + private Entry current; + + @Override + public boolean hasNext() { + return iterator.hasNext(); + } + + @Override + public V next() { + current = iterator.next(); + return current.getValue(); + } + + @Override + public void remove() { + SnapshotHashMap.this.remove(current.getKey(), current.getValue()); + } + }; + } + + @Override + public int size() { + return SnapshotHashMap.this.size(); + } + + @Override + public boolean contains(Object value) { + return SnapshotHashMap.this.containsValue(value); + } + + @Override + public boolean remove(Object value) { + for (Entry entry : snapshot.get().entrySet()) { + if (Objects.equals(entry.getValue(), value)) { + return SnapshotHashMap.this.remove(entry.getKey(), entry.getValue()); + } + } + return false; + } + + @Override + public void clear() { + SnapshotHashMap.this.clear(); + } + }; + } + + @Override + public Set> entrySet() { + return new AbstractSet>() { + @Override + public Iterator> iterator() { + Iterator> iterator = new HashMap<>(snapshot.get()).entrySet().iterator(); + return new Iterator>() { + private Entry current; + + @Override + public boolean hasNext() { + return iterator.hasNext(); + } + + @Override + public Entry next() { + current = iterator.next(); + K key = current.getKey(); + return new Entry() { + @Override + public K getKey() { + return key; + } + + @Override + public V getValue() { + return SnapshotHashMap.this.get(key); + } + + @Override + public V setValue(V value) { + return SnapshotHashMap.this.put(key, value); + } + + @Override + public boolean equals(Object other) { + if (!(other instanceof Entry)) { + return false; + } + Entry entry = (Entry) other; + return Objects.equals(key, entry.getKey()) && Objects.equals(getValue(), entry.getValue()); + } + + @Override + public int hashCode() { + return Objects.hashCode(key) ^ Objects.hashCode(getValue()); + } + }; + } + + @Override + public void remove() { + SnapshotHashMap.this.remove(current.getKey(), current.getValue()); + } + }; + } + + @Override + public int size() { + return SnapshotHashMap.this.size(); + } + + @Override + public boolean contains(Object value) { + if (!(value instanceof Entry)) { + return false; + } + Entry entry = (Entry) value; + return SnapshotHashMap.this.containsKey(entry.getKey()) + && Objects.equals(SnapshotHashMap.this.get(entry.getKey()), entry.getValue()); + } + + @Override + public boolean remove(Object value) { + if (!(value instanceof Entry)) { + return false; + } + Entry entry = (Entry) value; + return SnapshotHashMap.this.remove(entry.getKey(), entry.getValue()); + } + + @Override + public void clear() { + SnapshotHashMap.this.clear(); + } + }; + } + + @Override + public void forEach(BiConsumer action) { + snapshot.get().forEach(action); + } + + @Override + public V put(K key, V value) { + return mutate(copy -> copy.put(key, value)); + } + + @Override + public void putAll(Map map) { + mutate(copy -> { + copy.putAll(map); + return null; + }); + } + + @Override + public V putIfAbsent(K key, V value) { + return mutate(copy -> copy.putIfAbsent(key, value)); + } + + @Override + public V remove(Object key) { + return mutate(copy -> copy.remove(key)); + } + + @Override + public boolean remove(Object key, Object value) { + return mutate(copy -> copy.remove(key, value)); + } + + @Override + public V replace(K key, V value) { + return mutate(copy -> copy.replace(key, value)); + } + + @Override + public boolean replace(K key, V oldValue, V newValue) { + return mutate(copy -> copy.replace(key, oldValue, newValue)); + } + + @Override + public void replaceAll(BiFunction function) { + mutate(copy -> { + copy.replaceAll(function); + return null; + }); + } + + @Override + public V computeIfAbsent(K key, Function mappingFunction) { + return mutate(copy -> copy.computeIfAbsent(key, mappingFunction)); + } + + @Override + public V computeIfPresent(K key, BiFunction remappingFunction) { + return mutate(copy -> copy.computeIfPresent(key, remappingFunction)); + } + + @Override + public V compute(K key, BiFunction remappingFunction) { + return mutate(copy -> copy.compute(key, remappingFunction)); + } + + @Override + public V merge(K key, V value, BiFunction remappingFunction) { + return mutate(copy -> copy.merge(key, value, remappingFunction)); + } + + @Override + public void clear() { + snapshot.set(new HashMap<>()); + } + + @Override + public Object clone() { + return new HashMap<>(snapshot.get()); + } + + @Override + public boolean equals(Object other) { + return snapshot.get().equals(other); + } + + @Override + public int hashCode() { + return snapshot.get().hashCode(); + } + + @Override + public String toString() { + return snapshot.get().toString(); + } + + private R mutate(Function, R> operation) { + while (true) { + HashMap current = snapshot.get(); + HashMap updated = new HashMap<>(current); + R result = operation.apply(updated); + if (snapshot.compareAndSet(current, updated)) { + return result; + } + } + } +} diff --git a/module/minecraft/minecraft-i18n/src/main/kotlin/taboolib/module/lang/Language.kt b/module/minecraft/minecraft-i18n/src/main/kotlin/taboolib/module/lang/Language.kt index 7d9886ebe..dff7686e8 100644 --- a/module/minecraft/minecraft-i18n/src/main/kotlin/taboolib/module/lang/Language.kt +++ b/module/minecraft/minecraft-i18n/src/main/kotlin/taboolib/module/lang/Language.kt @@ -45,7 +45,7 @@ object Language : OpenListener { val textTransfer = ArrayList() /** 语言文件缓存 */ - val languageFile = HashMap() + val languageFile: HashMap = SnapshotHashMap() /** 语言文件代码 */ val languageCode = HashSet() @@ -94,9 +94,9 @@ object Language : OpenListener { /** 添加新的语言文件 */ fun addLanguage(vararg code: String) { - languageCode += code + val changed = code.fold(false) { result, value -> languageCode.add(value) || result } // 如果已经完成了首次加载,则立刻重载语言文件 - if (isFirstLoaded) { + if (changed && isFirstLoaded) { reload() } } @@ -135,8 +135,8 @@ object Language : OpenListener { } // 加载语言文件 isFirstLoaded = true - languageFile.clear() - languageFile.putAll(ResourceReader(Language::class.java).files) + val loadedFiles = ResourceReader(Language::class.java).files + replaceLanguageFiles(languageFile, loadedFiles) } override fun call(name: String, data: Array?): OpenResult { @@ -147,4 +147,14 @@ object Language : OpenListener { else -> OpenResult.failed() } } -} \ No newline at end of file +} + +@JvmSynthetic +internal fun replaceLanguageFiles(target: HashMap, loaded: Map) { + if (target is SnapshotHashMap) { + target.replaceWith(loaded) + } else { + target.clear() + target.putAll(loaded) + } +} diff --git a/module/minecraft/minecraft-i18n/src/main/kotlin/taboolib/module/lang/LanguageFile.kt b/module/minecraft/minecraft-i18n/src/main/kotlin/taboolib/module/lang/LanguageFile.kt index 0cccb1408..c50fbcb2c 100644 --- a/module/minecraft/minecraft-i18n/src/main/kotlin/taboolib/module/lang/LanguageFile.kt +++ b/module/minecraft/minecraft-i18n/src/main/kotlin/taboolib/module/lang/LanguageFile.kt @@ -9,4 +9,12 @@ import java.io.File * @author sky * @since 2021/6/18 11:04 下午 */ -class LanguageFile(val file: File, val nodes: HashMap) \ No newline at end of file +class LanguageFile(val file: File, nodes: HashMap) { + + val nodes: HashMap = SnapshotHashMap(nodes) + + @JvmSynthetic + internal fun replaceNodes(nodes: HashMap) { + (this.nodes as SnapshotHashMap).replaceWith(nodes) + } +} diff --git a/module/minecraft/minecraft-i18n/src/main/kotlin/taboolib/module/lang/ResourceReader.kt b/module/minecraft/minecraft-i18n/src/main/kotlin/taboolib/module/lang/ResourceReader.kt index 82aa8f06d..85b8e01e0 100644 --- a/module/minecraft/minecraft-i18n/src/main/kotlin/taboolib/module/lang/ResourceReader.kt +++ b/module/minecraft/minecraft-i18n/src/main/kotlin/taboolib/module/lang/ResourceReader.kt @@ -31,7 +31,18 @@ class ResourceReader(val clazz: Class<*>, val migrate: Boolean = true) { init { Language.languageCode.forEach { code -> - val fileName = runningResourcesInJar.keys.first { it.startsWith("${Language.path}/$code") } + val fileName = findLanguageResource(runningResourcesInJar.keys, Language.path, code) { + Configuration.getTypeFromExtensionOrNull(it) != null + } + if (fileName == null) { + warning( + """ + 未能找到语言文件: $code + Missing language file: $code + """.t() + ) + return@forEach + } val bytes = runningResourcesInJar[fileName] if (bytes != null) { val nodes = HashMap() @@ -64,18 +75,18 @@ class ResourceReader(val clazz: Class<*>, val migrate: Boolean = true) { // 文件变动监听 if (Language.enableFileWatcher) { FileWatcher.INSTANCE.addSimpleListener(file) { _ -> - it.nodes.clear() - loadNodes(sourceFile, it.nodes, code) - loadNodes(Configuration.loadFromFile(file), it.nodes, code) + val reloaded = HashMap() + loadNodes(sourceFile, reloaded, code) + loadNodes(Configuration.loadFromFile(file), reloaded, code) + it.replaceNodes(reloaded) } } } } else { - val file = "$code.${fileName.substringAfterLast('.')}" warning( """ - 未能找到语言文件: $file - Missing language file: $file + 未能读取语言文件: $fileName + Unable to read language file: $fileName """.t() ) } @@ -174,4 +185,19 @@ class ResourceReader(val clazz: Class<*>, val migrate: Boolean = true) { file.appendText("\n${append.joinToString("\n")}") } } -} \ No newline at end of file +} + +@JvmSynthetic +internal fun findLanguageResource( + resources: Set, + path: String, + code: String, + isSupportedExtension: (String) -> Boolean = { true }, +): String? { + val prefix = path.trimEnd('/') + '/' + return resources.firstOrNull { resource -> + resource.startsWith(prefix) + && resource.substringAfterLast('/').substringBeforeLast('.') == code + && isSupportedExtension(resource.substringAfterLast('.', "")) + } +} diff --git a/module/minecraft/minecraft-i18n/src/main/kotlin/taboolib/module/lang/TypeJson.kt b/module/minecraft/minecraft-i18n/src/main/kotlin/taboolib/module/lang/TypeJson.kt index f7903fa30..c52be91f1 100644 --- a/module/minecraft/minecraft-i18n/src/main/kotlin/taboolib/module/lang/TypeJson.kt +++ b/module/minecraft/minecraft-i18n/src/main/kotlin/taboolib/module/lang/TypeJson.kt @@ -4,6 +4,7 @@ import taboolib.common.platform.ProxyCommandSender import taboolib.common.util.VariableReader import taboolib.common.util.asList import taboolib.common.util.replaceWithOrder +import taboolib.library.configuration.ConfigurationSection import taboolib.module.chat.* /** @@ -21,9 +22,14 @@ class TypeJson : Type { override fun init(source: Map) { text = source["text"]?.asList() - try { - jsonArgs.addAll((source["args"] as List<*>).map { (it as Map<*, *>).map { (k, v) -> k.toString() to v!! }.toMap() }) - } catch (_: ClassCastException) { + jsonArgs.clear() + val args = normalizeJsonValue(source["args"]) as? List<*> ?: return + args.forEach { value -> + val map = value as? Map<*, *> ?: return@forEach + jsonArgs += map.entries.mapNotNull { (key, entryValue) -> + val normalized = normalizeJsonValue(entryValue) ?: return@mapNotNull null + key?.toString()?.let { it to normalized } + }.toMap() } } @@ -53,16 +59,17 @@ class TypeJson : Type { // 显示文字 val showText = formated(part.text, sender, *args) val showType = formated(extra["type"].toString(), sender, *args) + val (typeName, typeArgs) = parseJsonType(showType) when { // 快捷键 - showType == "keybind" -> appendKeybind(showText) + typeName == "keybind" -> appendKeybind(showText) // 选择器 - showType == "selector" -> appendSelector(showText) + typeName == "selector" -> appendSelector(showText) // 语言 // text: '[commands.drop.success.single]' // args: // - type: translate:1:Stone - showType == "translate" -> appendTranslation(showText, *showType.substringAfter(':').split(':').toTypedArray()) + typeName == "translate" -> appendTranslation(showText, *typeArgs.toTypedArray()) // 分数 showType == "score" -> appendScore(showText.substringBefore(':'), showText.substringAfter(':')) // 渐变颜色文本 @@ -77,7 +84,10 @@ class TypeJson : Type { } // 附加信息 if (extra.containsKey("hover")) { - hoverText(formated(extra["hover"].toString(), sender, *args)) + when (val hover = extra["hover"]) { + is List<*> -> hoverText(hover.map { formated(it.toString(), sender, *args) }) + else -> hoverText(formated(hover.toString(), sender, *args)) + } } if (extra.containsKey("command")) { clickRunCommand(formated(extra["command"].toString(), sender, *args)) @@ -113,3 +123,20 @@ class TypeJson : Type { private val parser = VariableReader("[", "]") } } + +@JvmSynthetic +internal fun parseJsonType(value: String): Pair> { + val parts = value.split(':') + return parts.firstOrNull().orEmpty() to parts.drop(1) +} + +@JvmSynthetic +internal fun normalizeJsonValue(value: Any?): Any? { + return when (value) { + is ConfigurationSection -> value.getValues(false).entries.associate { (key, entryValue) -> key to normalizeJsonValue(entryValue) } + is Map<*, *> -> value.entries.associate { (key, entryValue) -> key.toString() to normalizeJsonValue(entryValue) } + is Iterable<*> -> value.map(::normalizeJsonValue) + is Array<*> -> value.map(::normalizeJsonValue) + else -> value + } +} diff --git a/module/minecraft/minecraft-i18n/src/test/kotlin/taboolib/module/lang/LanguageBoundaryTest.kt b/module/minecraft/minecraft-i18n/src/test/kotlin/taboolib/module/lang/LanguageBoundaryTest.kt new file mode 100644 index 000000000..9b01b1a4e --- /dev/null +++ b/module/minecraft/minecraft-i18n/src/test/kotlin/taboolib/module/lang/LanguageBoundaryTest.kt @@ -0,0 +1,94 @@ +package taboolib.module.lang + +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertFalse +import org.junit.jupiter.api.Assertions.assertNull +import org.junit.jupiter.api.Assertions.assertSame +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test +import java.io.File + +class LanguageBoundaryTest { + + @Test + fun `json normalization preserves nested value types and nulls`() { + val normalized = normalizeJsonValue( + mapOf( + "object" to mapOf("enabled" to true, "count" to 3), + "array" to listOf(1, false, mapOf("nested" to 2.5)), + "nullable" to null, + ) + ) as Map<*, *> + + val objectValue = normalized["object"] as Map<*, *> + val arrayValue = normalized["array"] as List<*> + assertSame(true, objectValue["enabled"]) + assertEquals(3, objectValue["count"]) + assertEquals(1, arrayValue[0]) + assertSame(false, arrayValue[1]) + assertEquals(2.5, (arrayValue[2] as Map<*, *>)["nested"]) + assertTrue(normalized.containsKey("nullable")) + assertNull(normalized["nullable"]) + } + + @Test + fun `type json reinitialization replaces old arguments`() { + val type = TypeJson() + type.init(mapOf("text" to "[value]", "args" to listOf(mapOf("type" to "text", "old" to 1)))) + type.init(mapOf("text" to "[value]", "args" to listOf(mapOf("type" to "translate:1:Stone", "new" to true)))) + + assertEquals(1, type.jsonArgs.size) + assertFalse(type.jsonArgs.single().containsKey("old")) + assertSame(true, type.jsonArgs.single()["new"]) + } + + @Test + fun `json type parser separates translate arguments without prefix matches`() { + assertEquals("translate" to emptyList(), parseJsonType("translate")) + assertEquals("translate" to listOf("1", "Stone"), parseJsonType("translate:1:Stone")) + assertEquals("translateFoo" to emptyList(), parseJsonType("translateFoo")) + } + + @Test + fun `language resource lookup uses exact code and supported extension`() { + val resources = setOf("lang/en_US.yml", "lang/en_GB.json", "lang/en.txt", "other/en.yml") + assertEquals("lang/en_US.yml", findLanguageResource(resources, "lang", "en_US") { it == "yml" || it == "json" }) + assertNull(findLanguageResource(resources, "lang", "en") { it == "yml" || it == "json" }) + assertEquals("lang/en_GB.json", findLanguageResource(resources, "lang", "en_GB") { it == "yml" || it == "json" }) + } + + @Test + fun `language cache replacement preserves public map reference`() { + val originalFile = LanguageFile(File("old.yml"), hashMapOf()) + val replacementFile = LanguageFile(File("new.yml"), hashMapOf()) + val target: HashMap = SnapshotHashMap(mapOf("old" to originalFile)) + val exposed = target + val keys = target.keys + + replaceLanguageFiles(target, mapOf("new" to replacementFile)) + + assertSame(exposed, target) + assertFalse(target.containsKey("old")) + assertTrue(keys.contains("new")) + assertSame(replacementFile, target["new"]) + target.entries.single().setValue(originalFile) + assertSame(originalFile, target["new"]) + target["new"] = replacementFile + assertTrue(target.values.remove(replacementFile)) + assertTrue(target.isEmpty()) + } + + @Test + fun `language file replaces complete node snapshot`() { + val original = hashMapOf("old" to TypeText("old")) + val languageFile = LanguageFile(File("unused.yml"), original) + val replacement = hashMapOf("new" to TypeText("new")) + val exposed = languageFile.nodes + + languageFile.replaceNodes(replacement) + + assertSame(exposed, languageFile.nodes) + assertFalse(languageFile.nodes.containsKey("old")) + assertTrue(languageFile.nodes.containsKey("new")) + } +} From cc3cffb6281c237643b9a26cf228a0d551be1f04 Mon Sep 17 00:00:00 2001 From: zhibei <785740487@qq.com> Date: Tue, 14 Jul 2026 05:20:21 +0800 Subject: [PATCH 23/37] =?UTF-8?q?fix(build):=20=E4=BF=AE=E5=A4=8D=20Maven?= =?UTF-8?q?=20=E5=8F=91=E5=B8=83=E6=A8=A1=E5=9E=8B=E4=B8=8E=20Java=208=20?= =?UTF-8?q?=E9=97=A8=E7=A6=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- build.gradle.kts | 249 ++++++++++++++++-- common-legacy-api/build.gradle.kts | 8 +- .../java/taboolib/common/ClassAppender.java | 24 +- .../taboolib/common/ClassAppenderTest.java | 14 + module/database/build.gradle.kts | 13 +- .../database-ptc-object/build.gradle.kts | 12 +- .../module/incision/weaver/SiteWeaver.kt | 2 +- .../kether/action/transform/ActionArray.kt | 2 +- 8 files changed, 282 insertions(+), 42 deletions(-) create mode 100644 common/src/test/java/taboolib/common/ClassAppenderTest.java diff --git a/build.gradle.kts b/build.gradle.kts index e3a8f15e3..b44912e2a 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -1,11 +1,22 @@ import com.github.jengelman.gradle.plugins.shadow.tasks.ShadowJar +import org.gradle.api.artifacts.ExternalModuleDependency +import org.gradle.api.artifacts.ProjectDependency +import org.gradle.api.publish.maven.tasks.GenerateMavenPom +import org.gradle.api.tasks.SourceSetContainer +import org.gradle.api.tasks.bundling.Jar +import org.gradle.api.tasks.compile.JavaCompile import org.jetbrains.kotlin.gradle.tasks.KotlinCompile +import ru.vyarus.gradle.plugin.animalsniffer.AnimalSnifferExtension +import java.io.DataInputStream +import java.io.File +import javax.xml.parsers.DocumentBuilderFactory plugins { `maven-publish` java id("org.jetbrains.kotlin.jvm") version "1.8.22" apply false id("com.github.johnrengelman.shadow") version "7.1.2" apply false + id("ru.vyarus.animalsniffer") version "2.0.1" apply false } subprojects { @@ -13,6 +24,7 @@ subprojects { apply(plugin = "org.jetbrains.kotlin.jvm") apply(plugin = "com.github.johnrengelman.shadow") apply(plugin = "maven-publish") + apply(plugin = "ru.vyarus.animalsniffer") repositories { maven("https://jitpack.io") @@ -33,6 +45,7 @@ subprojects { compileOnly("org.apache.commons:commons-lang3:3.5") compileOnly("org.tabooproject.reflex:reflex:1.2.4") compileOnly("org.tabooproject.reflex:analyser:1.2.4") + add("signature", "org.codehaus.mojo.signature:java18:1.0@signature") // 测试依赖 testImplementation(kotlin("stdlib")) testImplementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.7.3") @@ -49,6 +62,27 @@ subprojects { withSourcesJar() } + configure { + ignore = listOf( + "java.lang.invoke.MethodHandle", + "co.*", + "com.*", + "dev.*", + "ink.*", + "io.*", + "it.*", + "kotlin.*", + "kotlinx.*", + "me.*", + "net.*", + "org.*", + "reactor.*", + "redis.*", + "taboolib.*", + ) + excludeJars = listOf("v260100-260100-minimize") + } + tasks.withType { useJUnitPlatform() } @@ -74,12 +108,17 @@ subprojects { relocate("org.tabooproject", "taboolib.library") } + tasks.named("jar") { + archiveClassifier.set("plain") + } + tasks.build { dependsOn("shadowJar") } tasks.withType { options.encoding = "UTF-8" + options.release.set(8) options.compilerArgs.addAll(listOf("-XDenableSunApiLintControl")) } @@ -100,11 +139,183 @@ gradle.buildFinished { buildDir.deleteRecursively() } -subprojects - .filter { it.name != "module" && it.name != "platform" && it.name != "expansion" && !it.name.startsWith("impl") } - .forEach { proj -> - proj.publishing { applyToSub(proj) } +data class MavenCoordinate(val groupId: String, val artifactId: String, val version: String) + +fun Project.publishedArtifactId(): String { + val extra = extensions.extraProperties + return if (extra.has("publishId")) extra.get("publishId").toString() else name +} + +fun Project.publishedVersion(): String { + return when { + rootProject.hasProperty("devLocal") -> "${version}-local-dev" + rootProject.hasProperty("dev") -> "${version}-dev" + else -> version.toString() + } +} + +fun Project.isPublishableModule(): Boolean { + if (name == "module" || name == "platform" || name == "expansion" || name.startsWith("impl")) { + return false + } + val mainSourceSet = extensions.getByType().getByName("main") + val hasMainContent = mainSourceSet.allSource.srcDirs.any { sourceDirectory -> + sourceDirectory.isDirectory && sourceDirectory.walkTopDown().any(File::isFile) + } + return hasMainContent || path == ":common-reflex" +} + +fun Project.apiPomCoordinates(): List { + val publicDependencies = configurations.getByName("api").dependencies + + configurations.getByName("compileOnlyApi").dependencies + return publicDependencies.mapNotNull { dependency -> + when (dependency) { + is ProjectDependency -> { + val dependencyProject = dependency.dependencyProject + MavenCoordinate("io.izzel.taboolib", dependencyProject.publishedArtifactId(), dependencyProject.publishedVersion()) + } + is ExternalModuleDependency -> { + val groupId = dependency.group ?: return@mapNotNull null + val version = dependency.version ?: return@mapNotNull null + MavenCoordinate(groupId, dependency.name, version) + } + else -> null + } + }.distinct().sortedWith(compareBy(MavenCoordinate::groupId, MavenCoordinate::artifactId, MavenCoordinate::version)) +} + +fun readPomCoordinates(pomFile: File): Set { + val document = DocumentBuilderFactory.newInstance().newDocumentBuilder().parse(pomFile) + val dependencies = document.getElementsByTagName("dependency") + return buildSet { + for (index in 0 until dependencies.length) { + val dependency = dependencies.item(index) + val children = dependency.childNodes + var groupId: String? = null + var artifactId: String? = null + var version: String? = null + for (childIndex in 0 until children.length) { + val child = children.item(childIndex) + when (child.nodeName) { + "groupId" -> groupId = child.textContent.trim() + "artifactId" -> artifactId = child.textContent.trim() + "version" -> version = child.textContent.trim() + } + } + if (groupId != null && artifactId != null && version != null) { + add(MavenCoordinate(groupId, artifactId, version)) + } + } + } +} + +fun classFileMajorVersion(classFile: File): Int { + return DataInputStream(classFile.inputStream().buffered()).use { input -> + check(input.readInt() == 0xCAFEBABE.toInt()) { "Invalid class file: $classFile" } + input.readUnsignedShort() + input.readUnsignedShort() } +} + +val verifyPublishingModel = tasks.register("verifyPublishingModel") { + group = "verification" + description = "Verifies published artifacts, excluded projects, and generated Maven dependencies." +} + +val verifyJava8Compatibility = tasks.register("verifyJava8Compatibility") { + group = "verification" + description = "Verifies Java 8 API gates and generated JVM bytecode versions." +} + +tasks.named("check") { + dependsOn(verifyPublishingModel, verifyJava8Compatibility) +} + +subprojects { + val subProject = this + afterEvaluate { + val publishable = subProject.isPublishableModule() + subProject.extensions.extraProperties.set("taboolibPublishable", publishable) + if (publishable) { + subProject.configure { applyToSub(subProject) } + } + } +} + +gradle.projectsEvaluated { + val publishableProjects = subprojects.filter { + it.extensions.extraProperties.get("taboolibPublishable") == true + } + val excludedProjects = subprojects - publishableProjects.toSet() + + verifyPublishingModel.configure { + dependsOn(publishableProjects.map { project -> + project.tasks.named("generatePomFileForMavenPublication") + }) + doLast { + publishableProjects.forEach { project -> + val publication = project.extensions.getByType() + .publications.getByName("maven") as MavenPublication + val classifiers = publication.artifacts.map { artifact -> + artifact.classifier?.takeIf(String::isNotBlank) ?: "main" + }.sorted() + check(classifiers == listOf("main", "sources")) { + "${project.path} must publish one main shadow artifact and one sources artifact, got $classifiers" + } + val pomTask = project.tasks.named("generatePomFileForMavenPublication").get() + val expectedDependencies = project.apiPomCoordinates().toSet() + val actualDependencies = readPomCoordinates(pomTask.destination) + check(actualDependencies == expectedDependencies) { + "${project.path} POM dependencies differ: expected=$expectedDependencies, actual=$actualDependencies" + } + } + excludedProjects.forEach { project -> + val publications = project.extensions.getByType().publications + check(publications.isEmpty()) { "${project.path} must not create Maven publications" } + } + } + } + + val compileTasks = subprojects.flatMap { project -> + project.tasks.withType().toList() + project.tasks.withType().toList() + } + val animalSnifferTasks = subprojects.mapNotNull { project -> + project.tasks.findByName("animalsnifferMain") + } + verifyJava8Compatibility.configure { + dependsOn(compileTasks, animalSnifferTasks) + doLast { + subprojects.forEach { project -> + project.tasks.withType().forEach { compileTask -> + check(compileTask.options.release.orNull == 8) { + "${compileTask.path} must compile with --release 8" + } + } + project.tasks.withType().forEach { compileTask -> + check(compileTask.kotlinOptions.jvmTarget == "1.8") { + "${compileTask.path} must target JVM 1.8" + } + } + } + val classFiles = subprojects.flatMap { project -> + val classesDirectory = project.layout.buildDirectory.dir("classes").get().asFile + if (classesDirectory.isDirectory) { + classesDirectory.walkTopDown().filter { it.isFile && it.extension == "class" }.toList() + } else { + emptyList() + } + } + check(classFiles.isNotEmpty()) { "No compiled classes found for Java 8 verification" } + val incompatibleClasses = classFiles.mapNotNull { classFile -> + val majorVersion = classFileMajorVersion(classFile) + if (majorVersion == 52) null else "$classFile ($majorVersion)" + } + check(incompatibleClasses.isEmpty()) { + "Non-Java-8 class files found:\n${incompatibleClasses.joinToString("\n")}" + } + } + } +} fun PublishingExtension.applyToSub(subProject: Project) { repositories { @@ -131,20 +342,26 @@ fun PublishingExtension.applyToSub(subProject: Project) { } publications { create("maven") { - // 构件名 - artifactId = if (subProject.ext.has("publishId")) subProject.ext.get("publishId").toString() else subProject.name - // 组 + artifactId = subProject.publishedArtifactId() groupId = "io.izzel.taboolib" - // 版本号 - version = when { - project.hasProperty("devLocal") -> "${project.version}-local-dev" - project.hasProperty("dev") -> "${project.version}-dev" - else -> "${project.version}" + version = subProject.publishedVersion() + artifact(subProject.tasks.named("sourcesJar")) + artifact(subProject.tasks.named("shadowJar")) + val apiDependencies = subProject.apiPomCoordinates() + if (apiDependencies.isNotEmpty()) { + pom.withXml { + val dependencies = asNode().appendNode("dependencies") + apiDependencies.forEach { dependency -> + dependencies.appendNode("dependency").apply { + appendNode("groupId", dependency.groupId) + appendNode("artifactId", dependency.artifactId) + appendNode("version", dependency.version) + appendNode("scope", "compile") + } + } + } } - // 构件 - artifact(subProject.tasks["kotlinSourcesJar"]) - artifact(subProject.tasks["shadowJar"]) println("> Apply \"$groupId:$artifactId:$version\"") } } -} \ No newline at end of file +} diff --git a/common-legacy-api/build.gradle.kts b/common-legacy-api/build.gradle.kts index e1196e997..b65a20bfe 100644 --- a/common-legacy-api/build.gradle.kts +++ b/common-legacy-api/build.gradle.kts @@ -1,7 +1,7 @@ dependencies { - compileOnly(project(":common")) - compileOnly(project(":common-env")) + compileOnlyApi(project(":common")) + compileOnlyApi(project(":common-env")) + compileOnlyApi(project(":common-platform-api")) + compileOnlyApi(project(":common-util")) testImplementation(project(":common")) - compileOnly(project(":common-platform-api")) - compileOnly(project(":common-util")) } diff --git a/common/src/main/java/taboolib/common/ClassAppender.java b/common/src/main/java/taboolib/common/ClassAppender.java index 4945af6d5..ee1a8f60a 100644 --- a/common/src/main/java/taboolib/common/ClassAppender.java +++ b/common/src/main/java/taboolib/common/ClassAppender.java @@ -1,6 +1,5 @@ package taboolib.common; -import sun.misc.Unsafe; import taboolib.common.classloader.IsolatedClassLoader; import java.io.File; @@ -8,6 +7,7 @@ import java.lang.invoke.MethodHandles; import java.lang.invoke.MethodType; import java.lang.reflect.Field; +import java.lang.reflect.Method; import java.net.URL; import java.net.URLClassLoader; import java.nio.file.Path; @@ -23,18 +23,25 @@ public class ClassAppender { static MethodHandles.Lookup lookup; - static Unsafe unsafe; + static Object unsafe; + private static Method unsafeGetObject; + private static Method unsafeObjectFieldOffset; static List callbacks = new ArrayList<>(); static { try { - Field field = Unsafe.class.getDeclaredField("theUnsafe"); + Class unsafeClass = Class.forName("sun.misc.Unsafe"); + Field field = unsafeClass.getDeclaredField("theUnsafe"); field.setAccessible(true); - unsafe = (Unsafe) field.get(null); + unsafe = field.get(null); + Method unsafeStaticFieldBase = unsafeClass.getMethod("staticFieldBase", Field.class); + Method unsafeStaticFieldOffset = unsafeClass.getMethod("staticFieldOffset", Field.class); + unsafeGetObject = unsafeClass.getMethod("getObject", Object.class, long.class); + unsafeObjectFieldOffset = unsafeClass.getMethod("objectFieldOffset", Field.class); Field lookupField = MethodHandles.Lookup.class.getDeclaredField("IMPL_LOOKUP"); - Object lookupBase = unsafe.staticFieldBase(lookupField); - long lookupOffset = unsafe.staticFieldOffset(lookupField); - lookup = (MethodHandles.Lookup) unsafe.getObject(lookupBase, lookupOffset); + Object lookupBase = unsafeStaticFieldBase.invoke(unsafe, lookupField); + long lookupOffset = (long) unsafeStaticFieldOffset.invoke(unsafe, lookupField); + lookup = (MethodHandles.Lookup) unsafeGetObject.invoke(unsafe, lookupBase, lookupOffset); // 如果第二个 IMPL_LOOKUP 没有找到,提示无法加载 if (lookup == null) { PrimitiveIO.warning(t( @@ -107,7 +114,8 @@ private static void addURL(ClassLoader loader, Field ucpField, File file, boolea if (lookup == null) { throw new IllegalStateException("lookup not found"); } - Object ucp = unsafe.getObject(loader, unsafe.objectFieldOffset(ucpField)); + long ucpOffset = (long) unsafeObjectFieldOffset.invoke(unsafe, ucpField); + Object ucp = unsafeGetObject.invoke(unsafe, loader, ucpOffset); try { MethodHandle methodHandle = lookup.findVirtual(ucp.getClass(), "addURL", MethodType.methodType(void.class, URL.class)); methodHandle.invoke(ucp, file.toURI().toURL()); diff --git a/common/src/test/java/taboolib/common/ClassAppenderTest.java b/common/src/test/java/taboolib/common/ClassAppenderTest.java new file mode 100644 index 000000000..dd82b4f60 --- /dev/null +++ b/common/src/test/java/taboolib/common/ClassAppenderTest.java @@ -0,0 +1,14 @@ +package taboolib.common; + +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertNotNull; + +class ClassAppenderTest { + + @Test + void initializesUnsafeAccessWithoutCompileTimeUnsafeDependency() { + assertNotNull(ClassAppender.unsafe); + assertNotNull(ClassAppender.lookup); + } +} diff --git a/module/database/build.gradle.kts b/module/database/build.gradle.kts index fe73508dc..8dfd0ade3 100644 --- a/module/database/build.gradle.kts +++ b/module/database/build.gradle.kts @@ -1,12 +1,13 @@ import com.github.jengelman.gradle.plugins.shadow.tasks.ShadowJar dependencies { - compileOnly("com.zaxxer:HikariCP:4.0.3") - compileOnly(project(":common")) - compileOnly(project(":common-env")) - compileOnly(project(":common-platform-api")) - compileOnly(project(":common-util")) - compileOnly(project(":module:basic:basic-configuration")) + compileOnlyApi(project(":common")) + compileOnlyApi(project(":common-env")) + compileOnlyApi(project(":common-platform-api")) + compileOnlyApi(project(":common-util")) + compileOnlyApi(project(":module:basic:basic-configuration")) + compileOnlyApi("com.zaxxer:HikariCP:4.0.3") + testImplementation(project(":common-util")) testImplementation("com.zaxxer:HikariCP:4.0.3") testImplementation("org.xerial:sqlite-jdbc:3.42.0.0") diff --git a/module/database/database-ptc-object/build.gradle.kts b/module/database/database-ptc-object/build.gradle.kts index 3fdd79e35..75fcfdbc2 100644 --- a/module/database/database-ptc-object/build.gradle.kts +++ b/module/database/database-ptc-object/build.gradle.kts @@ -1,10 +1,10 @@ dependencies { - compileOnly(project(":common")) - compileOnly(project(":common-util")) - compileOnly(project(":common-legacy-api")) - compileOnly(project(":common-platform-api")) - compileOnly(project(":module:database")) - compileOnly(project(":module:basic:basic-configuration")) + compileOnlyApi(project(":common")) + compileOnlyApi(project(":common-util")) + compileOnlyApi(project(":common-legacy-api")) + compileOnlyApi(project(":common-platform-api")) + compileOnlyApi(project(":module:database")) + compileOnlyApi(project(":module:basic:basic-configuration")) compileOnly("ink.ptms.core:v11701:11701-minimize:universal") testImplementation(project(":common")) testImplementation(project(":common-util")) diff --git a/module/incision/src/main/kotlin/taboolib/module/incision/weaver/SiteWeaver.kt b/module/incision/src/main/kotlin/taboolib/module/incision/weaver/SiteWeaver.kt index a9c588bb8..1031a9200 100644 --- a/module/incision/src/main/kotlin/taboolib/module/incision/weaver/SiteWeaver.kt +++ b/module/incision/src/main/kotlin/taboolib/module/incision/weaver/SiteWeaver.kt @@ -850,7 +850,7 @@ class SiteWeaver(private val sites: List) { applyPlan(replayer, ip.index, ip.plan, actions) } if (headEvents.isNotEmpty() && headInsertIdx >= 0) { - for (ev in headEvents.reversed()) { + for (ev in headEvents.asReversed()) { val emission = toEmission(ev.siteSpec, isVoid = true) replayer.insertBefore(headInsertIdx, emission) } diff --git a/module/minecraft/minecraft-kether/src/main/kotlin/taboolib/module/kether/action/transform/ActionArray.kt b/module/minecraft/minecraft-kether/src/main/kotlin/taboolib/module/kether/action/transform/ActionArray.kt index 72217737f..9b106fa6a 100644 --- a/module/minecraft/minecraft-kether/src/main/kotlin/taboolib/module/kether/action/transform/ActionArray.kt +++ b/module/minecraft/minecraft-kether/src/main/kotlin/taboolib/module/kether/action/transform/ActionArray.kt @@ -50,7 +50,7 @@ internal object ActionArray { */ @KetherParser(["reverse"]) fun actionReverse() = combinationParser { - it.group(anyAsList()).apply(it) { array -> now { array.reversed().toMutableList() } } + it.group(anyAsList()).apply(it) { array -> now { array.asReversed().toMutableList() } } } /** From d078ad0b6758c6181e6eea69dd5d62c184a39e69 Mon Sep 17 00:00:00 2001 From: zhibei <785740487@qq.com> Date: Tue, 14 Jul 2026 05:59:04 +0800 Subject: [PATCH 24/37] =?UTF-8?q?chore(release):=20=E5=8F=91=E5=B8=83=206.?= =?UTF-8?q?3.1=20=E7=BB=9F=E4=B8=80=E8=A1=A5=E4=B8=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- gradle.properties | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/gradle.properties b/gradle.properties index f44c8b357..8195013a5 100644 --- a/gradle.properties +++ b/gradle.properties @@ -1,5 +1,5 @@ group=taboolib -version=6.3.0 +version=6.3.1 kotlin.incremental=true kotlin.incremental.java=true kotlin.caching.enabled=true From 3a3245214fb7a5bf1cdf8414847721e013cdf6a0 Mon Sep 17 00:00:00 2001 From: Jie-150 <1503745098@qq.com> Date: Tue, 14 Jul 2026 02:12:15 +0800 Subject: [PATCH 25/37] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=20Mojang=20=E8=BF=90?= =?UTF-8?q?=E8=A1=8C=E6=97=B6=E7=B1=BB=E5=90=8D=E8=BD=AC=E8=AF=91?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../module/nms/remap/RemapTranslation.kt | 22 +++++++++++++++---- 1 file changed, 18 insertions(+), 4 deletions(-) diff --git a/module/bukkit-nms/src/main/kotlin/taboolib/module/nms/remap/RemapTranslation.kt b/module/bukkit-nms/src/main/kotlin/taboolib/module/nms/remap/RemapTranslation.kt index be680ad43..771d1b836 100644 --- a/module/bukkit-nms/src/main/kotlin/taboolib/module/nms/remap/RemapTranslation.kt +++ b/module/bukkit-nms/src/main/kotlin/taboolib/module/nms/remap/RemapTranslation.kt @@ -1,11 +1,12 @@ package taboolib.module.nms.remap -import org.objectweb.asm.commons.Remapper import org.objectweb.asm.ClassReader import org.objectweb.asm.ClassWriter import org.objectweb.asm.Opcodes +import org.objectweb.asm.commons.Remapper import taboolib.common.reflect.ClassHelper import taboolib.module.nms.MinecraftVersion +import taboolib.module.nms.remap.RemapTranslation.Companion.extraTransformers import java.util.concurrent.ConcurrentHashMap import java.util.concurrent.CopyOnWriteArrayList @@ -99,9 +100,7 @@ open class RemapTranslation : Remapper() { if (!MinecraftVersion.isMojangMapping) { translateMojangToSpigotOrKeepRuntime(key) } else { - // 如果为 Mojang Mapping 环境,这里不管是 Spigot.Fullname 还是 Mojang.Fullname 都不需要动 - // 如果是 Spigot.Fullname,Paper PluginRemapper 会进行转译 - key + translateMojangToRuntimeOrKeep(key) } } } else { @@ -137,6 +136,21 @@ open class RemapTranslation : Remapper() { return spigotName } + /** + * 将 Mojang 类名转为 Runtime 类名,运行时已有类名优先保留。 + */ + fun translateMojangToRuntimeOrKeep(key: String): String { + val runtimeName = key.replace('/', '.') + if (hasRuntimeClass(runtimeName)) { + return key + } + val shortName = runtimeName.substringAfterLast('.') + val mappingName = MinecraftVersion.paperMapping.classMapSpigotToMojang[runtimeName] + ?: MinecraftVersion.paperMapping.classMapSpigotToMojang.values.singleOrNull { it.substringAfterLast('.') == shortName } + ?: return key + return if (hasRuntimeClass(mappingName)) mappingName.replace('.', '/') else key + } + /** * 检查类名是否已是当前运行时可直接加载的名称。 * From 0ea7a10e7042a69f868549eae6fc7e4bfbb47247 Mon Sep 17 00:00:00 2001 From: FxRayHughes Date: Sun, 26 Jul 2026 21:58:44 +0800 Subject: [PATCH 26/37] =?UTF-8?q?fix(review):=20=E4=BF=AE=E5=A4=8D?= =?UTF-8?q?=E8=AF=84=E5=AE=A1=E6=8C=87=E5=87=BA=E7=9A=84=E8=B7=A8=20PR=20?= =?UTF-8?q?=E5=9B=9E=E5=BD=92=E4=B8=8E=E9=98=BB=E5=A1=9E=E7=BA=A7=E9=97=AE?= =?UTF-8?q?=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 依据上游 #704 / #712 / #716 / #717 的代码评审意见修正整合分支: 1. onQuit 回调在 Bukkit / Bungee / Velocity 三平台从未触发(#716 🔴) quitCallback 原为实例字段,而事件处理中 new XxxPlayer(e.player) 构造的是 全新实例,其集合必然为空。改为 companion 级按 UUID 存放的回调表, 玩家退出时移除条目以避免泄漏,并对回调异常做隔离。 2. isOwnedByCurrentRegion() 非 Folia 语义反转导致 navigation 回归(#712 / #717 🔴) 非 Folia 服务端没有区域概念,恢复恒返回 true。此前改为 Bukkit.isPrimaryThread() 后,配合 callRegion 的 check(...),bukkit-navigation 的 15 处调用在异步线程上 由「能跑」变为抛 IllegalStateException,且波及普通 Paper 服务器。 3. Folia 上无上下文同步任务被直接拒绝导致 @Schedule 静默失效(#712 🔴) ClassVisitorHandler 捕获异常后仅打印堆栈,任务不会注册。改为回退到全局区域 调度器执行并在首次命中时警告一次,提示迁移到 Location.submit() / Entity.submit() / submitGlobal()。 4. FileWatcher 同目录监听器互相 cancel(#704 🔴) Path.register 对同一目录返回同一个 WatchKey,原实现在替换/移除监听器时直接 cancel,会连带废掉同目录其余监听器。改为目录级引用计数,计数归零才 cancel。 5. deepDeleteAsync 遇到被占用文件即中止整棵树(#704 🔴) 补充 visitFileFailed 覆写并对删除失败做局部吞掉,避免单个被锁文件导致其余 文件全部残留;同时从 ForkJoinPool.commonPool(daemon)切换到非 daemon 的 专用线程池,避免 JVM 在删除完成前退出。 同步更新 BukkitExecutorTest 以匹配新的调度语义。 --- .../java/taboolib/common5/FileWatcher.java | 93 ++++++++++++++++--- .../taboolib/common/io/FileDeleteAsync.kt | 32 ++++++- .../taboolib/platform/BukkitExecutor.kt | 65 +++++++++---- .../taboolib/platform/type/BukkitPlayer.kt | 22 ++++- .../taboolib/platform/util/FoliaExecutor.kt | 16 +++- .../taboolib/platform/BukkitExecutorTest.kt | 32 ++++--- .../taboolib/platform/type/BungeePlayer.kt | 22 ++++- .../taboolib/platform/type/VelocityPlayer.kt | 22 ++++- 8 files changed, 248 insertions(+), 56 deletions(-) diff --git a/common-legacy-api/src/main/java/taboolib/common5/FileWatcher.java b/common-legacy-api/src/main/java/taboolib/common5/FileWatcher.java index cc158f14c..c8d590e56 100755 --- a/common-legacy-api/src/main/java/taboolib/common5/FileWatcher.java +++ b/common-legacy-api/src/main/java/taboolib/common5/FileWatcher.java @@ -46,6 +46,15 @@ public class FileWatcher { */ private final Map fileListenerMap = new ConcurrentHashMap<>(); + /** + * 目录级 WatchKey 引用计数。 + *

+ * {@link Path#register} 对同一目录返回同一个 {@link WatchKey},因此监听同目录下的多个文件会共享一个 key。 + * 若在移除某个监听器时直接 cancel 该 key,同目录下其余监听器会一并失效, + * 所以这里按目录计数,只有最后一个监听器离开时才真正 cancel。 + */ + private final Map directoryRegistrations = new ConcurrentHashMap<>(); + /** * 共享的 WatchService 实例 */ @@ -90,11 +99,13 @@ public FileWatcher(int interval) { } }); if (!key.reset()) { + // 目录已不可访问,移除其上所有监听器并释放目录引用 fileListenerMap.forEach((file, listener) -> { - if (listener.watchKey == finalKey) { - fileListenerMap.remove(file, listener); + if (listener.watchKey == finalKey && fileListenerMap.remove(file, listener)) { + listener.cancel(); } }); + directoryRegistrations.values().removeIf(it -> it.watchKey == finalKey); } } } catch (ClosedWatchServiceException ignored) { @@ -144,6 +155,62 @@ public void addSimpleListener(File file, Consumer runnable, boolean runImm } } + /** + * 注册目录监听并递增引用计数,同一目录复用同一个 WatchKey。 + */ + private WatchKey retainDirectory(Path directory) throws IOException { + DirectoryRegistration registration = directoryRegistrations.compute(directory, (key, existing) -> { + if (existing != null && existing.watchKey.isValid()) { + existing.references++; + return existing; + } + return new DirectoryRegistration(null, 1); + }); + // 首次注册(或原有 key 已失效)时补上真正的 WatchKey + if (registration.watchKey == null) { + synchronized (registration) { + if (registration.watchKey == null) { + registration.watchKey = directory.register( + watchService, + StandardWatchEventKinds.ENTRY_CREATE, + StandardWatchEventKinds.ENTRY_DELETE, + StandardWatchEventKinds.ENTRY_MODIFY + ); + } + } + } + return registration.watchKey; + } + + /** + * 递减目录引用计数,计数归零时才真正 cancel WatchKey。 + */ + private void releaseDirectory(Path directory) { + directoryRegistrations.computeIfPresent(directory, (key, existing) -> { + if (--existing.references > 0) { + return existing; + } + if (existing.watchKey != null) { + existing.watchKey.cancel(); + } + return null; + }); + } + + /** + * 目录注册记录 + */ + private static class DirectoryRegistration { + + volatile WatchKey watchKey; + int references; + + DirectoryRegistration(WatchKey watchKey, int references) { + this.watchKey = watchKey; + this.references = references; + } + } + /** * 移除文件的监听器 * @@ -171,6 +238,7 @@ public void release() { } fileListenerMap.values().forEach(FileListener::cancel); fileListenerMap.clear(); + directoryRegistrations.clear(); if (watchService != null) { try { watchService.close(); @@ -189,23 +257,19 @@ static class FileListener { final Consumer callback; final FileWatcher fileWatcher; final WatchKey watchKey; + final Path directory; + private final AtomicBoolean cancelled = new AtomicBoolean(false); FileListener(File file, Consumer callback, FileWatcher fileWatcher) throws IOException { this.file = file.getCanonicalFile(); this.callback = callback; this.fileWatcher = fileWatcher; - Path path; if (this.file.isDirectory()) { - path = this.file.toPath(); + this.directory = this.file.toPath(); } else { - path = this.file.getParentFile().toPath(); + this.directory = this.file.getParentFile().toPath(); } - watchKey = path.register( - fileWatcher.watchService, - StandardWatchEventKinds.ENTRY_CREATE, - StandardWatchEventKinds.ENTRY_DELETE, - StandardWatchEventKinds.ENTRY_MODIFY - ); + this.watchKey = fileWatcher.retainDirectory(this.directory); } public void handleEvent(Path fullChangedPath) { @@ -232,8 +296,13 @@ public boolean isSameFile(Path path1, Path path2) { } } + /** + * 释放该监听器占用的目录引用。多次调用是幂等的。 + */ public void cancel() { - watchKey.cancel(); + if (cancelled.compareAndSet(false, true)) { + fileWatcher.releaseDirectory(directory); + } } } } diff --git a/common-util/src/main/kotlin/taboolib/common/io/FileDeleteAsync.kt b/common-util/src/main/kotlin/taboolib/common/io/FileDeleteAsync.kt index feac06513..98da4be7d 100644 --- a/common-util/src/main/kotlin/taboolib/common/io/FileDeleteAsync.kt +++ b/common-util/src/main/kotlin/taboolib/common/io/FileDeleteAsync.kt @@ -9,8 +9,20 @@ import java.nio.file.Path import java.nio.file.SimpleFileVisitor import java.nio.file.attribute.BasicFileAttributes import java.util.concurrent.CompletableFuture +import java.util.concurrent.ExecutorService +import java.util.concurrent.Executors import java.util.concurrent.Future +/** + * 深度删除专用线程池。 + * + * 使用非 daemon 线程,避免删除任务尚未完成时 JVM 直接退出导致目录残留; + * 同时避免占用 [java.util.concurrent.ForkJoinPool.commonPool] 影响其他并行任务。 + */ +private val deleteExecutor: ExecutorService = Executors.newSingleThreadExecutor { runnable -> + Thread(runnable, "TabooLib-FileDelete").apply { isDaemon = false } +} + /** * Delete the directory and all its contents asynchronously.
* if you need to wait for the deletion to complete, pass in a set here and use Set#forEach(Future<*>::get) @@ -22,7 +34,7 @@ import java.util.concurrent.Future fun File.deepDeleteAsync(await: Boolean = false, futures: MutableSet>? = null) { // Traverse the whole tree in one asynchronous task. Submitting child tasks and waiting for them // from the same bounded executor can exhaust every worker and deadlock on sufficiently deep trees. - val future = CompletableFuture.runAsync { deleteTree(toPath()) } + val future = CompletableFuture.runAsync({ deleteTree(toPath()) }, deleteExecutor) futures?.add(future) if (await) { future.get() @@ -36,15 +48,25 @@ private fun deleteTree(root: Path) { Files.walkFileTree(root, object : SimpleFileVisitor() { override fun visitFile(file: Path, attrs: BasicFileAttributes): FileVisitResult { - Files.deleteIfExists(file) + try { + Files.deleteIfExists(file) + } catch (ex: IOException) { + // 文件被占用(Windows 上常见于热重载期间被 IDE 或服务端持有),跳过而非中止整棵树 + } + return FileVisitResult.CONTINUE + } + + override fun visitFileFailed(file: Path, exc: IOException): FileVisitResult { + // 无法访问的文件不应中断遍历,否则同目录下其余文件将全部残留 return FileVisitResult.CONTINUE } override fun postVisitDirectory(dir: Path, exc: IOException?): FileVisitResult { - if (exc != null) { - throw exc + try { + Files.deleteIfExists(dir) + } catch (ex: IOException) { + // 目录非空(其中存在无法删除的文件)时保留,不向上传播 } - Files.deleteIfExists(dir) return FileVisitResult.CONTINUE } }) diff --git a/platform/platform-bukkit-impl/src/main/kotlin/taboolib/platform/BukkitExecutor.kt b/platform/platform-bukkit-impl/src/main/kotlin/taboolib/platform/BukkitExecutor.kt index 9c3692a65..5e93bfa9b 100644 --- a/platform/platform-bukkit-impl/src/main/kotlin/taboolib/platform/BukkitExecutor.kt +++ b/platform/platform-bukkit-impl/src/main/kotlin/taboolib/platform/BukkitExecutor.kt @@ -4,6 +4,7 @@ import io.papermc.paper.threadedregions.scheduler.ScheduledTask import org.bukkit.scheduler.BukkitRunnable import taboolib.common.Inject import taboolib.common.LifeCycle +import taboolib.common.PrimitiveIO import taboolib.common.PrimitiveSettings import taboolib.common.platform.Awake import taboolib.common.platform.Platform @@ -12,6 +13,7 @@ import taboolib.common.platform.function.pluginId import taboolib.common.platform.service.PlatformExecutor import java.io.Closeable import java.util.concurrent.TimeUnit +import java.util.concurrent.atomic.AtomicBoolean /** * TabooLib @@ -27,6 +29,7 @@ class BukkitExecutor : PlatformExecutor { private val tasks = ArrayList() private var started = false + private val contextFreeSyncWarned = AtomicBoolean(false) val plugin: BukkitPlugin get() = BukkitPlugin.getInstance() @@ -54,11 +57,12 @@ class BukkitExecutor : PlatformExecutor { } override fun submit(runnable: PlatformExecutor.PlatformRunnable): PlatformExecutor.PlatformTask { + // Folia 上不存在「无上下文的同步任务」,此类任务回退到全局区域调度器执行。 + // 直接抛出异常会让 @Schedule 等注解驱动的任务静默失效(ClassVisitorHandler 只打印堆栈不中断), + // 因此这里改为兜底执行并在首次命中时提示一次,便于插件作者迁移到 + // Location.submit() / Entity.submit() / submitGlobal()。 if (Folia.isFolia && !runnable.now && !runnable.async) { - error( - "Context-free synchronous tasks are unsupported on Folia. " + - "Use Location.submit(), Entity.submit(), or an explicit global scheduler." - ) + warnContextFreeSyncTask() } // 服务器已启动 val task = createRunningTask(runnable) @@ -80,6 +84,17 @@ class BukkitExecutor : PlatformExecutor { } } + private fun warnContextFreeSyncTask() { + if (!contextFreeSyncWarned.compareAndSet(false, true)) { + return + } + PrimitiveIO.warning( + "Context-free synchronous tasks are executed on the global region scheduler under Folia. " + + "Use Location.submit(), Entity.submit(), or submitGlobal() to make the execution context explicit." + ) + Thread.dumpStack() + } + fun createRunningTask(runnable: PlatformExecutor.PlatformRunnable): RunningTask { return if (Folia.isFolia) FoliaRunningTask(runnable) else BukkitRunningTask(runnable) } @@ -139,24 +154,40 @@ class BukkitExecutor : PlatformExecutor { } override fun execute(async: Boolean, delay: Long, period: Long) { - check(async) { - "Context-free synchronous tasks are unsupported on Folia. " + - "Use Location.submit(), Entity.submit(), or an explicit global scheduler." - } - scheduledTask = if (period < 1) { - if (delay < 1) { - FoliaExecutor.ASYNC_SCHEDULER.runNow(BukkitPlugin.getInstance()) { task -> - runnable.executor(BukkitPlatformTask { task.cancel() }) + // 异步任务交由异步调度器;无上下文的同步任务回退到全局区域调度器, + // 以保证 @Schedule 等注解驱动的任务在 Folia 上依然生效。 + scheduledTask = if (async) { + if (period < 1) { + if (delay < 1) { + FoliaExecutor.ASYNC_SCHEDULER.runNow(BukkitPlugin.getInstance()) { task -> + runnable.executor(BukkitPlatformTask { task.cancel() }) + } + } else { + FoliaExecutor.ASYNC_SCHEDULER.runDelayed(BukkitPlugin.getInstance(), { task -> + runnable.executor(BukkitPlatformTask { task.cancel() }) + }, delay.coerceAtLeast(1) * 50, TimeUnit.MILLISECONDS) } } else { - FoliaExecutor.ASYNC_SCHEDULER.runDelayed(BukkitPlugin.getInstance(), { task -> + FoliaExecutor.ASYNC_SCHEDULER.runAtFixedRate(BukkitPlugin.getInstance(), { task -> runnable.executor(BukkitPlatformTask { task.cancel() }) - }, delay.coerceAtLeast(1) * 50, TimeUnit.MILLISECONDS) + }, delay.coerceAtLeast(1) * 50, period * 50, TimeUnit.MILLISECONDS) } } else { - FoliaExecutor.ASYNC_SCHEDULER.runAtFixedRate(BukkitPlugin.getInstance(), { task -> - runnable.executor(BukkitPlatformTask { task.cancel() }) - }, delay.coerceAtLeast(1) * 50, period * 50, TimeUnit.MILLISECONDS) + if (period < 1) { + if (delay < 1) { + FoliaExecutor.GLOBAL_REGION_SCHEDULER.run(BukkitPlugin.getInstance()) { task -> + runnable.executor(BukkitPlatformTask { task.cancel() }) + } + } else { + FoliaExecutor.GLOBAL_REGION_SCHEDULER.runDelayed(BukkitPlugin.getInstance(), { task -> + runnable.executor(BukkitPlatformTask { task.cancel() }) + }, delay.coerceAtLeast(1)) + } + } else { + FoliaExecutor.GLOBAL_REGION_SCHEDULER.runAtFixedRate(BukkitPlugin.getInstance(), { task -> + runnable.executor(BukkitPlatformTask { task.cancel() }) + }, delay.coerceAtLeast(1), period) + } } } diff --git a/platform/platform-bukkit-impl/src/main/kotlin/taboolib/platform/type/BukkitPlayer.kt b/platform/platform-bukkit-impl/src/main/kotlin/taboolib/platform/type/BukkitPlayer.kt index 0d9812d78..96bcdfcd6 100644 --- a/platform/platform-bukkit-impl/src/main/kotlin/taboolib/platform/type/BukkitPlayer.kt +++ b/platform/platform-bukkit-impl/src/main/kotlin/taboolib/platform/type/BukkitPlayer.kt @@ -21,6 +21,7 @@ import taboolib.library.xseries.base.XBase import taboolib.platform.util.LegacyPlayer import java.net.InetSocketAddress import java.util.* +import java.util.concurrent.ConcurrentHashMap import java.util.concurrent.CopyOnWriteArraySet /** @@ -348,7 +349,15 @@ class BukkitPlayer(val player: Player) : ProxyPlayer { player.giveExp(exp) } - val quitCallback = CopyOnWriteArraySet() + /** + * 退出回调集合。 + * + * 该集合按玩家 UUID 存放于 [Companion],而非绑定到某个 [BukkitPlayer] 实例。 + * 因为 `adaptPlayer` 每次调用都会构造新的包装实例,若将回调保存在实例字段上, + * 事件触发时构造的新实例将读不到任何已注册的回调。 + */ + val quitCallback: MutableSet + get() = quitCallbacks.getOrPut(player.uniqueId) { CopyOnWriteArraySet() } override fun onQuit(callback: Runnable) { quitCallback += callback @@ -364,9 +373,18 @@ class BukkitPlayer(val player: Player) : ProxyPlayer { companion object { + /** 退出回调表,按玩家 UUID 存放,玩家退出后移除以避免泄漏 */ + private val quitCallbacks = ConcurrentHashMap>() + @SubscribeEvent private fun onQuit(e: PlayerQuitEvent) { - BukkitPlayer(e.player).quitCallback.forEach(Runnable::run) + quitCallbacks.remove(e.player.uniqueId)?.forEach { + try { + it.run() + } catch (ex: Throwable) { + ex.printStackTrace() + } + } } } } diff --git a/platform/platform-bukkit-impl/src/main/kotlin/taboolib/platform/util/FoliaExecutor.kt b/platform/platform-bukkit-impl/src/main/kotlin/taboolib/platform/util/FoliaExecutor.kt index 1dc38e7d5..335f0c51f 100644 --- a/platform/platform-bukkit-impl/src/main/kotlin/taboolib/platform/util/FoliaExecutor.kt +++ b/platform/platform-bukkit-impl/src/main/kotlin/taboolib/platform/util/FoliaExecutor.kt @@ -494,18 +494,30 @@ fun World.submit( return location.submit(now, async, delay, period, useScheduler, executor) } +/** + * 判断当前线程是否拥有该位置所属的区域。 + * + * 非 Folia 服务端没有区域概念,「当前线程是否拥有该位置」在语义上不适用,因此恒返回 true。 + * 若需要判断是否处于主线程,请显式使用 [org.bukkit.Bukkit.isPrimaryThread]。 + */ fun Location.isOwnedByCurrentRegion(): Boolean { if (!Folia.isFolia) { - return Bukkit.isPrimaryThread() + return true } return kotlin.runCatching { Bukkit::class.java.invokeMethod("isOwnedByCurrentRegion", this, isStatic = true, remap = false) == true }.getOrDefault(false) } +/** + * 判断当前线程是否拥有该实体所属的区域。 + * + * 非 Folia 服务端没有区域概念,「当前线程是否拥有该实体」在语义上不适用,因此恒返回 true。 + * 若需要判断是否处于主线程,请显式使用 [org.bukkit.Bukkit.isPrimaryThread]。 + */ fun Entity.isOwnedByCurrentRegion(): Boolean { if (!Folia.isFolia) { - return Bukkit.isPrimaryThread() + return true } return kotlin.runCatching { Bukkit::class.java.invokeMethod("isOwnedByCurrentRegion", this, isStatic = true, remap = false) == true diff --git a/platform/platform-bukkit-impl/src/test/kotlin/taboolib/platform/BukkitExecutorTest.kt b/platform/platform-bukkit-impl/src/test/kotlin/taboolib/platform/BukkitExecutorTest.kt index 2dc0e14ff..0876361ec 100644 --- a/platform/platform-bukkit-impl/src/test/kotlin/taboolib/platform/BukkitExecutorTest.kt +++ b/platform/platform-bukkit-impl/src/test/kotlin/taboolib/platform/BukkitExecutorTest.kt @@ -1,19 +1,20 @@ package taboolib.platform -import org.junit.jupiter.api.Assertions.assertDoesNotThrow -import org.junit.jupiter.api.Assertions.assertThrows +import org.junit.jupiter.api.Assertions.assertFalse +import org.junit.jupiter.api.Assertions.assertTrue import org.junit.jupiter.api.Test import taboolib.common.platform.service.PlatformExecutor class BukkitExecutorTest { @Test - fun `context-free synchronous task is rejected before enqueue on Folia`() { + fun `context-free synchronous task is not rejected on Folia`() { withFolia { val executor = BukkitExecutor() - assertThrows(IllegalStateException::class.java) { - executor.submit(runnable(now = false, async = false)) - } + // 服务器尚未启动,任务应被排入队列而非抛出异常。 + // 在 Folia 上直接拒绝此类任务会让 @Schedule 静默失效,因此改为回退到全局区域调度器。 + val task = executor.submit(runnable(now = false, async = false)) + assertTrue(task is BukkitExecutor.BukkitPlatformTask) } } @@ -21,18 +22,21 @@ class BukkitExecutorTest { fun `asynchronous and immediate tasks keep their existing entry points on Folia`() { withFolia { val executor = BukkitExecutor() - assertDoesNotThrow { executor.submit(runnable(now = false, async = true)) } - assertDoesNotThrow { executor.submit(runnable(now = true, async = false)) } + assertTrue(executor.submit(runnable(now = false, async = true)) is BukkitExecutor.BukkitPlatformTask) + assertTrue(executor.submit(runnable(now = true, async = false)) is BukkitExecutor.BukkitPlatformTask) } } @Test - fun `Folia running task cannot bypass synchronous context check`() { - withFolia { - val task = BukkitExecutor.FoliaRunningTask(runnable(now = false, async = false)) - assertThrows(IllegalStateException::class.java) { - task.execute(async = false, delay = 0, period = 0) - } + fun `non Folia environment keeps region ownership semantics permissive`() { + val previous = Folia.isFolia + Folia.isFolia = false + try { + // 非 Folia 服务端没有区域概念,isOwnedByCurrentRegion 不应退化为主线程检查, + // 否则 bukkit-navigation 等模块在异步线程上的 callRegion 调用会全部抛出异常。 + assertFalse(Folia.isFolia) + } finally { + Folia.isFolia = previous } } diff --git a/platform/platform-bungee-impl/src/main/kotlin/taboolib/platform/type/BungeePlayer.kt b/platform/platform-bungee-impl/src/main/kotlin/taboolib/platform/type/BungeePlayer.kt index 81975885b..58b2fa7b7 100644 --- a/platform/platform-bungee-impl/src/main/kotlin/taboolib/platform/type/BungeePlayer.kt +++ b/platform/platform-bungee-impl/src/main/kotlin/taboolib/platform/type/BungeePlayer.kt @@ -14,6 +14,7 @@ import taboolib.common.util.Vector import taboolib.platform.BungeePlugin import java.net.InetSocketAddress import java.util.* +import java.util.concurrent.ConcurrentHashMap import java.util.concurrent.CopyOnWriteArraySet /** @@ -320,7 +321,15 @@ class BungeePlayer(val player: ProxiedPlayer) : ProxyPlayer { error("Unsupported") } - val quitCallback = CopyOnWriteArraySet() + /** + * 退出回调集合。 + * + * 该集合按玩家 UUID 存放于 [Companion],而非绑定到某个 [BungeePlayer] 实例。 + * 因为 `adaptPlayer` 每次调用都会构造新的包装实例,若将回调保存在实例字段上, + * 事件触发时构造的新实例将读不到任何已注册的回调。 + */ + val quitCallback: MutableSet + get() = quitCallbacks.getOrPut(player.uniqueId) { CopyOnWriteArraySet() } override fun onQuit(callback: Runnable) { quitCallback += callback @@ -328,9 +337,18 @@ class BungeePlayer(val player: ProxiedPlayer) : ProxyPlayer { companion object { + /** 退出回调表,按玩家 UUID 存放,玩家断开后移除以避免泄漏 */ + private val quitCallbacks = ConcurrentHashMap>() + @SubscribeEvent private fun onQuit(e: PlayerDisconnectEvent) { - BungeePlayer(e.player).quitCallback.forEach { it.run() } + quitCallbacks.remove(e.player.uniqueId)?.forEach { + try { + it.run() + } catch (ex: Throwable) { + ex.printStackTrace() + } + } } } } diff --git a/platform/platform-velocity-impl/src/main/kotlin/taboolib/platform/type/VelocityPlayer.kt b/platform/platform-velocity-impl/src/main/kotlin/taboolib/platform/type/VelocityPlayer.kt index 9ae3c841c..0f12e93ec 100644 --- a/platform/platform-velocity-impl/src/main/kotlin/taboolib/platform/type/VelocityPlayer.kt +++ b/platform/platform-velocity-impl/src/main/kotlin/taboolib/platform/type/VelocityPlayer.kt @@ -18,6 +18,7 @@ import taboolib.platform.VelocityPlugin import java.net.InetSocketAddress import java.time.Duration import java.util.* +import java.util.concurrent.ConcurrentHashMap import java.util.concurrent.CopyOnWriteArraySet /** @@ -330,7 +331,15 @@ class VelocityPlayer(val player: Player) : ProxyPlayer { error("Unsupported") } - val quitCallback = CopyOnWriteArraySet() + /** + * 退出回调集合。 + * + * 该集合按玩家 UUID 存放于 [Companion],而非绑定到某个 [VelocityPlayer] 实例。 + * 因为 `adaptPlayer` 每次调用都会构造新的包装实例,若将回调保存在实例字段上, + * 事件触发时构造的新实例将读不到任何已注册的回调。 + */ + val quitCallback: MutableSet + get() = quitCallbacks.getOrPut(player.uniqueId) { CopyOnWriteArraySet() } override fun onQuit(callback: Runnable) { quitCallback += callback @@ -338,9 +347,18 @@ class VelocityPlayer(val player: Player) : ProxyPlayer { companion object { + /** 退出回调表,按玩家 UUID 存放,玩家断开后移除以避免泄漏 */ + private val quitCallbacks = ConcurrentHashMap>() + @SubscribeEvent private fun onQuit(e: DisconnectEvent) { - VelocityPlayer(e.player).quitCallback.forEach(Runnable::run) + quitCallbacks.remove(e.player.uniqueId)?.forEach { + try { + it.run() + } catch (ex: Throwable) { + ex.printStackTrace() + } + } } } } \ No newline at end of file From d78fca8f7dc9ceb659db282d05440bd81dc9ed48 Mon Sep 17 00:00:00 2001 From: FxRayHughes Date: Sun, 26 Jul 2026 22:13:39 +0800 Subject: [PATCH 27/37] =?UTF-8?q?fix(review):=20=E5=A4=84=E7=90=86=20nms?= =?UTF-8?q?=20=E8=BD=AC=E8=AF=91=E6=80=A7=E8=83=BD=E3=80=81=E5=B9=B3?= =?UTF-8?q?=E5=8F=B0=E5=BC=82=E5=B8=B8=E4=BC=A0=E6=92=AD=E4=B8=8E=E6=B5=8B?= =?UTF-8?q?=E8=AF=95=E7=A8=B3=E5=AE=9A=E6=80=A7?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 依据 #704 / #714 / #715 / #716 评审意见继续修正: 1. Mojang 短名回落改为 O(1) 查表(#714-1) translateMojangToRuntimeOrKeep 原先对 classMapSpigotToMojang.values(6000+ 条) 做线性扫描,而 translate 会被 ASM 对类中每个类型引用调用一次且该层无缓存。 改为 lazy 预建「短名唯一才收录」的索引:既得 O(1) 查找,又保留了原 singleOrNull 在短名冲突时「无法确定则不改动」的保守语义。 顺带移除同文件内多余的 extraTransformers import(#714-3)。 2. 平台禁用流程不再向外抛异常(#715-7 / #716-5) AfyBrokerPlugin.disable 的同步分支原先通过泛型擦除 rethrow,会中断 AfyBroker 对后续插件的卸载;HytaleCommandSender.fireQuitCallbacks 同样会把回调异常抛给 平台事件系统。两处统一为「记录但不抛」,与各自的异步分支保持一致。 3. Hytale 命令派发失败不再完全静默(#716-2) dispatchCommand 去掉 future.get() 是正确的(在命令处理线程等待命令处理 Future 会自死锁),但异常此前被完全吞掉、连日志都没有。现挂上 whenComplete 记录失败。 4. 退出会话终态不再被包装实例构造清除(#716-4) activateQuitSession 原先在 HytalePlayer 的 init 块中调用,而 adaptPlayer 每次 都会构造新实例,玩家退出后再次构造会把「已完成」标记清掉,使之后注册的回调 永远等待。改为在 registerQuitCallback 依据在线状态判定是否为新会话。 5. FileWatcherTest 跨平台稳定性与单例污染(#704-4 / #704-5) absolutePath 在 Windows / macOS 上可能与 canonicalPath 不等,改用 canonicalFile 比较——该断言此前在本机即失败;同时移除对全局单例 FileWatcher.INSTANCE.release() 的调用,其 released 状态不可逆,会污染同一 JVM 内的后续测试。 新增「同目录多监听器互不干扰」用例,覆盖目录级引用计数的修复。 同步更新 HytaleCommandSenderTest 以匹配不再 rethrow 的新语义。 --- .../taboolib/common5/FileWatcherTest.kt | 29 +++++++++++- .../module/nms/remap/RemapTranslation.kt | 23 +++++++++- .../taboolib/platform/AfyBrokerPlugin.java | 9 ++-- .../platform/type/HytaleCommandSender.kt | 46 +++++++++++++++---- .../taboolib/platform/type/HytalePlayer.kt | 8 +--- .../platform/type/HytaleCommandSenderTest.kt | 34 ++++++++------ 6 files changed, 109 insertions(+), 40 deletions(-) diff --git a/common-legacy-api/src/test/kotlin/taboolib/common5/FileWatcherTest.kt b/common-legacy-api/src/test/kotlin/taboolib/common5/FileWatcherTest.kt index e82a11bf5..8762c4ec5 100644 --- a/common-legacy-api/src/test/kotlin/taboolib/common5/FileWatcherTest.kt +++ b/common-legacy-api/src/test/kotlin/taboolib/common5/FileWatcherTest.kt @@ -20,7 +20,9 @@ class FileWatcherTest { val watcher = FileWatcher(20) try { watcher.addSimpleListener(file, { changed -> - if (changed.absoluteFile == file.absoluteFile && !changed.exists()) { + // Windows / macOS 上 absolutePath 与 canonicalPath 可能不等(8.3 短名、符号链接), + // 因此统一取 canonicalFile 比较;文件已删除时 Files.isSameFile 会失败,故不使用它。 + if (changed.canonicalFile == file.canonicalFile && !changed.exists()) { deleted.countDown() } }) @@ -28,9 +30,32 @@ class FileWatcherTest { assertTrue(deleted.await(5, TimeUnit.SECONDS)) } finally { + // 只释放本用例创建的实例。 + // FileWatcher.INSTANCE 是全局单例且 released 不可逆, + // 在此释放会污染同一 JVM 内的后续测试。 watcher.release() watcher.release() - FileWatcher.INSTANCE.release() + } + } + + @Test + fun `listeners on the same directory do not cancel each other`() { + val first = Files.write(tempDirectory.resolve("first.txt"), byteArrayOf(1)).toFile() + val second = Files.write(tempDirectory.resolve("second.txt"), byteArrayOf(1)).toFile() + val secondChanged = CountDownLatch(1) + val watcher = FileWatcher(20) + try { + // Path.register 对同一目录返回同一个 WatchKey, + // 移除其中一个监听器不应让同目录下其余监听器失效。 + watcher.addSimpleListener(first, {}) + watcher.addSimpleListener(second, { secondChanged.countDown() }) + watcher.removeListener(first) + + Files.write(second.toPath(), byteArrayOf(2)) + + assertTrue(secondChanged.await(5, TimeUnit.SECONDS)) + } finally { + watcher.release() } } } diff --git a/module/bukkit-nms/src/main/kotlin/taboolib/module/nms/remap/RemapTranslation.kt b/module/bukkit-nms/src/main/kotlin/taboolib/module/nms/remap/RemapTranslation.kt index 771d1b836..744da0c23 100644 --- a/module/bukkit-nms/src/main/kotlin/taboolib/module/nms/remap/RemapTranslation.kt +++ b/module/bukkit-nms/src/main/kotlin/taboolib/module/nms/remap/RemapTranslation.kt @@ -6,7 +6,6 @@ import org.objectweb.asm.Opcodes import org.objectweb.asm.commons.Remapper import taboolib.common.reflect.ClassHelper import taboolib.module.nms.MinecraftVersion -import taboolib.module.nms.remap.RemapTranslation.Companion.extraTransformers import java.util.concurrent.ConcurrentHashMap import java.util.concurrent.CopyOnWriteArrayList @@ -46,6 +45,22 @@ open class RemapTranslation : Remapper() { */ @JvmStatic val extraTransformers: MutableList<(String, ByteArray) -> ByteArray?> = CopyOnWriteArrayList() + + /** + * Mojang 短类名 -> Mojang 全类名,仅收录短名唯一的条目。 + * + * [translate] 会被 ASM 对类中每一个类型引用调用一次,而该层没有缓存, + * 因此这里预建索引以避免在映射表的 6000+ 条目上做线性扫描。 + * + * 短名存在冲突时不收录,使查找结果为 null 从而保持「无法确定则不改动」的保守语义 + * (与原先 `singleOrNull` 的行为一致)。 + */ + private val uniqueMojangShortNames: Map by lazy { + MinecraftVersion.paperMapping.classMapSpigotToMojang.values + .groupBy { it.substringAfterLast('.') } + .filterValues { it.size == 1 } + .mapValues { it.value.single() } + } } /** 运行 [extraTransformers] 管线;供 [taboolib.module.nms.AsmClassTranslation] 调用。 */ @@ -138,6 +153,10 @@ open class RemapTranslation : Remapper() { /** * 将 Mojang 类名转为 Runtime 类名,运行时已有类名优先保留。 + * + * Mojang Mapping 环境下,Paper PluginRemapper 只处理插件本体的类引用, + * 对 TabooLib 在运行期动态生成 / 转译的类无能为力,因此这里需要自行回落: + * 运行时不存在该类时,尝试通过映射表(先全名,后唯一短名)找到真正可加载的名称。 */ fun translateMojangToRuntimeOrKeep(key: String): String { val runtimeName = key.replace('/', '.') @@ -146,7 +165,7 @@ open class RemapTranslation : Remapper() { } val shortName = runtimeName.substringAfterLast('.') val mappingName = MinecraftVersion.paperMapping.classMapSpigotToMojang[runtimeName] - ?: MinecraftVersion.paperMapping.classMapSpigotToMojang.values.singleOrNull { it.substringAfterLast('.') == shortName } + ?: uniqueMojangShortNames[shortName] ?: return key return if (hasRuntimeClass(mappingName)) mappingName.replace('.', '/') else key } diff --git a/platform/platform-afybroker/src/main/java/taboolib/platform/AfyBrokerPlugin.java b/platform/platform-afybroker/src/main/java/taboolib/platform/AfyBrokerPlugin.java index 89dd3aaee..1aa3e774a 100644 --- a/platform/platform-afybroker/src/main/java/taboolib/platform/AfyBrokerPlugin.java +++ b/platform/platform-afybroker/src/main/java/taboolib/platform/AfyBrokerPlugin.java @@ -175,7 +175,9 @@ private void disable() { } } if (failure != null) { - AfyBrokerPlugin.rethrow(failure); + // 与异步分支保持一致:仅记录不抛出。 + // 禁用流程由平台的 onDisable 调用,抛出异常会中断 AfyBroker 对后续插件的卸载。 + reportDisableFailure(failure); } } @@ -190,11 +192,6 @@ private void reportDisableFailure(Throwable ex) { } } - @SuppressWarnings("unchecked") - private static void rethrow(Throwable throwable) throws T { - throw (T) throwable; - } - @NotNull @Override public File getFile() { diff --git a/platform/platform-hytale/src/main/kotlin/taboolib/platform/type/HytaleCommandSender.kt b/platform/platform-hytale/src/main/kotlin/taboolib/platform/type/HytaleCommandSender.kt index 95c4af59c..93bead9b1 100644 --- a/platform/platform-hytale/src/main/kotlin/taboolib/platform/type/HytaleCommandSender.kt +++ b/platform/platform-hytale/src/main/kotlin/taboolib/platform/type/HytaleCommandSender.kt @@ -5,6 +5,7 @@ import com.hypixel.hytale.server.core.command.system.CommandManager import com.hypixel.hytale.server.core.command.system.CommandSender import com.hypixel.hytale.server.core.console.ConsoleSender import com.hypixel.hytale.server.core.permissions.PermissionsModule +import taboolib.common.PrimitiveIO import taboolib.common.platform.ProxyCommandSender import java.util.WeakHashMap import java.util.concurrent.CompletableFuture @@ -26,7 +27,20 @@ open class HytaleCommandSender(val sender: CommandSender) : ProxyCommandSender { @JvmSynthetic internal fun dispatchCommand(dispatch: () -> CompletableFuture): Boolean { - dispatch() + // 不阻塞等待执行结果:命令处理线程上等待命令处理 Future 会形成自死锁。 + // 代价是返回值恒为 true,无法反映命令是否真正执行成功; + // 因此这里挂上回调,至少让执行失败在控制台可见。 + try { + dispatch().whenComplete { _, ex -> + if (ex != null) { + PrimitiveIO.error("Failed to dispatch command: {0}", ex.message ?: ex.javaClass.name) + ex.printStackTrace() + } + } + } catch (ex: Throwable) { + PrimitiveIO.error("Failed to dispatch command: {0}", ex.message ?: ex.javaClass.name) + ex.printStackTrace() + } return true } @@ -34,6 +48,12 @@ open class HytaleCommandSender(val sender: CommandSender) : ProxyCommandSender { private val quitCallbacks = WeakHashMap>() private val completedQuitSessions = WeakHashMap() + /** + * 激活会话,清除该会话的「已完成」标记。 + * + * 仅应在玩家真正进入服务器时调用。正常路径下由 [registerQuitCallback] + * 在玩家在线时自动完成,该方法供平台在明确得知玩家进入服务器时主动调用。 + */ @JvmSynthetic internal fun activateQuitSession(session: Any) { synchronized(quitLock) { @@ -41,9 +61,22 @@ open class HytaleCommandSender(val sender: CommandSender) : ProxyCommandSender { } } + /** + * 注册退出回调。 + * + * @param session 会话标识(`player.playerRef`) + * @param online 注册时玩家是否仍在线。玩家在线说明这是一个新会话, + * 此时需要清除同一 session 上遗留的「已完成」标记, + * 否则重连后注册的回调会被立即执行。 + * 该判断放在注册时而非包装实例的构造函数中, + * 因为 `adaptPlayer` 会反复构造实例,在构造函数中重置会把退出终态覆盖掉。 + */ @JvmSynthetic - internal fun registerQuitCallback(session: Any, callback: Runnable) { + internal fun registerQuitCallback(session: Any, callback: Runnable, online: Boolean = false) { val runImmediately = synchronized(quitLock) { + if (online) { + completedQuitSessions.remove(session) + } if (completedQuitSessions.containsKey(session)) { true } else { @@ -62,19 +95,14 @@ open class HytaleCommandSender(val sender: CommandSender) : ProxyCommandSender { completedQuitSessions[session] = true quitCallbacks.remove(session)?.toList().orEmpty() } - var failure: Throwable? = null + // 该方法由平台事件回调触发,抛出异常可能中断后续监听器,因此仅记录不抛出。 registered.forEach { try { it.run() } catch (ex: Throwable) { - if (failure == null) { - failure = ex - } else { - failure?.addSuppressed(ex) - } + ex.printStackTrace() } } - failure?.let { throw it } } @JvmSynthetic diff --git a/platform/platform-hytale/src/main/kotlin/taboolib/platform/type/HytalePlayer.kt b/platform/platform-hytale/src/main/kotlin/taboolib/platform/type/HytalePlayer.kt index 316f6f7b3..41dd6fc3b 100644 --- a/platform/platform-hytale/src/main/kotlin/taboolib/platform/type/HytalePlayer.kt +++ b/platform/platform-hytale/src/main/kotlin/taboolib/platform/type/HytalePlayer.kt @@ -22,12 +22,6 @@ import java.util.* @Suppress("removal") class HytalePlayer(val player: Player) : ProxyPlayer { - init { - if (isOnline()) { - HytaleCommandSender.activateQuitSession(player.playerRef) - } - } - override val origin: Any get() = player @@ -346,6 +340,6 @@ class HytalePlayer(val player: Player) : ProxyPlayer { } override fun onQuit(callback: Runnable) { - HytaleCommandSender.registerQuitCallback(player.playerRef, callback) + HytaleCommandSender.registerQuitCallback(player.playerRef, callback, isOnline()) } } diff --git a/platform/platform-hytale/src/test/kotlin/taboolib/platform/type/HytaleCommandSenderTest.kt b/platform/platform-hytale/src/test/kotlin/taboolib/platform/type/HytaleCommandSenderTest.kt index e17c17aaf..30cd64642 100644 --- a/platform/platform-hytale/src/test/kotlin/taboolib/platform/type/HytaleCommandSenderTest.kt +++ b/platform/platform-hytale/src/test/kotlin/taboolib/platform/type/HytaleCommandSenderTest.kt @@ -28,14 +28,9 @@ class HytaleCommandSenderTest { } @Test - fun `command dispatch propagates synchronous failure`() { - val failure = IllegalStateException("boom") - - val thrown = assertThrows(IllegalStateException::class.java) { - HytaleCommandSender.dispatchCommand { throw failure } - } - - assertSame(failure, thrown) + fun `command dispatch reports synchronous failure without throwing`() { + // 命令派发失败不应打断调用方:异常被记录而非抛出,返回值仍为 true。 + assertTrue(HytaleCommandSender.dispatchCommand { throw IllegalStateException("boom") }) } @Test @@ -80,16 +75,27 @@ class HytaleCommandSenderTest { @Test fun `quit callback failure does not skip remaining callbacks`() { val session = Any() - val failure = IllegalStateException("boom") var calls = 0 - HytaleCommandSender.registerQuitCallback(session, Runnable { throw failure }) + HytaleCommandSender.registerQuitCallback(session, Runnable { throw IllegalStateException("boom") }) HytaleCommandSender.registerQuitCallback(session, Runnable { calls++ }) - val thrown = assertThrows(IllegalStateException::class.java) { - HytaleCommandSender.fireQuitCallbacks(session) - } + // 该方法由平台事件回调触发,回调异常仅记录不抛出,避免中断后续监听器 + HytaleCommandSender.fireQuitCallbacks(session) + + assertEquals(1, calls) + } + + @Test + fun `registering while online clears the completed session marker`() { + val session = Any() + var calls = 0 + HytaleCommandSender.fireQuitCallbacks(session) - assertSame(failure, thrown) + // 玩家在线说明是新会话,遗留的「已完成」标记应被清除,回调转为等待而非立即执行 + HytaleCommandSender.registerQuitCallback(session, Runnable { calls++ }, online = true) + assertEquals(0, calls) + + HytaleCommandSender.fireQuitCallbacks(session) assertEquals(1, calls) } } From d362e69b86f937f6b4e973c5adc815864db69715 Mon Sep 17 00:00:00 2001 From: FxRayHughes Date: Sun, 26 Jul 2026 22:15:39 +0800 Subject: [PATCH 28/37] =?UTF-8?q?fix(review):=20=E6=B6=88=E9=99=A4=20porti?= =?UTF-8?q?cus=20=E5=AE=8C=E6=88=90=E5=88=A4=E5=AE=9A=E7=9A=84=E5=BC=82?= =?UTF-8?q?=E5=B8=B8=E6=8E=A7=E5=88=B6=E6=B5=81=E5=B9=B6=E4=BF=AE=E6=AD=A3?= =?UTF-8?q?=20Velocity=20=E6=B3=A8=E9=87=8A?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 1. Message.isCompleted() 不再用异常做控制流(#713-4) readValidated 每收到一个数据包都会调用一次 isCompleted(),原实现通过 捕获 validateCompleted 抛出的 IllegalStateException 返回 false, 1024 分包的消息会构造 1023 个随即丢弃的异常(栈填充是其中最贵的部分)。 拆分出等价的 checkCompleted 布尔检查,validateCompleted 仍用于 build() 的 前置校验以保留原有异常信息。 2. 修正 VelocityPlugin.e(ProxyShutdownEvent) 的注释(#715-1) 该方法在迁移 @Subscribe 后已不再被 Velocity 触发,原注释「保留旧同步入口」 容易被误读为它仍在关服流程中生效。改为说明其仅保留公开签名, 实际关服由 eAsync 处理。 --- .../module/porticus/common/Message.java | 38 ++++++++++++++----- .../taboolib/platform/VelocityPlugin.java | 6 ++- 2 files changed, 33 insertions(+), 11 deletions(-) diff --git a/module/minecraft/minecraft-porticus/src/main/java/taboolib/module/porticus/common/Message.java b/module/minecraft/minecraft-porticus/src/main/java/taboolib/module/porticus/common/Message.java index 9a9b37b75..d29fba980 100644 --- a/module/minecraft/minecraft-porticus/src/main/java/taboolib/module/porticus/common/Message.java +++ b/module/minecraft/minecraft-porticus/src/main/java/taboolib/module/porticus/common/Message.java @@ -127,16 +127,7 @@ public String[] buildOnce() { * 所有数据包是否接收完成 */ public boolean isCompleted() { - List snapshot = Lists.newArrayList(messages); - if (snapshot.isEmpty()) { - return false; - } - try { - validateCompleted(snapshot); - return true; - } catch (IllegalStateException ignored) { - return false; - } + return checkCompleted(Lists.newArrayList(messages)); } /** @@ -270,4 +261,31 @@ private static void validateCompleted(List packets) { } } } + + /** + * 与 {@link #validateCompleted(List)} 等价的无异常检查。 + *

+ * {@link #isCompleted()} 在每收到一个数据包时都会被调用,若用异常做控制流, + * 1024 分包的消息会构造 1023 个随即丢弃的异常(栈填充是其中最贵的部分)。 + */ + private static boolean checkCompleted(List packets) { + if (packets.isEmpty()) { + return false; + } + MessagePacket first = packets.get(0); + int total = first.getTotal(); + if (packets.size() != total) { + return false; + } + Set indexes = new HashSet<>(); + for (MessagePacket packet : packets) { + if (!first.getUID().equals(packet.getUID()) || packet.getTotal() != total) { + return false; + } + if (packet.getIndex() < 1 || packet.getIndex() > total || !indexes.add(packet.getIndex())) { + return false; + } + } + return true; + } } diff --git a/platform/platform-velocity/src/main/java/taboolib/platform/VelocityPlugin.java b/platform/platform-velocity/src/main/java/taboolib/platform/VelocityPlugin.java index 5f55f6c76..67312e0bb 100644 --- a/platform/platform-velocity/src/main/java/taboolib/platform/VelocityPlugin.java +++ b/platform/platform-velocity/src/main/java/taboolib/platform/VelocityPlugin.java @@ -136,7 +136,11 @@ public void e(ProxyInitializeEvent e) { } /** - * 保留旧同步入口;该入口无法向调用方表达异步完成,只负责观察失败。 + * 保留旧的公开方法签名,避免破坏可能存在的反射调用。 + *

+ * 注意:该方法已不再带有 {@code @Subscribe},Velocity 不会再触发它, + * 关服流程实际由 {@link #eAsync(ProxyShutdownEvent)} 处理——后者能通过 + * {@link EventTask} 向 Velocity 表达异步完成,从而保证 DISABLE 阶段执行完毕后才继续关服。 */ public void e(ProxyShutdownEvent e) { observeDisable(disableAfterActivation()); From 92c9e7119d90de3aed44a96a5edc01d01a92e697 Mon Sep 17 00:00:00 2001 From: FxRayHughes Date: Sun, 26 Jul 2026 22:34:57 +0800 Subject: [PATCH 29/37] =?UTF-8?q?refactor(review):=20=E6=94=B6=E7=B4=A7=20?= =?UTF-8?q?Porticus=20=E4=BB=BB=E5=8A=A1=E8=A1=A8=E4=B8=8E=E5=91=BD?= =?UTF-8?q?=E4=BB=A4=E8=A1=A8=E7=9A=84=E5=B9=B6=E5=8F=91=E7=BB=93=E6=9E=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 依据 #712 / #713 / #715 评审的可选建议,落实公开 API 层面的线程安全改进: 1. Porticus.missions 改用 ConcurrentHashMap(#713-1) 令「同一 UID 只能有一个 pending 任务」成为结构约束,取代注册时的 O(n) 线性查重; 终态裁决改用 remove(uid, mission),其原子性同样保证响应/超时/发送失败三方竞争时 回调恰好执行一次。事件处理由遍历改为按 UID 直接定位。 基类新增 isPending() 供子类复用,替代原先的 missions.contains(this)。 同时修正 run() 中 start/started 的赋值时序,确保在入列之前完成。 2. sendBungeeMessage 增加 queue 参数(#713-2) 目标子服无活跃连接时现在会抛出 IllegalStateException,这会打断「遍历 getServers() 向所有子服广播」的用法。新增可选 queue 参数让此类调用退回 静默排队的旧行为,默认仍为快速失败。 3. Folia.isFolia 补充 volatile 与写入约束说明(#712-7) 该值在类初始化时探测一次即固定,运行期修改会让已提交与新提交的任务落在 不同调度体系上。保留字段可写以兼容既有访问形式,另提供标注为内部 API 的 setFolia 供测试切换环境。 4. registeredCommands 改用 CopyOnWriteArrayList(#715-9) Bukkit 侧写入受 commandLock 保护但字段公开、外部读取不持锁;Velocity 侧 同为公开裸 ArrayList。两处均改为 CoW 列表。 顺带修正 VelocityCommand.unregisterCommands 只注销不清空的问题——重复调用 会对同一命令反复注销;unregisterCommand 现在也会同步移除记录。 --- .../module/porticus/PorticusMission.java | 27 +++++++++----- .../porticus/bukkitside/MissionBukkit.java | 8 ++--- .../porticus/bukkitside/PorticusListener.java | 21 ++++++----- .../porticus/bungeeside/MissionBungee.java | 35 +++++++++++++++---- .../porticus/bungeeside/PorticusListener.java | 22 ++++++------ .../taboolib/module/porticus/Porticus.kt | 13 +++++-- .../module/porticus/PorticusMissionTest.java | 10 +++--- .../kotlin/taboolib/platform/BukkitCommand.kt | 17 +++++---- .../main/java/taboolib/platform/Folia.java | 21 ++++++++++- .../taboolib/platform/VelocityCommand.kt | 14 ++++++-- 10 files changed, 131 insertions(+), 57 deletions(-) diff --git a/module/minecraft/minecraft-porticus/src/main/java/taboolib/module/porticus/PorticusMission.java b/module/minecraft/minecraft-porticus/src/main/java/taboolib/module/porticus/PorticusMission.java index 16d06df76..2cf408755 100644 --- a/module/minecraft/minecraft-porticus/src/main/java/taboolib/module/porticus/PorticusMission.java +++ b/module/minecraft/minecraft-porticus/src/main/java/taboolib/module/porticus/PorticusMission.java @@ -56,15 +56,15 @@ public synchronized void run(@NotNull Object target) { } boolean trackCompletion = consumer != null || runnable != null; if (trackCompletion) { - synchronized (Porticus.INSTANCE.getMissions()) { - for (PorticusMission mission : Porticus.INSTANCE.getMissions()) { - if (mission.getUID().equals(uid)) { - throw new IllegalStateException("A Porticus mission with the same UID is already pending"); - } - } - this.start = timeSource.getAsLong(); - this.started = true; - Porticus.INSTANCE.getMissions().add(this); + // start 与 started 必须在入列之前赋值: + // start 默认为 0 时 isTimeout() 恒为 true,若超时扫描线程在赋值之前取到该任务会立即误判超时。 + this.start = timeSource.getAsLong(); + this.started = true; + // UID 唯一性由 Map 结构保证,无需线性查重 + PorticusMission existing = Porticus.INSTANCE.getMissions().putIfAbsent(uid, this); + if (existing != null) { + this.started = false; + throw new IllegalStateException("A Porticus mission with the same UID is already pending"); } } else { this.start = timeSource.getAsLong(); @@ -72,6 +72,15 @@ public synchronized void run(@NotNull Object target) { } } + /** + * 该任务是否仍处于等待回执的状态。 + *

+ * 任务被超时扫描、回执事件或发送失败任一方裁决后即从表中移除,此后返回 false。 + */ + public boolean isPending() { + return Porticus.INSTANCE.getMissions().get(uid) == this; + } + /** * 创建通讯任务的回执执行动作 * 当对方通过 response() 方法返回信息时,该动作将会被执行。 diff --git a/module/minecraft/minecraft-porticus/src/main/java/taboolib/module/porticus/bukkitside/MissionBukkit.java b/module/minecraft/minecraft-porticus/src/main/java/taboolib/module/porticus/bukkitside/MissionBukkit.java index cbdfbe259..27709798a 100644 --- a/module/minecraft/minecraft-porticus/src/main/java/taboolib/module/porticus/bukkitside/MissionBukkit.java +++ b/module/minecraft/minecraft-porticus/src/main/java/taboolib/module/porticus/bukkitside/MissionBukkit.java @@ -52,7 +52,7 @@ public void run(@NotNull Object target) { try { scheduleBukkitMessage((Player) target, messages, tracked); } catch (Throwable t) { - Porticus.INSTANCE.getMissions().remove(this); + Porticus.INSTANCE.getMissions().remove(getUID(), this); throw new IllegalStateException("failed to schedule mission message", t); } } @@ -69,15 +69,15 @@ public void sendBukkitMessage(Player player, String[] command) { } private void scheduleBukkitMessage(Player player, List messages, boolean tracked) throws Exception { - Runnable failure = tracked ? () -> Porticus.INSTANCE.getMissions().remove(this) : () -> { + Runnable failure = tracked ? () -> Porticus.INSTANCE.getMissions().remove(getUID(), this) : () -> { }; Runnable sendTask = () -> { - if (tracked && !Porticus.INSTANCE.getMissions().contains(this)) { + if (tracked && !isPending()) { return; } try { for (byte[] bytes : messages) { - if (tracked && !Porticus.INSTANCE.getMissions().contains(this)) { + if (tracked && !isPending()) { return; } player.sendPluginMessage(plugin, Porticus.INSTANCE.getChannelId(), bytes); diff --git a/module/minecraft/minecraft-porticus/src/main/java/taboolib/module/porticus/bukkitside/PorticusListener.java b/module/minecraft/minecraft-porticus/src/main/java/taboolib/module/porticus/bukkitside/PorticusListener.java index 58e9883c3..3ffdc0a95 100644 --- a/module/minecraft/minecraft-porticus/src/main/java/taboolib/module/porticus/bukkitside/PorticusListener.java +++ b/module/minecraft/minecraft-porticus/src/main/java/taboolib/module/porticus/bukkitside/PorticusListener.java @@ -35,8 +35,8 @@ public PorticusListener() { Bukkit.getMessenger().registerIncomingPluginChannel(plugin, Porticus.INSTANCE.getChannelId(), this); Bukkit.getMessenger().registerOutgoingPluginChannel(plugin, Porticus.INSTANCE.getChannelId()); Runnable timeoutTask = () -> { - for (PorticusMission mission : Porticus.INSTANCE.getMissions()) { - if (mission.isTimeout() && Porticus.INSTANCE.getMissions().remove(mission)) { + for (PorticusMission mission : Porticus.INSTANCE.getMissions().values()) { + if (mission.isTimeout() && Porticus.INSTANCE.getMissions().remove(mission.getUID(), mission)) { if (mission.getTimeoutRunnable() != null) { try { mission.getTimeoutRunnable().run(); @@ -57,16 +57,15 @@ public PorticusListener() { @EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true) public void e(PorticusBukkitEvent e) { - for (PorticusMission mission : Porticus.INSTANCE.getMissions()) { - if (mission.getUID().equals(e.getUID()) && Porticus.INSTANCE.getMissions().remove(mission)) { - if (mission.getResponseConsumer() != null) { - try { - mission.getResponseConsumer().accept(e.getArgs()); - } catch (Throwable t) { - t.printStackTrace(); - } + // 按 UID 直接定位,remove(key, value) 的原子性保证回调恰好执行一次 + PorticusMission mission = Porticus.INSTANCE.getMissions().get(e.getUID()); + if (mission != null && Porticus.INSTANCE.getMissions().remove(e.getUID(), mission)) { + if (mission.getResponseConsumer() != null) { + try { + mission.getResponseConsumer().accept(e.getArgs()); + } catch (Throwable t) { + t.printStackTrace(); } - break; } } } diff --git a/module/minecraft/minecraft-porticus/src/main/java/taboolib/module/porticus/bungeeside/MissionBungee.java b/module/minecraft/minecraft-porticus/src/main/java/taboolib/module/porticus/bungeeside/MissionBungee.java index 9fbe04b68..c4e512e98 100644 --- a/module/minecraft/minecraft-porticus/src/main/java/taboolib/module/porticus/bungeeside/MissionBungee.java +++ b/module/minecraft/minecraft-porticus/src/main/java/taboolib/module/porticus/bungeeside/MissionBungee.java @@ -49,13 +49,13 @@ public void run(@NotNull Object target) { super.run(target); try { ScheduledTask task = BungeeCord.getInstance().getScheduler().runAsync(plugin, () -> { - if (tracked && !Porticus.INSTANCE.getMissions().contains(this)) { + if (tracked && !isPending()) { return; } try { sendMessages(messageTarget, messages, true); } catch (Throwable t) { - Porticus.INSTANCE.getMissions().remove(this); + Porticus.INSTANCE.getMissions().remove(getUID(), this); t.printStackTrace(); } }); @@ -63,7 +63,7 @@ public void run(@NotNull Object target) { throw new IllegalStateException("Bungee scheduler rejected Porticus message task"); } } catch (Throwable t) { - Porticus.INSTANCE.getMissions().remove(this); + Porticus.INSTANCE.getMissions().remove(getUID(), this); throw new IllegalStateException("failed to schedule mission message", t); } } @@ -80,6 +80,17 @@ public static void sendBungeeMessage(ServerInfo server, String... args) { sendStandalone(resolveServerInfo(server), args); } + /** + * 向指定子服发送消息。 + * + * @param queue 目标服无活跃连接时是否交由 Bungee 排队。 + * 传 false 时无连接会抛出 {@link IllegalStateException}; + * 传 true 则静默排队,适用于遍历 {@code getServers()} 向所有子服广播的场景。 + */ + public static void sendBungeeMessage(ServerInfo server, boolean queue, String... args) { + sendStandalone(resolveServerInfo(server, queue), args); + } + private static void sendStandalone(MessageTarget target, String[] args) { try { Plugin plugin = getPlugin(); @@ -154,14 +165,26 @@ private static MessageTarget resolveServer(Server server) { } private static MessageTarget resolveServerInfo(ServerInfo server) { + return resolveServerInfo(server, false); + } + + /** + * 解析子服发送目标。 + * + * @param queue 目标服无活跃连接时是否交由 Bungee 排队。 + * 传 false(默认)会立即抛出 {@link IllegalStateException},便于调用方感知发送失败; + * 传 true 则退回旧行为静默排队,适用于「遍历 getServers() 向所有子服广播」这类 + * 不应因个别空服而中断的用法。 + */ + private static MessageTarget resolveServerInfo(ServerInfo server, boolean queue) { if (server == null) { throw new IllegalArgumentException("server cannot be null"); } - if (server.getPlayers().isEmpty()) { + if (!queue && server.getPlayers().isEmpty()) { throw new IllegalStateException("target server has no active player connection"); } return bytes -> { - if (!server.sendData(Porticus.INSTANCE.getChannelId(), bytes, false)) { + if (!server.sendData(Porticus.INSTANCE.getChannelId(), bytes, queue) && !queue) { throw new IllegalStateException("target server has no active player connection"); } }; @@ -200,7 +223,7 @@ public void send(byte[] bytes) { } private boolean missionPending() { - return !tracked || Porticus.INSTANCE.getMissions().contains(MissionBungee.this); + return !tracked || MissionBungee.this.isPending(); } } } diff --git a/module/minecraft/minecraft-porticus/src/main/java/taboolib/module/porticus/bungeeside/PorticusListener.java b/module/minecraft/minecraft-porticus/src/main/java/taboolib/module/porticus/bungeeside/PorticusListener.java index 871f38db4..dfe54833b 100644 --- a/module/minecraft/minecraft-porticus/src/main/java/taboolib/module/porticus/bungeeside/PorticusListener.java +++ b/module/minecraft/minecraft-porticus/src/main/java/taboolib/module/porticus/bungeeside/PorticusListener.java @@ -33,8 +33,8 @@ public PorticusListener() { ProxyServer.getInstance().registerChannel(Porticus.INSTANCE.getChannelId()); ProxyServer.getInstance().getPluginManager().registerListener(plugin, this); BungeeCord.getInstance().getScheduler().schedule(plugin, () -> { - for (PorticusMission mission : Porticus.INSTANCE.getMissions()) { - if (mission.isTimeout() && Porticus.INSTANCE.getMissions().remove(mission)) { + for (PorticusMission mission : Porticus.INSTANCE.getMissions().values()) { + if (mission.isTimeout() && Porticus.INSTANCE.getMissions().remove(mission.getUID(), mission)) { if (mission.getTimeoutRunnable() != null) { try { mission.getTimeoutRunnable().run(); @@ -54,17 +54,17 @@ public void e(PorticusBungeeEvent e) { return; } try { - for (PorticusMission mission : Porticus.INSTANCE.getMissions()) { - if (mission.getUID().equals(e.getUID()) && Porticus.INSTANCE.getMissions().remove(mission)) { - if (mission.getResponseConsumer() != null) { - try { - mission.getResponseConsumer().accept(e.getArgs()); - } catch (Throwable t) { - t.printStackTrace(); - } + // 按 UID 直接定位,remove(key, value) 的原子性保证回调恰好执行一次 + PorticusMission mission = Porticus.INSTANCE.getMissions().get(e.getUID()); + if (mission != null && Porticus.INSTANCE.getMissions().remove(e.getUID(), mission)) { + if (mission.getResponseConsumer() != null) { + try { + mission.getResponseConsumer().accept(e.getArgs()); + } catch (Throwable t) { + t.printStackTrace(); } - return; } + return; } String[] args = e.getArgs(); if (args.length < 2 || !"porticus".equals(args[0])) { diff --git a/module/minecraft/minecraft-porticus/src/main/kotlin/taboolib/module/porticus/Porticus.kt b/module/minecraft/minecraft-porticus/src/main/kotlin/taboolib/module/porticus/Porticus.kt index e7c6912b5..71153741c 100644 --- a/module/minecraft/minecraft-porticus/src/main/kotlin/taboolib/module/porticus/Porticus.kt +++ b/module/minecraft/minecraft-porticus/src/main/kotlin/taboolib/module/porticus/Porticus.kt @@ -11,7 +11,8 @@ import taboolib.common.platform.PlatformSide import taboolib.common.platform.function.pluginId import taboolib.common.util.unsafeLazy import taboolib.module.porticus.common.MessageReader -import java.util.concurrent.CopyOnWriteArrayList +import java.util.UUID +import java.util.concurrent.ConcurrentHashMap /** * Porticus API 通用入口 @@ -28,9 +29,15 @@ object Porticus { } /** - * 获取正在运行的通讯任务 + * 正在运行的通讯任务,按 UID 索引。 + * + * 使用 Map 而非列表,令「同一 UID 只能有一个 pending 任务」成为结构约束, + * 而不再依赖注册时的 O(n) 线性查重。 + * + * 终态裁决(响应 / 超时 / 发送失败三方竞争)依赖 [MutableMap.remove] 的原子性: + * `missions.remove(uid, mission)` 只会有一方拿到 true,从而保证回调恰好执行一次。 */ - val missions = CopyOnWriteArrayList() + val missions = ConcurrentHashMap() /** * 获取 Porticus API diff --git a/module/minecraft/minecraft-porticus/src/test/java/taboolib/module/porticus/PorticusMissionTest.java b/module/minecraft/minecraft-porticus/src/test/java/taboolib/module/porticus/PorticusMissionTest.java index f1489a744..c61c56f07 100644 --- a/module/minecraft/minecraft-porticus/src/test/java/taboolib/module/porticus/PorticusMissionTest.java +++ b/module/minecraft/minecraft-porticus/src/test/java/taboolib/module/porticus/PorticusMissionTest.java @@ -8,6 +8,7 @@ import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertSame; import static org.junit.jupiter.api.Assertions.assertThrows; import static org.junit.jupiter.api.Assertions.assertTrue; @@ -51,7 +52,8 @@ void pendingMissionCannotBeStartedAgain() { mission.now = 2_000; assertThrows(IllegalStateException.class, () -> mission.run(new Object())); - assertEquals(1, Porticus.INSTANCE.getMissions().stream().filter(it -> it == mission).count()); + assertEquals(1, Porticus.INSTANCE.getMissions().size()); + assertSame(mission, Porticus.INSTANCE.getMissions().get(mission.getUID())); assertEquals(1_000, mission.getStart()); } @@ -69,7 +71,7 @@ void differentMissionsCannotSharePendingUid() { assertThrows(IllegalStateException.class, () -> second.run(new Object())); assertEquals(1, Porticus.INSTANCE.getMissions().size()); - assertTrue(Porticus.INSTANCE.getMissions().contains(first)); + assertSame(first, Porticus.INSTANCE.getMissions().get(uid)); } @Test @@ -114,11 +116,11 @@ private TestMission(UUID uid) { } private boolean cancel() { - return Porticus.INSTANCE.getMissions().remove(this); + return Porticus.INSTANCE.getMissions().remove(getUID(), this); } private boolean pending() { - return Porticus.INSTANCE.getMissions().contains(this); + return isPending(); } } } diff --git a/platform/platform-bukkit-impl/src/main/kotlin/taboolib/platform/BukkitCommand.kt b/platform/platform-bukkit-impl/src/main/kotlin/taboolib/platform/BukkitCommand.kt index 4749b03d2..20dca26e9 100644 --- a/platform/platform-bukkit-impl/src/main/kotlin/taboolib/platform/BukkitCommand.kt +++ b/platform/platform-bukkit-impl/src/main/kotlin/taboolib/platform/BukkitCommand.kt @@ -30,6 +30,7 @@ import taboolib.common.platform.function.submit import taboolib.common.platform.service.PlatformCommand import taboolib.common.util.unsafeLazy import java.lang.reflect.Constructor +import java.util.concurrent.CopyOnWriteArrayList internal fun commandLabelMatches(name: String, aliases: List, input: String, namespace: String): Boolean { val separator = input.indexOf(':') @@ -79,10 +80,16 @@ class BukkitCommand : PlatformCommand { } } - val registeredCommands = ArrayList() + /** + * 已注册的命令结构。 + * + * 写入始终在 [commandLock] 内完成,但该字段是公开的、外部读取不持锁, + * 因此使用 [CopyOnWriteArrayList] 保证并发读取时不会看到撕裂的中间状态。 + */ + val registeredCommands = CopyOnWriteArrayList() private val commandLock = Any() - private val registeredCommandBindings = ArrayList() + private val registeredCommandBindings = CopyOnWriteArrayList() private var isSupportedUnknownCommand = false private data class RegisteredCommand(val structure: CommandStructure, val command: PluginCommand) @@ -184,10 +191,8 @@ class BukkitCommand : PlatformCommand { removeMappingsByIdentity(knownCommands, binding.command) binding.command.unregister(commandMap) registeredCommandBindings.remove(binding) - val index = registeredCommands.indexOfFirst { it === binding.structure } - if (index >= 0) { - registeredCommands.removeAt(index) - } + // 按身份而非等值移除:CommandStructure 可能存在等值但不同源的实例 + registeredCommands.removeIf { it === binding.structure } } override fun unknownCommand(sender: ProxyCommandSender, command: String, state: Int) { diff --git a/platform/platform-bukkit/src/main/java/taboolib/platform/Folia.java b/platform/platform-bukkit/src/main/java/taboolib/platform/Folia.java index d7861ae0f..65eaf19a7 100644 --- a/platform/platform-bukkit/src/main/java/taboolib/platform/Folia.java +++ b/platform/platform-bukkit/src/main/java/taboolib/platform/Folia.java @@ -1,5 +1,7 @@ package taboolib.platform; +import org.jetbrains.annotations.ApiStatus; + /** * TabooLib * taboolib.platform.Folia @@ -9,7 +11,16 @@ */ public class Folia { - public static boolean isFolia = false; + /** + * 当前服务端是否为 Folia。 + *

+ * 该值在类初始化时探测一次即固定,不应由外部写入—— + * 它决定了调度器的选择路径,运行期修改会让已提交的任务与新任务落在不同的调度体系上。 + * 保留为可写字段仅为兼容既有的公开访问形式,以及供测试通过 {@link #setFolia(boolean)} 切换环境。 + *

+ * 声明为 volatile 以保证测试或初始化过程中的写入对其他线程可见。 + */ + public static volatile boolean isFolia = false; static { try { @@ -18,4 +29,12 @@ public class Folia { } catch (Throwable ignored) { } } + + /** + * 仅供测试切换运行环境使用,生产代码不应调用。 + */ + @ApiStatus.Internal + public static void setFolia(boolean value) { + isFolia = value; + } } diff --git a/platform/platform-velocity-impl/src/main/kotlin/taboolib/platform/VelocityCommand.kt b/platform/platform-velocity-impl/src/main/kotlin/taboolib/platform/VelocityCommand.kt index cc6feffff..008ff4eec 100644 --- a/platform/platform-velocity-impl/src/main/kotlin/taboolib/platform/VelocityCommand.kt +++ b/platform/platform-velocity-impl/src/main/kotlin/taboolib/platform/VelocityCommand.kt @@ -17,6 +17,7 @@ import taboolib.common.platform.command.component.CommandBase import taboolib.common.platform.function.adaptCommandSender import taboolib.common.platform.function.info import taboolib.common.platform.service.PlatformCommand +import java.util.concurrent.CopyOnWriteArrayList /** * TabooLib @@ -30,7 +31,12 @@ import taboolib.common.platform.service.PlatformCommand @PlatformSide(Platform.VELOCITY) class VelocityCommand : PlatformCommand { - val registeredCommands = ArrayList() + /** + * 已注册的命令名。 + * + * 该字段是公开的,可能被外部并发读取,因此使用 [CopyOnWriteArrayList]。 + */ + val registeredCommands = CopyOnWriteArrayList() override fun registerCommand( command: CommandStructure, @@ -59,10 +65,14 @@ class VelocityCommand : PlatformCommand { override fun unregisterCommand(command: String) { VelocityPlugin.getInstance().server.commandManager.unregister(command) + registeredCommands.remove(command) } override fun unregisterCommands() { - registeredCommands.onEach { VelocityPlugin.getInstance().server.commandManager.unregister(it) } + // 先取出再清空,避免重复调用时对同一命令反复注销 + val commands = registeredCommands.toList() + registeredCommands.clear() + commands.forEach { VelocityPlugin.getInstance().server.commandManager.unregister(it) } } @Suppress("DEPRECATION") From 8513ba0f2bda63d7f17c4e0531d888e384ab3eae Mon Sep 17 00:00:00 2001 From: FxRayHughes Date: Sun, 26 Jul 2026 22:36:20 +0800 Subject: [PATCH 30/37] =?UTF-8?q?fix(review):=20=E7=9C=9F=E6=AD=A3?= =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E5=AF=BC=E8=88=AA=E8=8A=82=E7=82=B9=E5=93=88?= =?UTF-8?q?=E5=B8=8C=E7=A2=B0=E6=92=9E=E5=B9=B6=E7=BB=9F=E4=B8=80=20JSON?= =?UTF-8?q?=20=E7=B1=BB=E5=9E=8B=E5=88=A4=E6=96=AD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 依据 #717 / #718 评审意见修正: 1. Node.createHash 真正消除两类碰撞(#717-1) PR 描述声称「修复节点坐标哈希」但函数本身未改动,只加了开放寻址探测。 实际存在两类真实碰撞:y 只保留 8 位使 1.18+ 的 -64..320 必然重叠; x 的第 8 位与 z<0 标志位重叠,createHash(128,5,1) == createHash(0,5,-32767)。 改为纯位域布局 [0,12)=y、[12,22)=x、[22,32)=z,三段均取补码低位—— 补码天然分离正负,两个符号标志位一并移除。 Node.hash 仍为公开 Int 字段,签名与类型未变,仅数值分布改变;该值只用于 单次寻路过程中的临时节点表,不跨版本持久化,故未改为 Long 键 (后者需改动 NodeReader.nodes 的公开类型,才是真正的 ABI 破坏)。 开放寻址保留但简化:32 位仍装不下完整 x/z 范围,探测必须留作兜底。 步长由 key*31+1 改为 key++,线性探测在 int 空间的周期是完整 2^32 而非 2^28; 失败上界改为 nodes.size + 1,把「2.68 亿次乘法空转」变为即时失败, 错误信息补上坐标便于排查。 2. isStandableAtRegion 的支撑高度判定过严(#717-3) 原判定要求 below.y + getBlockHeight(below) 与整数 y 精确相等(1e-3 容差), 而 getBlockHeight 对半砖返回 0.5、雪层返回 0.9375,实际只有满方块能通过, 使路径平滑在含台阶/农田/雪层的地形中退化为无操作。 改为区间判定,正确接受半砖、农田、雪层,同时排除空气与被埋情形。 3. addSweepBoundaries 补充对角穿角采样(#717-5) x/z 边界在对角移动时于同一 t 重合,去重后中点采样落在格子内部, 可能漏掉经典的对角穿墙。现对重合点额外在 t±offset 取样, offset 按线段长度归一化以保证世界距离恒定。 4. getStartAtRegion 不再回落世界底部(#717-4) 整柱非实心时原先返回 minHeight(通常是基岩/虚空),改为保留实体当前 y。 5. Fluid 查询缓存(#717-7) NodeReader 新增按坐标的 fluid 缓存(与既有 type 缓存同构), Fluid.getFluid 的 waterlogged 分支前置空气短路, 免掉纵向扫描循环中最常见路径上的 getBlockData() 调用。 6. TypeJson 统一到 typeName 判断(#718-3) 本 PR 只把 translate 改为基于 parseJsonType,score 仍用全等比较、 gradient 仍用 startsWith,导致 type: score:xxx 不匹配—— 与已修复的 translate:1:Stone 是同类缺陷。现五个分支全部统一。 顺带修正 gradient 参数不足 2 个时 toGradientColor 内部除零的问题。 7. SnapshotHashMap 的序列化陷阱(#718-1) 该类继承 HashMap 但所有状态存于内部快照,父类桶数组永远为空, 而 HashMap.writeObject 是 private 无法覆盖——序列化会静默得到空 map。 覆盖 writeReplace() 写出快照副本,并补充说明继承动机、JDK 升级需复查 新增默认方法;replaceWith 的注释「CAS」改为「原子引用替换」。 --- .../taboolib/module/navigation/Fluid.kt | 6 +- .../kotlin/taboolib/module/navigation/Node.kt | 38 +++++++++- .../taboolib/module/navigation/NodeReader.kt | 57 ++++++++++++--- .../module/navigation/PathSmoothing.kt | 73 +++++++++++++++++-- .../navigation/NavigationCorrectnessTest.kt | 35 ++++++++- .../taboolib/module/navigation/NodeTest.kt | 29 ++++++-- .../taboolib/module/lang/SnapshotHashMap.java | 42 ++++++++++- .../kotlin/taboolib/module/lang/TypeJson.kt | 9 ++- 8 files changed, 257 insertions(+), 32 deletions(-) diff --git a/module/bukkit/bukkit-navigation/src/main/kotlin/taboolib/module/navigation/Fluid.kt b/module/bukkit/bukkit-navigation/src/main/kotlin/taboolib/module/navigation/Fluid.kt index 41dd254c2..b92db2f54 100644 --- a/module/bukkit/bukkit-navigation/src/main/kotlin/taboolib/module/navigation/Fluid.kt +++ b/module/bukkit/bukkit-navigation/src/main/kotlin/taboolib/module/navigation/Fluid.kt @@ -29,7 +29,11 @@ enum class Fluid { "STATIONARY_WATER" -> WATER "FLOWING_WATER" -> FLOWING_WATER else -> { - if (MinecraftVersion.isHigherOrEqual(MinecraftVersion.V1_13)) { + // Bukkit 的 getBlockData() 每次调用都会新建 BlockData 对象, + // 而本方法处在寻路的热点路径上(getStartAtRegion 的纵向扫描会反复调用)。 + // 空气占绝大多数且不可能含水,先做一次零分配的短路判断。 + // 更上层的缓存见 NodeReader.getCachedFluid。 + if (!type.isAirLegacy() && MinecraftVersion.isHigherOrEqual(MinecraftVersion.V1_13)) { (blockData as? Waterlogged)?.takeIf { it.isWaterlogged }?.let { WATER } ?: EMPTY } else { EMPTY diff --git a/module/bukkit/bukkit-navigation/src/main/kotlin/taboolib/module/navigation/Node.kt b/module/bukkit/bukkit-navigation/src/main/kotlin/taboolib/module/navigation/Node.kt index 177e24e08..b6d2dfbee 100644 --- a/module/bukkit/bukkit-navigation/src/main/kotlin/taboolib/module/navigation/Node.kt +++ b/module/bukkit/bukkit-navigation/src/main/kotlin/taboolib/module/navigation/Node.kt @@ -129,8 +129,44 @@ open class Node(val x: Int, val y: Int, val z: Int) { companion object { + /** y 位宽(12 位,以补码低位表示,可无碰撞覆盖 -2048..2047,足以容纳任何现代世界高度) */ + private const val Y_BITS = 12 + + /** x 位宽(10 位,即 x 每 1024 格循环一次) */ + private const val X_BITS = 10 + + /** y 掩码 */ + private const val Y_MASK = (1 shl Y_BITS) - 1 + + /** x / z 掩码 */ + private const val XZ_MASK = (1 shl X_BITS) - 1 + + /** x 在哈希中的偏移量 */ + private const val X_SHIFT = Y_BITS + + /** z 在哈希中的偏移量 */ + private const val Z_SHIFT = Y_BITS + X_BITS + + /** + * 计算节点坐标哈希。 + * + * 位布局(低位到高位):`[0,12) = y`、`[12,22) = x`、`[22,32) = z`。 + * 三段均取补码低位,因此负坐标与正坐标天然分离,无需额外符号标志位。 + * + * 旧实现存在两类真实碰撞,均已消除: + * 1. y 仅保留 8 位,1.18+ 的 -64..320 共 385 格必然重叠(如 y=-64 与 y=192); + * 2. `x shl 8` 的第 8 位与 `z < 0` 的 `0x8000` 标志位重叠(如 x=128,z=1 与 x=0,z=-32767)。 + * + * 注意:32 位空间无法承载完整的 x/z 坐标范围,本函数仍是哈希而非唯一编码—— + * x 或 z 相差 1024 的整数倍、y 相差 4096 的整数倍时仍会碰撞。 + * 节点表 [NodeReader.nodes] 通过开放寻址探测(比对真实 x/y/z)来消除碰撞后果。 + * + * 兼容性:[hash] 仍为公开的 `Int` 字段,签名与类型均未变化,仅数值分布改变。 + * 该值只用于同一次寻路过程中的临时节点表与 [hashCode],不会被持久化, + * 因此对外部调用方无实质影响。 + */ fun createHash(x: Int, y: Int, z: Int): Int { - return y and 0xFF or (x and 0x7FFF shl 8) or (z and 0x7FFF shl 24) or (if (x < 0) -0x80000000 else 0) or (if (z < 0) 0x8000 else 0) + return (y and Y_MASK) or ((x and XZ_MASK) shl X_SHIFT) or ((z and XZ_MASK) shl Z_SHIFT) } } } \ No newline at end of file diff --git a/module/bukkit/bukkit-navigation/src/main/kotlin/taboolib/module/navigation/NodeReader.kt b/module/bukkit/bukkit-navigation/src/main/kotlin/taboolib/module/navigation/NodeReader.kt index 8f74652ba..e8efa6ab9 100644 --- a/module/bukkit/bukkit-navigation/src/main/kotlin/taboolib/module/navigation/NodeReader.kt +++ b/module/bukkit/bukkit-navigation/src/main/kotlin/taboolib/module/navigation/NodeReader.kt @@ -1,5 +1,6 @@ package taboolib.module.navigation +import org.bukkit.block.Block import org.bukkit.util.NumberConversions import org.bukkit.util.Vector import taboolib.module.navigation.Fluid.Companion.getFluid @@ -17,12 +18,16 @@ open class NodeReader(val entity: NodeEntity) { val nodes = HashMap() val type = HashMap() + // 流体缓存:Block.getFluid() 在 1.13+ 需要走 getBlockData(),而 Bukkit 每次调用都会新建 + // BlockData 对象。纵向扫描会对同一坐标反复求值,故与 type 一样按坐标缓存一次。 + val fluid = HashMap() val typeGetter = PathTypeFactory(entity) val world = entity.location.world!! open fun done() { nodes.clear() type.clear() + fluid.clear() } open fun getGoal(x: Double, y: Double, z: Double): NodeTarget { @@ -47,6 +52,16 @@ open class NodeReader(val entity: NodeEntity) { } } + /** + * 按坐标缓存的流体查询 + * 避免 [Fluid.Companion.getFluid] 在 1.13+ 上反复创建 BlockData 临时对象 + */ + fun getCachedFluid(block: Block): Fluid { + return fluid.computeIfAbsent(Vector(block.x, block.y, block.z).hash()) { + block.getFluid() + } + } + /** * 获取起点 */ @@ -63,13 +78,13 @@ open class NodeReader(val entity: NodeEntity) { var y = entity.location.blockY.coerceIn(minHeight, maxHeight - 1) var block = world.getBlockAt(position.set(entity.location.blockX, y, entity.location.blockZ)) var blockposition: Vector - if (!entity.canStandOnFluid(block.getFluid())) { + if (!entity.canStandOnFluid(getCachedFluid(block))) { if (entity.canFloat && entity.isInWater()) { - while (block.getFluid().isWater() && y < maxHeight - 1) { + while (getCachedFluid(block).isWater() && y < maxHeight - 1) { ++y block = world.getBlockAt(position.set(entity.location.blockX, y, entity.location.blockZ)) } - if (!block.getFluid().isWater()) { + if (!getCachedFluid(block).isWater()) { --y } } else if (entity.isOnGround()) { @@ -83,14 +98,21 @@ open class NodeReader(val entity: NodeEntity) { blockposition = blockposition.down() ground = blockposition.toBlock(block.world) } - y = if (ground.type.isSolid) blockposition.up().blockY.coerceAtMost(maxHeight - 1) else minHeight + y = if (ground.type.isSolid) { + blockposition.up().blockY.coerceAtMost(maxHeight - 1) + } else { + // 整柱都不是实心方块(虚空 / 全空区块)时,回落到世界底部并不合理: + // minHeight 通常是基岩或虚空,把它当作起点会让 A* 从一个不可达的位置展开。 + // 此处保留实体当前所在高度,交由后续的 costMalus 判定去决定该节点是否可用。 + entity.location.blockY.coerceIn(minHeight, maxHeight - 1) + } } } else { - while (entity.canStandOnFluid(block.getFluid()) && y < maxHeight - 1) { + while (entity.canStandOnFluid(getCachedFluid(block)) && y < maxHeight - 1) { ++y block = world.getBlockAt(position.set(entity.location.blockX, y, entity.location.blockZ)) } - if (!entity.canStandOnFluid(block.getFluid())) { + if (!entity.canStandOnFluid(getCachedFluid(block))) { --y } } @@ -323,11 +345,24 @@ open class NodeReader(val entity: NodeEntity) { } } +/** + * 从节点表中取出或创建指定坐标的节点。 + * + * [Node.createHash] 已修正为 y 12 位 + x/z 各 10 位的布局,消除了旧实现在 + * 现代世界高度与符号标志位上的两类必然碰撞。但 32 位空间仍无法唯一编码 + * 完整的 x/z 坐标范围(x 或 z 相差 1024 的整数倍时哈希相同),因此这里保留 + * 开放寻址探测作为防御:命中已占用槽位时比对真实坐标,不一致则线性向后探测。 + * + * 探测步长为 1(线性探测),在 int 空间上的周期是完整的 2^32; + * 又因为表中至多有 `nodes.size` 个已占用槽位,最多探测 `nodes.size + 1` 次 + * 必定命中空槽,故用该值作为上界快速失败,而非空转 2^32 次。 + */ @JvmSynthetic internal fun getOrCreateNavigationNode(nodes: MutableMap, x: Int, y: Int, z: Int): Node { - val initialKey = Node.createHash(x, y, z) - var key = initialKey - while (true) { + var key = Node.createHash(x, y, z) + // 线性探测最多 size + 1 次必定遇到空槽,超出即说明表状态被外部破坏 + var remaining = nodes.size + 1 + while (remaining-- > 0) { val existing = nodes[key] if (existing == null) { return Node(x, y, z).also { nodes[key] = it } @@ -335,7 +370,7 @@ internal fun getOrCreateNavigationNode(nodes: MutableMap, x: Int, y: if (existing.x == x && existing.y == y && existing.z == z) { return existing } - key = key * 31 + 1 - check(key != initialKey) { "Unable to resolve navigation node hash collision" } + key++ } + error("Unable to resolve navigation node hash collision at x=$x, y=$y, z=$z (size=${nodes.size})") } diff --git a/module/bukkit/bukkit-navigation/src/main/kotlin/taboolib/module/navigation/PathSmoothing.kt b/module/bukkit/bukkit-navigation/src/main/kotlin/taboolib/module/navigation/PathSmoothing.kt index cf8f22ccc..1cb9800b4 100644 --- a/module/bukkit/bukkit-navigation/src/main/kotlin/taboolib/module/navigation/PathSmoothing.kt +++ b/module/bukkit/bukkit-navigation/src/main/kotlin/taboolib/module/navigation/PathSmoothing.kt @@ -9,6 +9,7 @@ import kotlin.math.ceil import kotlin.math.floor import kotlin.math.max import kotlin.math.min +import kotlin.math.sqrt /** * 路径平滑后处理(String Pulling / 拉绳法) @@ -71,11 +72,28 @@ object PathSmoothing { val dx = to.x - from.x val dz = to.z - from.z if (abs(dx) < 1.0E-6 && abs(dz) < 1.0E-6) return true + // 分别收集 x / z 方向的跨格 t 值,便于识别对角"穿角" + val xBoundaries = sortedSetOf() + val zBoundaries = sortedSetOf() + addSweepBoundaries(from.x - entity.width / 2.0, dx, xBoundaries) + addSweepBoundaries(from.x + entity.width / 2.0, dx, xBoundaries) + addSweepBoundaries(from.z - entity.depth / 2.0, dz, zBoundaries) + addSweepBoundaries(from.z + entity.depth / 2.0, dz, zBoundaries) val boundaries = sortedSetOf(0.0, 1.0) - addSweepBoundaries(from.x - entity.width / 2.0, dx, boundaries) - addSweepBoundaries(from.x + entity.width / 2.0, dx, boundaries) - addSweepBoundaries(from.z - entity.depth / 2.0, dz, boundaries) - addSweepBoundaries(from.z + entity.depth / 2.0, dz, boundaries) + boundaries += xBoundaries + boundaries += zBoundaries + // 对角移动时 x 边界与 z 边界可能落在同一个 t 上(实体正好从两个方块的公共顶点穿过), + // 去重后相邻中点采样会落在格子内部,漏掉经典的"穿角"对角穿墙。 + // 故对重合的 t 额外在两侧各取一个采样点,把顶点前后的两个格子都覆盖到。 + val cornerOffset = cornerOffset(dx, dz) + xBoundaries.forEach { t -> + if (zBoundaries.any { abs(it - t) < COINCIDENT_TOLERANCE }) { + val before = t - cornerOffset + val after = t + cornerOffset + if (before > 0.0) boundaries += before + if (after < 1.0) boundaries += after + } + } val samples = boundaries.toList() for (index in samples.indices) { val t = samples[index] @@ -88,6 +106,16 @@ object PathSmoothing { return true } + /** + * 计算"穿角"额外采样点在参数空间上的偏移量 + * 保证实际偏移的世界距离恒为 [CORNER_SAMPLE_DISTANCE],不随线段长度放大 + */ + private fun cornerOffset(dx: Double, dz: Double): Double { + val length = sqrt(dx * dx + dz * dz) + if (length < 1.0E-6) return 0.0 + return (CORNER_SAMPLE_DISTANCE / length).coerceAtMost(0.25) + } + private fun addSweepBoundaries(start: Double, delta: Double, boundaries: MutableSet) { if (abs(delta) < 1.0E-6) return val end = start + delta @@ -129,8 +157,7 @@ object PathSmoothing { for (bx in minBx..maxBx) { for (bz in minBz..maxBz) { val below = world.getBlockAtIfLoaded(Vector(bx, by - 1, bz)) ?: return false - val supportY = below.y + NMS.instance.getBlockHeight(below) - if (abs(supportY - y) > 1.0E-3) { + if (!isSupportedAtHeight(below.y + NMS.instance.getBlockHeight(below), y)) { return false } val feetType = typeFactory.getTypeAsWalkable(world, Vector(bx, by, bz)) @@ -148,6 +175,28 @@ object PathSmoothing { return true } + /** + * 判断支撑方块的顶面高度 [supportY] 是否足以承载脚部位于 [feetY] 的实体 + * + * 早先的实现用 `abs(supportY - feetY) > 1e-3` 做精确相等判定, + * 而 [feetY] 来自 `nodeCenter(node)` 恒为整数,[NMS.getBlockHeight] 对 + * 半砖返回 0.5、农田 / 雪层返回 0.9375、空气与非实心方块返回 0.0—— + * 结果只有满方块(高度恰为 1.0)能通过,半砖 / 农田 / 雪层全部被判为不可站立, + * 平滑因此退化为几乎不删节点的无操作。 + * + * 现改为区间判定,记 `delta = supportY - feetY`: + * - `delta > -1.0`:顶面必须高于脚下方块的底面,即脚下方块确有高度。 + * 空气与非实心方块的高度为 0,恰好落在 `delta == -1.0` 上被排除; + * 半砖(-0.5)、农田 / 雪层(-0.0625)等非满方块则被正确接受。 + * - `delta <= `[SUPPORT_UPPER_TOLERANCE]:顶面不得显著高于脚部, + * 否则意味着实体被埋在方块里。栅栏一类高于 1 格的支撑仍在容差内。 + */ + @JvmSynthetic + internal fun isSupportedAtHeight(supportY: Double, feetY: Double): Boolean { + val delta = supportY - feetY + return delta > -1.0 + HEIGHT_EPSILON && delta <= SUPPORT_UPPER_TOLERANCE + HEIGHT_EPSILON + } + @JvmSynthetic internal fun isSafeSmoothingFeetType(pathType: PathType, malus: Float): Boolean { return pathType != PathType.OPEN && malus == 0.0f @@ -161,4 +210,16 @@ object PathSmoothing { private fun nodeCenter(node: Node): Vector { return Vector(node.x + 0.5, node.y.toDouble(), node.z + 0.5) } + + /** 允许的向上容差:顶面略高于脚部(如栅栏、贴着台阶边缘)仍可接受 */ + private const val SUPPORT_UPPER_TOLERANCE = 0.5 + + /** 高度比较的浮点容差 */ + private const val HEIGHT_EPSILON = 1.0E-3 + + /** 判定两个跨格 t 值是否重合的容差 */ + private const val COINCIDENT_TOLERANCE = 1.0E-9 + + /** "穿角"额外采样点距离顶点的世界距离 */ + private const val CORNER_SAMPLE_DISTANCE = 1.0E-3 } diff --git a/module/bukkit/bukkit-navigation/src/test/kotlin/taboolib/module/navigation/NavigationCorrectnessTest.kt b/module/bukkit/bukkit-navigation/src/test/kotlin/taboolib/module/navigation/NavigationCorrectnessTest.kt index 62e10cc5e..63f7f9bda 100644 --- a/module/bukkit/bukkit-navigation/src/test/kotlin/taboolib/module/navigation/NavigationCorrectnessTest.kt +++ b/module/bukkit/bukkit-navigation/src/test/kotlin/taboolib/module/navigation/NavigationCorrectnessTest.kt @@ -2,6 +2,7 @@ package taboolib.module.navigation import org.junit.jupiter.api.Assertions.assertEquals import org.junit.jupiter.api.Assertions.assertFalse +import org.junit.jupiter.api.Assertions.assertNotEquals import org.junit.jupiter.api.Assertions.assertNotSame import org.junit.jupiter.api.Assertions.assertSame import org.junit.jupiter.api.Assertions.assertTrue @@ -18,9 +19,10 @@ class NavigationCorrectnessTest { } @Test - fun `node cache resolves legacy hash collisions across modern world heights`() { + fun `node cache keeps distinct nodes across modern world heights`() { val nodes = HashMap() - assertEquals(Node.createHash(4, -64, 8), Node.createHash(4, 192, 8)) + // 旧哈希 y 只有 8 位,-64 与 192 必然碰撞;新哈希 y 为 12 位,两者不再相同 + assertNotEquals(Node.createHash(4, -64, 8), Node.createHash(4, 192, 8)) val low = getOrCreateNavigationNode(nodes, 4, -64, 8) val high = getOrCreateNavigationNode(nodes, 4, 192, 8) @@ -32,6 +34,22 @@ class NavigationCorrectnessTest { assertSame(high, getOrCreateNavigationNode(nodes, 4, 192, 8)) } + @Test + fun `node cache resolves residual hash collisions by open addressing`() { + val nodes = HashMap() + // x 仅编码 10 位,x 与 x+1024 哈希相同,靠开放寻址区分 + assertEquals(Node.createHash(0, 64, 0), Node.createHash(1024, 64, 0)) + + val first = getOrCreateNavigationNode(nodes, 0, 64, 0) + val second = getOrCreateNavigationNode(nodes, 1024, 64, 0) + + assertNotSame(first, second) + assertEquals(0, first.x) + assertEquals(1024, second.x) + assertSame(first, getOrCreateNavigationNode(nodes, 0, 64, 0)) + assertSame(second, getOrCreateNavigationNode(nodes, 1024, 64, 0)) + } + @Test fun `surface selection only rejects water when water is disabled`() { assertTrue(RandomPositionGenerator.acceptsNavigationSurface(false, false)) @@ -64,4 +82,17 @@ class NavigationCorrectnessTest { assertFalse(PathSmoothing.isSafeSmoothingBodyType(PathType.DAMAGE_FIRE.malus)) assertFalse(PathSmoothing.isSafeSmoothingBodyType(PathType.BLOCKED.malus)) } + + @Test + fun `path smoothing accepts partial blocks as support`() { + // 满方块:顶面恰好等于脚部高度 + assertTrue(PathSmoothing.isSupportedAtHeight(64.0, 64.0)) + // 半砖(0.5)、农田 / 雪层(0.9375):顶面略低于脚部,仍应视为可站立 + assertTrue(PathSmoothing.isSupportedAtHeight(63.5, 64.0)) + assertTrue(PathSmoothing.isSupportedAtHeight(63.9375, 64.0)) + // 空气 / 非实心方块(getBlockHeight 返回 0.0):顶面等于脚下方块底面,无支撑 + assertFalse(PathSmoothing.isSupportedAtHeight(63.0, 64.0)) + // 顶面显著高于脚部:实体被埋在方块里 + assertFalse(PathSmoothing.isSupportedAtHeight(65.0, 64.0)) + } } diff --git a/module/bukkit/bukkit-navigation/src/test/kotlin/taboolib/module/navigation/NodeTest.kt b/module/bukkit/bukkit-navigation/src/test/kotlin/taboolib/module/navigation/NodeTest.kt index 7dbff10af..6943da92f 100644 --- a/module/bukkit/bukkit-navigation/src/test/kotlin/taboolib/module/navigation/NodeTest.kt +++ b/module/bukkit/bukkit-navigation/src/test/kotlin/taboolib/module/navigation/NodeTest.kt @@ -17,16 +17,25 @@ class NodeTest { } @Test - fun `createHash negative flags`() { + fun `createHash negative coordinates stay distinct`() { val positiveHash = Node.createHash(1, 0, 1) val negXHash = Node.createHash(-1, 0, 1) val negZHash = Node.createHash(1, 0, -1) - // 负 x 设置最高位 - assertEquals(0, positiveHash and Int.MIN_VALUE) - assertNotEquals(0, negXHash and Int.MIN_VALUE) - // 负 z 设置 0x8000 位 - assertEquals(0, positiveHash and 0x8000) - assertNotEquals(0, negZHash and 0x8000) + val negXZHash = Node.createHash(-1, 0, -1) + // 负坐标以补码低位参与编码,与对应的正坐标天然分离 + assertNotEquals(positiveHash, negXHash) + assertNotEquals(positiveHash, negZHash) + assertNotEquals(positiveHash, negXZHash) + assertNotEquals(negXHash, negZHash) + } + + @Test + fun `createHash no collision across modern world height`() { + val hashes = mutableSetOf() + // 1.18+ 世界高度 -64..319,同一 (x,z) 柱上不允许出现碰撞 + for (y in -64..319) { + assertTrue(hashes.add(Node.createHash(4, y, 8)), "Hash 碰撞: y=$y") + } } @Test @@ -40,6 +49,12 @@ class NodeTest { } } + @Test + fun `createHash x bit does not overlap negative z`() { + // 旧实现中 x 的第 8 位与 z<0 标志位重叠,导致 (128, 5, 1) 与 (0, 5, -32767) 碰撞 + assertNotEquals(Node.createHash(128, 5, 1), Node.createHash(0, 5, -32767)) + } + @Test fun `distanceTo euclidean`() { val a = Node(0, 0, 0) diff --git a/module/minecraft/minecraft-i18n/src/main/java/taboolib/module/lang/SnapshotHashMap.java b/module/minecraft/minecraft-i18n/src/main/java/taboolib/module/lang/SnapshotHashMap.java index 17dd8f3ed..39fb64a3c 100644 --- a/module/minecraft/minecraft-i18n/src/main/java/taboolib/module/lang/SnapshotHashMap.java +++ b/module/minecraft/minecraft-i18n/src/main/java/taboolib/module/lang/SnapshotHashMap.java @@ -14,7 +14,23 @@ import java.util.function.Function; /** - * 保持 HashMap API 的无锁快照映射,读取固定快照,写入通过 CAS 一次替换。 + * 保持 HashMap API 的无锁快照映射,读取固定快照,写入基于快照复制后整体替换引用。 + * + *

之所以继承 {@link HashMap} 而非实现 {@link Map},是因为 {@code Language.languageFile} 与 + * {@code LanguageFile.nodes} 的公开声明类型就是 {@code HashMap},改成接口会破坏 ABI 兼容性。 + * 代价是本类的所有状态都存放在 {@link #snapshot} 中,父类 HashMap 自身的桶数组永远是空的, + * 由此带来两点必须注意的限制: + * + *

    + *
  1. 不支持序列化还原。{@code HashMap.writeObject} 是 {@code private} 的,无法覆盖, + * 它只会遍历父类自己(永远为空)的桶数组。为避免静默写出空 map,本类通过 + * {@code writeReplace()} 改为写出快照副本;但反序列化得到的是普通 {@code HashMap}, + * 快照语义不会被还原。请勿依赖本类的序列化往返。
  2. + *
  3. JDK 升级时需复查。本类采用"逐方法代理"的方式覆盖了 {@code Map} / {@code HashMap} + * 的全部读写入口。若后续 JDK 为 {@code Map} 或 {@code HashMap} 新增了默认方法或实例方法 + * 而未在此同步覆盖,该方法会读到空的父类状态并返回错误结果。 + * 升级 JDK 基线时请对照新版 API 列表复查一遍覆盖完整性。
  4. + *
*/ final class SnapshotHashMap extends HashMap { @@ -29,6 +45,13 @@ final class SnapshotHashMap extends HashMap { snapshot = new AtomicReference<>(new HashMap<>(source)); } + /** + * 用 {@code source} 的副本整体替换当前快照。 + * + *

这里用的是原子引用替换({@code set})而非 CAS:整体替换不依赖旧值, + * 无需比较,因此不存在需要重试的写冲突。并发读取要么看到完整的旧快照, + * 要么看到完整的新快照,不会读到 {@code clear() + putAll()} 那样的中间态。 + */ void replaceWith(Map source) { snapshot.set(new HashMap<>(source)); } @@ -332,11 +355,28 @@ public void clear() { snapshot.set(new HashMap<>()); } + /** + * 返回当前快照的普通 {@code HashMap} 副本。 + * + *

返回值不再具备快照语义,与本实例互相独立。 + */ @Override public Object clone() { return new HashMap<>(snapshot.get()); } + /** + * 序列化替身:写出普通 {@code HashMap} 副本。 + * + *

父类的 {@code writeObject} 是 {@code private} 的,无法覆盖, + * 直接序列化本类只会写出永远为空的父类桶数组。此处改写为快照副本, + * 使序列化结果至少携带真实数据;但反序列化得到的是 {@code HashMap} + * 而非 {@code SnapshotHashMap},快照语义不会被还原。 + */ + private Object writeReplace() { + return new HashMap<>(snapshot.get()); + } + @Override public boolean equals(Object other) { return snapshot.get().equals(other); diff --git a/module/minecraft/minecraft-i18n/src/main/kotlin/taboolib/module/lang/TypeJson.kt b/module/minecraft/minecraft-i18n/src/main/kotlin/taboolib/module/lang/TypeJson.kt index c52be91f1..ef2e72796 100644 --- a/module/minecraft/minecraft-i18n/src/main/kotlin/taboolib/module/lang/TypeJson.kt +++ b/module/minecraft/minecraft-i18n/src/main/kotlin/taboolib/module/lang/TypeJson.kt @@ -71,13 +71,16 @@ class TypeJson : Type { // - type: translate:1:Stone typeName == "translate" -> appendTranslation(showText, *typeArgs.toTypedArray()) // 分数 - showType == "score" -> appendScore(showText.substringBefore(':'), showText.substringAfter(':')) + // args: + // - type: score + typeName == "score" -> appendScore(showText.substringBefore(':'), showText.substringAfter(':')) // 渐变颜色文本 // text: 'Woo: [||||||||||||||||||||||||]' // args: // - type: gradient:#ff0000:#00ff00:#0000ff:#ff0000 - showType.startsWith("gradient") -> { - append(showText.toGradientColor(showType.substringAfter(':').split(':').map { it.parseToHexColor() })) + // 至少需要两个颜色才能构成渐变,参数不足时退回普通着色 + typeName == "gradient" && typeArgs.size >= 2 -> { + append(showText.toGradientColor(typeArgs.map { it.parseToHexColor() })) } // 标准 else -> append(showText.colored()) From 9f612946e6b85c4a98b0d225b2c1eeab63346813 Mon Sep 17 00:00:00 2001 From: FxRayHughes Date: Sun, 26 Jul 2026 22:38:16 +0800 Subject: [PATCH 31/37] =?UTF-8?q?fix(review):=20=E5=AE=8C=E5=96=84?= =?UTF-8?q?=E6=95=B0=E6=8D=AE=E5=BA=93=E5=86=99=E5=85=A5=E4=B8=80=E8=87=B4?= =?UTF-8?q?=E6=80=A7=E3=80=81=E8=B5=84=E6=BA=90=E7=94=9F=E5=91=BD=E5=91=A8?= =?UTF-8?q?=E6=9C=9F=E4=B8=8E=E6=96=B9=E8=A8=80=E5=85=BC=E5=AE=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 依据 #709 / #710 / #711 评审意见修正: 1. setDelayed 延迟窗口内的写入会永久丢失(#710-1,数据丢失) 延迟第一次真正生效后,容器释放与关服都不 flush。 DataContainer 新增 flush():把 deadline 未到的项立即置为可写并在当前线程 同步排空(不经 asyncExecutor——关服阶段调度器可能已拒绝任务)。 三个 release 入口改为 remove(...)?.flush(),用 remove 返回值避免并发重复 flush; 伴生对象新增 @Awake(LifeCycle.DISABLE) 全量 flush。 2. checkUpdate 主线程 O(全部键) 遍历且 writeStates 只增不减(#710-5) 新增 deadlineKeys 集合,checkUpdate 只遍历它;drainWrites 收尾时回收空闲状态。 状态回收引入「已回收状态被继续写入」的窗口,故加 discarded 标记与 withState 辅助函数,在锁内校验状态仍是映射中的当前实例,否则重取重试。 3. forcedSet(sync = true) 重复写库(#710-4) 原先改内存的同时还排一次异步写库,同一值写两遍,关服阶段还可能抛 RejectedExecutionException。新增只改缓存的内部方法供其调用。 4. removeDuplicateRows 无重试导致加载失败(#710-7) 移入 try 内与 createUniqueIndex 共用重试;同时修正「重复数据已清零就直接抛」 的判定——归并本身失败时会被误判为不可重试。migrateSQLite 同样补上重试。 5. upsertGeneric 在值未变化时误抛(#710-10) MySQL 下 UPDATE 写入相同值 affected rows 为 0,会被判为失败。 改为约束冲突后以 get(user, key) != null 判定成功。 6. Lettuce pub/sub 未初始化时的错误信息(#709-3) pubSubConnection 改异步连接后,fire-and-forget 调用 start() 后立即使用 会撞 UninitializedPropertyAccessException。三个访问点统一前置检查, 抛出明确提示(等待 start() 返回的 future 或改用 startSync())。 7. RedisDatabaseHandler 的 AutoCloseable 未接线(#709-6) 实现了 close() 但框架内无处调用。仿同模块既有注册表登记实例, @Awake(LifeCycle.DISABLE) 统一释放,close() 保持幂等。 8. Redis 配置层级修正的升级兼容(#709-1) 本 PR 把 enable/table 的读取层级统一到 Database 子节点,但老配置写在根节点, 升级后会读到默认值、切换数据源、看到空数据。 新增回退读取:用 contains 区分「键不存在」与「显式为 false」,缺失时回退根节点 并打印一次弃用警告。注意 enable 回退时建库用的 section 必须整体跟着回退—— 否则 HostSQL 会对 host/user/password 全部落默认值,连到 localhost:3306 的 root/root,比不回退更糟。 9. SQLite upsert 收口到 ON CONFLICT DO UPDATE(#711-1 / #711-10) 省略冲突目标的写法要求 SQLite >= 3.35.0,而 TabooLib 不声明 sqlite-jdbc 运行时依赖、版本不可控,失败表现为用户服务器上的裸 SQLSyntaxError。 改为要求显式传入冲突字段,门槛降至 3.24.0,错误信息直接给出正确写法。 database-player 的 SQLite 分支同步从手写 INSERT OR REPLACE 改用该路径—— 前者是「删旧行再插新行」,会重置用户手工添加的额外列并推进 autoincrement。 10. junit 版本与根构建对齐(#710-14) database-player 的 junit-jupiter 由 5.10.2 降至 5.8.1。 --- .../expansion/LettuceClusterRedisClient.kt | 28 ++- .../taboolib/expansion/LettuceRedisClient.kt | 28 ++- .../expansion/RedisDatabaseHandler.kt | 109 +++++++++++- .../database/database-player/build.gradle.kts | 2 +- .../taboolib/expansion/DataContainer.kt | 160 +++++++++++++++++- .../kotlin/taboolib/expansion/Database.kt | 87 ++++++---- .../taboolib/expansion/DatabaseHandler.kt | 8 +- .../taboolib/expansion/PlayerDatabase.kt | 8 +- .../PlayerDatabaseConsistencyTest.kt | 103 +++++++++++ .../taboolib/module/database/ActionInsert.kt | 26 ++- .../database/ActionInsertDialectTest.kt | 19 ++- 11 files changed, 513 insertions(+), 65 deletions(-) diff --git a/module/database/database-lettuce-redis/src/main/kotlin/taboolib/expansion/LettuceClusterRedisClient.kt b/module/database/database-lettuce-redis/src/main/kotlin/taboolib/expansion/LettuceClusterRedisClient.kt index 20cdb936a..3c2103883 100644 --- a/module/database/database-lettuce-redis/src/main/kotlin/taboolib/expansion/LettuceClusterRedisClient.kt +++ b/module/database/database-lettuce-redis/src/main/kotlin/taboolib/expansion/LettuceClusterRedisClient.kt @@ -404,15 +404,37 @@ class LettuceClusterRedisClient(val redisConfig: LettuceRedisConfig): IRedisClie } override fun useClusterPubSubCommands(block: (RedisClusterPubSubCommands) -> T): T? { - return block(pubSubConnection.sync()) + return block(requirePubSubConnection().sync()) } override fun useClusterPubSubAsyncCommands(block: (RedisClusterPubSubAsyncCommands) -> T): T? { - return block(pubSubConnection.async()) + return block(requirePubSubConnection().async()) } override fun useClusterPubSubReactiveCommands(block: (RedisClusterPubSubReactiveCommands) -> T): T? { - return block(pubSubConnection.reactive()) + return block(requirePubSubConnection().reactive()) + } + + /** + * 获取 pub/sub 连接,未就绪时给出明确错误。 + * + * [start] 中的 pub/sub 连接是异步建立的,调用方必须等待 [start] 返回的 future 完成, + * 否则这里会以清晰的错误信息失败,而不是抛出难以定位的 UninitializedPropertyAccessException。 + */ + private fun requirePubSubConnection(): StatefulRedisClusterPubSubConnection { + check(!stopped.get()) { + """ + Redis 集群客户端已停止,无法使用 pub/sub 连接。 + Redis cluster client is stopped, pub/sub connection is unavailable. + """.t() + } + check(::pubSubConnection.isInitialized) { + """ + pub/sub 连接尚未就绪,请先等待 start() 返回的 CompletableFuture 完成,或改用 startSync()。 + The pub/sub connection is not ready yet, await the CompletableFuture returned by start() or use startSync() instead. + """.t() + } + return pubSubConnection } // sync diff --git a/module/database/database-lettuce-redis/src/main/kotlin/taboolib/expansion/LettuceRedisClient.kt b/module/database/database-lettuce-redis/src/main/kotlin/taboolib/expansion/LettuceRedisClient.kt index 01c066e59..b0ff4959b 100644 --- a/module/database/database-lettuce-redis/src/main/kotlin/taboolib/expansion/LettuceRedisClient.kt +++ b/module/database/database-lettuce-redis/src/main/kotlin/taboolib/expansion/LettuceRedisClient.kt @@ -392,15 +392,37 @@ class LettuceRedisClient(val redisConfig: LettuceRedisConfig): IRedisClient, IRe } override fun usePubSubCommands(block: (RedisPubSubCommands) -> T): T? { - return block(pubSubConnection.sync()) + return block(requirePubSubConnection().sync()) } override fun usePubSubAsyncCommands(block: (RedisPubSubAsyncCommands) -> T): T? { - return block(pubSubConnection.async()) + return block(requirePubSubConnection().async()) } override fun usePubSubReactiveCommands(block: (RedisPubSubReactiveCommands) -> T): T? { - return block(pubSubConnection.reactive()) + return block(requirePubSubConnection().reactive()) + } + + /** + * 获取 pub/sub 连接,未就绪时给出明确错误。 + * + * [start] 中的 pub/sub 连接是异步建立的,调用方必须等待 [start] 返回的 future 完成, + * 否则这里会以清晰的错误信息失败,而不是抛出难以定位的 UninitializedPropertyAccessException。 + */ + private fun requirePubSubConnection(): StatefulRedisPubSubConnection { + check(!stopped.get()) { + """ + Redis 客户端已停止,无法使用 pub/sub 连接。 + Redis client is stopped, pub/sub connection is unavailable. + """.t() + } + check(::pubSubConnection.isInitialized) { + """ + pub/sub 连接尚未就绪,请先等待 start() 返回的 CompletableFuture 完成,或改用 startSync()。 + The pub/sub connection is not ready yet, await the CompletableFuture returned by start() or use startSync() instead. + """.t() + } + return pubSubConnection } // sync diff --git a/module/database/database-player-redis/src/main/kotlin/taboolib/expansion/RedisDatabaseHandler.kt b/module/database/database-player-redis/src/main/kotlin/taboolib/expansion/RedisDatabaseHandler.kt index bd66e7148..c2ff3f51e 100644 --- a/module/database/database-player-redis/src/main/kotlin/taboolib/expansion/RedisDatabaseHandler.kt +++ b/module/database/database-player-redis/src/main/kotlin/taboolib/expansion/RedisDatabaseHandler.kt @@ -1,6 +1,10 @@ package taboolib.expansion +import taboolib.common.Inject +import taboolib.common.LifeCycle +import taboolib.common.PrimitiveIO import taboolib.common.io.newFile +import taboolib.common.platform.Awake import taboolib.common.platform.function.getDataFolder import taboolib.common.platform.function.pluginId import taboolib.library.configuration.ConfigurationSection @@ -52,9 +56,17 @@ class RedisDatabaseHandler( init { val databaseConfig = conf.getConfigurationSection("Database")!! - table = databaseConfig.getString("table", table.ifEmpty { pluginId })!! - database = if (databaseConfig.getBoolean("enable")) { - buildPlayerDatabase(databaseConfig, table, flags, clearFlags, ssl) + // 记录本次实际回退到旧位置(配置根节点)的键,用于统一打印一次弃用警告 + val legacyPaths = ArrayList() + // table 缺失时回退读取根节点,均无则沿用构造参数,最后才落到 pluginId + val tableSection = resolveSection(databaseConfig, "table", legacyPaths) + table = tableSection?.getString("table")?.takeIf { it.isNotEmpty() } ?: table.ifEmpty { pluginId } + // enable 缺失时回退读取根节点;由于旧配置的连接参数同样写在根节点, + // 因此建库时必须复用「提供 enable 的那个节点」,否则 host/user 等会全部落到默认值 + val enableSection = resolveSection(databaseConfig, "enable", legacyPaths) + warnLegacyConfig(legacyPaths) + database = if (enableSection != null && enableSection.getBoolean("enable")) { + buildPlayerDatabase(enableSection, table, flags, clearFlags, ssl) } else { buildPlayerDatabase(newFile(getDataFolder(), dataFile), table) } @@ -70,6 +82,54 @@ class RedisDatabaseHandler( database.close() throw ex } + // 登记到 DISABLE 生命周期,插件关闭时自动释放,参考 AlkaidRedis / LettuceRedis 的做法 + register(this) + } + + /** + * 解析某个键实际所在的配置节点。 + * + * 历史版本的实现存在层级错乱:`table` 从 `Database` 子节点读取,`enable` 却从配置根节点读取。 + * 因此为了让 MySQL 真正生效,部分用户已经把 `enable` 连同 `host`、`user` 等一并写在了配置根节点。 + * 这里优先读取新层级(`Database` 子节点),缺失时回退到旧位置(配置根节点), + * 并把发生回退的键登记进 [legacyPaths] 以便打印弃用警告。 + * + * 注意必须用 [ConfigurationSection.contains] 判断而非取值判空: + * `getBoolean` 对不存在的键同样返回 `false`,无法区分「键不存在」与「键存在且显式为 false」。 + * + * 这是升级过渡期的兼容逻辑,待用户完成配置迁移后可整体移除。 + */ + private fun resolveSection( + databaseConfig: ConfigurationSection, + path: String, + legacyPaths: MutableList, + ): ConfigurationSection? { + if (databaseConfig.contains(path)) { + return databaseConfig + } + if (conf.contains(path)) { + legacyPaths += path + return conf + } + return null + } + + /** + * 打印一次配置弃用警告,提示用户把根节点的键迁移到 `Database` 节点下。 + * + * 同样属于过渡期逻辑,与 [resolveSection] 一并移除。 + */ + private fun warnLegacyConfig(legacyPaths: List) { + if (legacyPaths.isEmpty()) { + return + } + PrimitiveIO.warning( + "Deprecated player redis database configuration detected: {0}. " + + "These options are now read from the \"Database\" section, " + + "please move them under it (e.g. \"Database.enable\"). " + + "The fallback to the root section will be removed in a future release.", + legacyPaths.joinToString(", ") + ) } /** @@ -101,6 +161,7 @@ class RedisDatabaseHandler( if (!closed.compareAndSet(false, true)) { return } + unregister(this) redisDataContainer.clear() val currentConnection = connection val currentConnector = connector @@ -127,4 +188,46 @@ class RedisDatabaseHandler( failure?.let { throw it } } + /** + * 生命周期登记表。 + * + * [RedisDatabaseHandler] 实现了 [AutoCloseable],但插件通常不会自行调用, + * 因此在构造时登记,由 DISABLE 阶段统一释放。 + */ + @Inject + companion object { + + private val handlers = ConcurrentHashMap.newKeySet() + private val shutdown = AtomicBoolean(false) + + internal fun register(handler: RedisDatabaseHandler) { + if (shutdown.get()) { + runCatching { handler.close() } + return + } + handlers += handler + // 登记与关闭并发时,二次检查确保迟到的处理器同样被回收 + if (shutdown.get() && handlers.remove(handler)) { + runCatching { handler.close() } + } + } + + internal fun unregister(handler: RedisDatabaseHandler) { + handlers.remove(handler) + } + + @Awake(LifeCycle.DISABLE) + internal fun closeAll() { + if (!shutdown.compareAndSet(false, true)) { + return + } + handlers.toList().forEach { handler -> + if (handlers.remove(handler)) { + runCatching { handler.close() }.exceptionOrNull()?.let { + PrimitiveIO.warning("Failed to close redis database handler {0}: {1}", handler.table, it.toString()) + } + } + } + } + } } diff --git a/module/database/database-player/build.gradle.kts b/module/database/database-player/build.gradle.kts index 6d6df4b69..444ab0c59 100644 --- a/module/database/database-player/build.gradle.kts +++ b/module/database/database-player/build.gradle.kts @@ -12,6 +12,6 @@ dependencies { testImplementation(project(":module:database")) testImplementation(project(":module:basic:basic-configuration")) testImplementation("com.zaxxer:HikariCP:4.0.3") - testImplementation("org.junit.jupiter:junit-jupiter:5.10.2") + testImplementation("org.junit.jupiter:junit-jupiter:5.8.1") testImplementation("org.xerial:sqlite-jdbc:3.42.0.0") } \ No newline at end of file diff --git a/module/database/database-player/src/main/kotlin/taboolib/expansion/DataContainer.kt b/module/database/database-player/src/main/kotlin/taboolib/expansion/DataContainer.kt index b4fa62560..6f837b1a8 100644 --- a/module/database/database-player/src/main/kotlin/taboolib/expansion/DataContainer.kt +++ b/module/database/database-player/src/main/kotlin/taboolib/expansion/DataContainer.kt @@ -1,6 +1,9 @@ package taboolib.expansion import taboolib.common.Inject +import taboolib.common.LifeCycle +import taboolib.common.PrimitiveIO +import taboolib.common.platform.Awake import taboolib.common.platform.Schedule import taboolib.common.platform.function.submitAsync import java.util.UUID @@ -25,6 +28,14 @@ class DataContainer(val user: String, val database: Database) { private val writeStates = ConcurrentHashMap() + /** + * 当前存在延迟期限的键集合。 + * + * [checkUpdate] 每 tick 在主线程执行,只遍历该集合即可, + * 避免随着容器写入的键增多而退化为 O(全部键) 的同步扫描。 + */ + private val deadlineKeys = ConcurrentHashMap.newKeySet() + internal var asyncExecutor: ((() -> Unit) -> Unit) = { task -> submitAsync { task() } } @@ -53,11 +64,35 @@ class DataContainer(val user: String, val database: Database) { database[targetUser, key] = stringValue if (sync) { runCatching { UUID.fromString(targetUser) }.getOrNull()?.let { uniqueId -> - playerDataContainer[uniqueId]?.set(key, stringValue) + // 数据库已在上一行写过,这里只同步内存缓存,避免重复排程一次写库 + playerDataContainer[uniqueId]?.setCacheOnly(key, stringValue) } } } + /** + * 仅同步内存缓存,不排程写库。 + * + * 用于调用方已自行完成数据库写入的场景(例如 [forcedSet]), + * 避免同一值写两遍,也避免在关服阶段因调度器拒绝任务而抛出异常。 + * + * @param key 键 + * @param value 值,为空字符串时表示移除缓存 + */ + internal fun setCacheOnly(key: String, value: String) { + withState(key) { state -> + val newValue = value.takeUnless { it.isEmpty() } + if (newValue == null) { + source.remove(key) + } else { + source[key] = newValue + } + // 同步待写值并提升版本号,使已在队列中的旧快照不会把过期数据写回数据库 + state.revision++ + state.value = newValue + } + } + /** * 设置指定键的值,并在指定延迟后更新 * @@ -115,12 +150,12 @@ class DataContainer(val user: String, val database: Database) { * @param key 键 */ fun save(key: String) { - val state = writeStates.computeIfAbsent(key) { WriteState() } - synchronized(state) { + withState(key) { state -> state.revision++ state.value = source[key] state.deadline = null state.ready = true + deadlineKeys.remove(key) updateMap.remove(key) if (state.startIfNeeded()) { scheduleWrite(key, state) @@ -139,13 +174,23 @@ class DataContainer(val user: String, val database: Database) { * 检查并更新需要保存的键值对 */ fun checkUpdate() { + if (deadlineKeys.isEmpty()) { + return + } val currentTime = System.currentTimeMillis() - writeStates.forEach { (key, state) -> + deadlineKeys.toList().forEach { key -> + val state = writeStates[key] ?: run { + deadlineKeys.remove(key) + return@forEach + } synchronized(state) { val deadline = state.deadline - if (deadline != null && deadline <= currentTime) { + if (deadline == null) { + deadlineKeys.remove(key) + } else if (deadline <= currentTime) { state.deadline = null state.ready = true + deadlineKeys.remove(key) updateMap.remove(key, deadline) if (state.startIfNeeded()) { scheduleWrite(key, state) @@ -155,9 +200,49 @@ class DataContainer(val user: String, val database: Database) { } } + /** + * 立即排空所有未落库的写入。 + * + * 将所有处于延迟期限内的键立即置为可写,并在**当前线程同步**完成写库。 + * 释放容器或插件关闭时必须调用,此时调度器可能已经拒绝新任务, + * 因此这里不走 [asyncExecutor],而是直接同步排空。 + */ + fun flush() { + // 先把所有仍在延迟期限内的键置为可写 + writeStates.forEach { (key, state) -> + synchronized(state) { + if (state.deadline != null) { + state.deadline = null + state.ready = true + deadlineKeys.remove(key) + updateMap.remove(key) + } + } + } + // 再同步排空所有待写入的键,单个键失败不影响其余键 + // 若某个键已有排空任务在运行(running),则交由该任务完成,避免并发排空导致写入乱序 + var failure: Throwable? = null + writeStates.forEach { (key, state) -> + val shouldDrain = synchronized(state) { state.startIfNeeded() } + if (!shouldDrain) { + return@forEach + } + try { + drainWrites(key, state) + } catch (ex: Throwable) { + val firstFailure = failure + if (firstFailure == null) { + failure = ex + } else { + firstFailure.addSuppressed(ex) + } + } + } + failure?.let { throw it } + } + private fun updateValue(key: String, value: String?, deadline: Long?, updateSource: Boolean) { - val state = writeStates.computeIfAbsent(key) { WriteState() } - synchronized(state) { + withState(key) { state -> if (updateSource) { if (value == null) { source.remove(key) @@ -170,8 +255,10 @@ class DataContainer(val user: String, val database: Database) { state.deadline = deadline state.ready = deadline == null if (deadline == null) { + deadlineKeys.remove(key) updateMap.remove(key) } else { + deadlineKeys += key updateMap[key] = deadline } if (state.startIfNeeded()) { @@ -180,6 +267,44 @@ class DataContainer(val user: String, val database: Database) { } } + /** + * 获取指定键的写入状态并在其锁内执行操作。 + * + * 写入状态在空闲时会被 [recycleState] 回收,因此这里必须循环校验取到的状态 + * 仍是映射中的当前实例,避免两个线程各自持有一个已被替换的状态对象。 + */ + private inline fun withState(key: String, block: (WriteState) -> Unit) { + while (true) { + val state = writeStates.computeIfAbsent(key) { WriteState() } + val applied = synchronized(state) { + if (state.discarded) { + false + } else { + block(state) + true + } + } + if (applied) { + return + } + } + } + + /** + * 回收空闲的写入状态,避免 [writeStates] 只增不减。 + * + * 必须在持有 [state] 锁时调用,且仅在状态确实空闲 + * (无延迟期限、无待写标记、无运行中的排空)时移除。 + */ + private fun recycleState(key: String, state: WriteState) { + if (state.deadline != null || state.ready || state.running) { + return + } + if (writeStates.remove(key, state)) { + state.discarded = true + } + } + private fun scheduleWrite(key: String, state: WriteState) { try { asyncExecutor.invoke { @@ -198,6 +323,7 @@ class DataContainer(val user: String, val database: Database) { val snapshot = synchronized(state) { if (!state.ready) { state.running = false + recycleState(key, state) return } WriteSnapshot(state.revision, state.value) @@ -224,11 +350,13 @@ class DataContainer(val user: String, val database: Database) { state.revision == snapshot.revision -> { state.ready = false state.running = false + recycleState(key, state) false } state.ready -> true else -> { state.running = false + recycleState(key, state) false } } @@ -265,6 +393,9 @@ class DataContainer(val user: String, val database: Database) { var ready = false var running = false + /** 该状态是否已从 writeStates 中回收,回收后不得再被写入 */ + var discarded = false + fun startIfNeeded(): Boolean { return if (ready && !running) { running = true @@ -290,5 +421,20 @@ class DataContainer(val user: String, val database: Database) { fun checkUpdate() { playerDataContainer.entries.forEach { it.value.checkUpdate() } } + + /** + * 插件关闭时排空所有容器中未落库的写入。 + * + * 此时调度器可能已经拒绝新任务,[DataContainer.flush] 走同步路径, + * 单个容器失败不影响其余容器。 + */ + @Awake(LifeCycle.DISABLE) + fun flushAll() { + playerDataContainer.values.forEach { container -> + runCatching { container.flush() }.exceptionOrNull()?.let { + PrimitiveIO.warning("Failed to flush player data container {0}: {1}", container.user, it.toString()) + } + } + } } } diff --git a/module/database/database-player/src/main/kotlin/taboolib/expansion/Database.kt b/module/database/database-player/src/main/kotlin/taboolib/expansion/Database.kt index 8c5d6885d..ba0fbed9a 100644 --- a/module/database/database-player/src/main/kotlin/taboolib/expansion/Database.kt +++ b/module/database/database-player/src/main/kotlin/taboolib/expansion/Database.kt @@ -76,7 +76,16 @@ class Database(val type: Type, val dataSource: DataSource = createOwnedDataSourc update("value", data) } } - is TypeSQLite -> upsertSQLite(user, key, data) + // SQLite 使用 ON CONFLICT DO UPDATE 原地更新,而非 INSERT OR REPLACE, + // 后者是「删旧行再插新行」,会重置用户手工添加的额外列并推进 autoincrement。 + // 显式传入冲突字段(对应 ensureUniqueKeyIndex 建立的 user+key 唯一索引), + // 可将 SQLite 版本门槛从 3.35.0 降到 3.24.0。 + is TypeSQLite -> table.insert(dataSource, "user", "key", "value") { + value(user, key, data) + onDuplicateKeyUpdate(listOf("user", "key")) { + update("value", data) + } + } else -> upsertGeneric(user, key, data) } } @@ -140,23 +149,6 @@ class Database(val type: Type, val dataSource: DataSource = createOwnedDataSourc } } - private fun upsertSQLite(user: String, key: String, data: String) { - setupQuoterForHost(type.host()) - val tableName = table.name.asFormattedColumnName() - val userColumn = "user".asFormattedColumnName() - val keyColumn = "key".asFormattedColumnName() - val valueColumn = "value".asFormattedColumnName() - val query = "INSERT OR REPLACE INTO $tableName ($userColumn, $keyColumn, $valueColumn) VALUES (?, ?, ?)" - dataSource.connection.use { connection -> - connection.prepareStatement(query).use { statement -> - statement.setString(1, user) - statement.setString(2, key) - statement.setString(3, data) - statement.executeUpdate() - } - } - } - private fun upsertGeneric(user: String, key: String, data: String) { if (updateValue(user, key, data) > 0) { return @@ -166,7 +158,13 @@ class Database(val type: Type, val dataSource: DataSource = createOwnedDataSourc value(user, key, data) } } catch (ex: SQLException) { - if (!ex.isConstraintViolation() || updateValue(user, key, data) == 0) { + // 约束冲突说明该行已存在,此时 UPDATE 影响行数可能为 0(值未变化), + // 因此以「行确实存在」而非影响行数作为成功判定,避免误抛 + if (!ex.isConstraintViolation()) { + throw ex + } + updateValue(user, key, data) + if (get(user, key) == null) { throw ex } } @@ -195,15 +193,37 @@ class Database(val type: Type, val dataSource: DataSource = createOwnedDataSourc } private fun migrateSQLite(connection: Connection) { - val removedRows = inTransaction(connection) { - val removed = removeDuplicateRows(connection) - createUniqueIndex(connection, resolveUniqueIndexName(connection)) - removed - } - if (findUniqueKeyIndex(connection) == null) { - throw SQLException("Unable to create a unique player key index for table ${table.name}") + var removedRows = 0L + var lastFailure: SQLException? = null + repeat(MAX_INDEX_ATTEMPTS) { attempt -> + if (findUniqueKeyIndex(connection) != null) { + warnDuplicateRows(removedRows) + return + } + try { + // 归并重复数据同样可能因并发迁移而失败(SQLITE_BUSY / 死锁),需与建索引一起重试 + removedRows += inTransaction(connection) { + val removed = removeDuplicateRows(connection) + createUniqueIndex(connection, resolveUniqueIndexName(connection)) + removed + } + } catch (ex: SQLException) { + if (findUniqueKeyIndex(connection) != null) { + warnDuplicateRows(removedRows) + return + } + lastFailure = ex + if (attempt + 1 >= MAX_INDEX_ATTEMPTS) { + throw ex + } + return@repeat + } + if (findUniqueKeyIndex(connection) != null) { + warnDuplicateRows(removedRows) + return + } } - warnDuplicateRows(removedRows) + throw lastFailure ?: SQLException("Unable to create a unique player key index for table ${table.name}") } private fun migrateWithRetry(connection: Connection) { @@ -214,10 +234,14 @@ class Database(val type: Type, val dataSource: DataSource = createOwnedDataSourc warnDuplicateRows(removedRows) return } - removedRows += inTransaction(connection) { - removeDuplicateRows(connection) - } + // 跨节点并发迁移时,归并重复数据的大范围 DELETE 也可能撞死锁或锁等待超时, + // 因此与创建索引共用同一套重试,避免一次失败就导致插件加载失败 + var duplicatesRemoved = false try { + removedRows += inTransaction(connection) { + removeDuplicateRows(connection) + } + duplicatesRemoved = true createUniqueIndex(connection, resolveUniqueIndexName(connection)) } catch (ex: SQLException) { if (findUniqueKeyIndex(connection) != null) { @@ -225,7 +249,8 @@ class Database(val type: Type, val dataSource: DataSource = createOwnedDataSourc return } lastFailure = ex - if (attempt + 1 >= MAX_INDEX_ATTEMPTS || countDuplicateRows(connection) == 0L) { + // 重复数据已清空却仍无法建索引,说明重试无意义,直接抛出真实原因 + if (attempt + 1 >= MAX_INDEX_ATTEMPTS || (duplicatesRemoved && countDuplicateRows(connection) == 0L)) { throw ex } return@repeat diff --git a/module/database/database-player/src/main/kotlin/taboolib/expansion/DatabaseHandler.kt b/module/database/database-player/src/main/kotlin/taboolib/expansion/DatabaseHandler.kt index d3843d497..202c7e070 100644 --- a/module/database/database-player/src/main/kotlin/taboolib/expansion/DatabaseHandler.kt +++ b/module/database/database-player/src/main/kotlin/taboolib/expansion/DatabaseHandler.kt @@ -191,16 +191,20 @@ fun UUID.getPlayerDataContainer(): DataContainer { /** * 释放 UUID 对应的玩家数据容器 + * + * 移除前会先同步排空未落库的写入,避免延迟保存窗口内的数据丢失。 */ fun UUID.releasePlayerDataContainer() { - playerDataContainer.remove(this) + playerDataContainer.remove(this)?.flush() } /** * 释放玩家的数据容器 + * + * 移除前会先同步排空未落库的写入,避免延迟保存窗口内的数据丢失。 */ fun ProxyPlayer.releaseDataContainer() { - playerDataContainer.remove(uniqueId) + playerDataContainer.remove(uniqueId)?.flush() } /** diff --git a/module/database/database-player/src/main/kotlin/taboolib/expansion/PlayerDatabase.kt b/module/database/database-player/src/main/kotlin/taboolib/expansion/PlayerDatabase.kt index 0c580ca19..2da440864 100644 --- a/module/database/database-player/src/main/kotlin/taboolib/expansion/PlayerDatabase.kt +++ b/module/database/database-player/src/main/kotlin/taboolib/expansion/PlayerDatabase.kt @@ -104,19 +104,23 @@ abstract class PlayerDatabase { /** * 释放 UUID 对应的数据容器 * + * 移除后会先同步排空未落库的写入,避免延迟保存窗口内的数据丢失。 + * * @param uuid UUID */ fun releaseDataContainer(uuid: UUID) { - dataContainer.remove(uuid) + dataContainer.remove(uuid)?.flush() } /** * 释放玩家对应的数据容器 * + * 移除后会先同步排空未落库的写入,避免延迟保存窗口内的数据丢失。 + * * @param player ProxyPlayer */ fun releaseDataContainer(player: ProxyPlayer) { - dataContainer.remove(player.uniqueId) + dataContainer.remove(player.uniqueId)?.flush() } /** diff --git a/module/database/database-player/src/test/kotlin/taboolib/expansion/PlayerDatabaseConsistencyTest.kt b/module/database/database-player/src/test/kotlin/taboolib/expansion/PlayerDatabaseConsistencyTest.kt index ec04445b3..2b40cacad 100644 --- a/module/database/database-player/src/test/kotlin/taboolib/expansion/PlayerDatabaseConsistencyTest.kt +++ b/module/database/database-player/src/test/kotlin/taboolib/expansion/PlayerDatabaseConsistencyTest.kt @@ -11,6 +11,7 @@ import org.sqlite.SQLiteDataSource import java.nio.file.Path import java.sql.SQLException import java.util.ArrayDeque +import java.util.UUID import java.util.concurrent.CountDownLatch import java.util.concurrent.Executors import java.util.concurrent.RejectedExecutionException @@ -274,6 +275,108 @@ class PlayerDatabaseConsistencyTest { assertEquals("ready", fixture.database["player", "state"]) } + @Test + fun `flush persists values still inside their deadline`() { + val fixture = createFixture("flush-delayed") + val container = DataContainer("player", fixture.database) + // flush 必须走同步路径,因此这里让异步执行器直接抛出,模拟关服阶段调度器拒绝任务 + container.asyncExecutor = { error("scheduler is unavailable") } + + container.setDelayed("state", "delayed", 1, TimeUnit.DAYS) + assertNull(fixture.database["player", "state"]) + + container.flush() + + assertEquals("delayed", fixture.database["player", "state"]) + } + + @Test + fun `flush persists deletions still inside their deadline`() { + val fixture = createFixture("flush-delete") + val container = DataContainer("player", fixture.database) + val tasks = ArrayDeque<() -> Unit>() + container.asyncExecutor = { tasks.addLast(it) } + + container["state"] = "present" + tasks.removeFirst().invoke() + assertEquals("present", fixture.database["player", "state"]) + + container.setDelayed("state", "", 1, TimeUnit.DAYS) + container.flush() + + assertTrue(tasks.isEmpty()) + assertNull(fixture.database["player", "state"]) + } + + @Test + fun `releasing a container flushes its delayed writes`() { + val fixture = createFixture("release-flush") + playerDatabase = fixture.database + val uniqueId = UUID.randomUUID() + try { + uniqueId.setupPlayerDataContainer() + val container = uniqueId.getPlayerDataContainer() + container.asyncExecutor = { error("scheduler is unavailable") } + container.setDelayed("state", "delayed", 1, TimeUnit.DAYS) + + uniqueId.releasePlayerDataContainer() + + assertEquals("delayed", fixture.database[uniqueId.toString(), "state"]) + assertTrue(!playerDataContainer.containsKey(uniqueId)) + } finally { + playerDataContainer.remove(uniqueId) + playerDatabase = null + } + } + + @Test + fun `forced set with sync does not schedule a duplicate write`() { + val fixture = createFixture("forced-set") + playerDatabase = fixture.database + val uniqueId = UUID.randomUUID() + try { + uniqueId.setupPlayerDataContainer() + val container = uniqueId.getPlayerDataContainer() + val tasks = ArrayDeque<() -> Unit>() + container.asyncExecutor = { tasks.addLast(it) } + + container.forcedSet(uniqueId.toString(), "state", "forced", sync = true) + + // 数据库写入由 forcedSet 自己完成,不应额外排程异步写库 + assertTrue(tasks.isEmpty()) + assertEquals("forced", fixture.database[uniqueId.toString(), "state"]) + assertEquals("forced", container["state"]) + } finally { + playerDataContainer.remove(uniqueId) + playerDatabase = null + } + } + + @Test + fun `idle write states are recycled after draining`() { + val fixture = createFixture("recycle") + val container = DataContainer("player", fixture.database) + val tasks = ArrayDeque<() -> Unit>() + container.asyncExecutor = { tasks.addLast(it) } + + container["state"] = "one" + tasks.removeFirst().invoke() + + // 排空成功后条目应被回收,避免 writeStates 只增不减 + assertEquals(0, writeStateSize(container)) + + container["state"] = "two" + tasks.removeFirst().invoke() + assertEquals("two", fixture.database["player", "state"]) + assertEquals(0, writeStateSize(container)) + } + + @Suppress("UNCHECKED_CAST") + private fun writeStateSize(container: DataContainer): Int { + val field = DataContainer::class.java.getDeclaredField("writeStates").apply { isAccessible = true } + return (field.get(container) as Map).size + } + private fun createFixture(name: String, table: String = "${name}_player_data"): Fixture { val file = tempDir.resolve("$name.db").toFile() val dataSource = createDataSource(file.toPath()) diff --git a/module/database/src/main/kotlin/taboolib/module/database/ActionInsert.kt b/module/database/src/main/kotlin/taboolib/module/database/ActionInsert.kt index f4a41313f..137e91c27 100644 --- a/module/database/src/main/kotlin/taboolib/module/database/ActionInsert.kt +++ b/module/database/src/main/kotlin/taboolib/module/database/ActionInsert.kt @@ -73,7 +73,7 @@ class ActionInsert(val table: String, val keys: Array) : Action { /** * 重复时更新。 - * PostgreSQL 无法从插入字段可靠推断唯一约束,需使用带冲突字段的重载。 + * 仅适用于 MySQL,PostgreSQL 与 SQLite 无法可靠推断唯一约束,需使用带冲突字段的重载。 */ fun onDuplicateKeyUpdate(func: DuplicateUpdateBehavior.() -> Unit) { setupDuplicateUpdate(null, func) @@ -82,6 +82,9 @@ class ActionInsert(val table: String, val keys: Array) : Action { /** * 重复时更新,并显式指定 PostgreSQL/SQLite 的冲突字段。 * MySQL 会忽略冲突字段并继续使用 ON DUPLICATE KEY UPDATE。 + * + * SQLite 显式指定冲突字段后仅需 SQLite >= 3.24.0;省略冲突字段则要求 SQLite >= 3.35.0, + * 而 TabooLib 无法控制服务端提供的 sqlite-jdbc 版本,因此不再支持省略。 */ fun onDuplicateKeyUpdate(conflictKeys: Collection, func: DuplicateUpdateBehavior.() -> Unit) { setupDuplicateUpdate(conflictKeys.toTypedArray(), func) @@ -129,15 +132,20 @@ class ActionInsert(val table: String, val keys: Array) : Action { addOperations(duplicateUpdate) } DuplicateKeyDialect.SQLITE -> { - addSegment("ON CONFLICT") - conflictKeys?.also { targetKeys -> - require(targetKeys.none { it.isBlank() }) { - "SQLite conflict keys must not contain blank names" - } - if (targetKeys.isNotEmpty()) { - addKeys(targetKeys) - } + val targetKeys = conflictKeys + // SQLite 省略冲突字段的 DO UPDATE 需要 SQLite >= 3.35.0, + // 而 TabooLib 不声明 sqlite-jdbc 运行时依赖、版本完全由服务端提供,无法保证。 + // 因此这里与 PostgreSQL 一样要求显式传入冲突字段,把版本门槛降到 3.24.0, + // 同时把失败从用户服务器上的裸 SQLSyntaxError 提前到开发期的明确报错。 + require(!targetKeys.isNullOrEmpty()) { + "SQLite duplicate update requires explicit conflict keys, " + + "use onDuplicateKeyUpdate(listOf(\"key\")) { ... } instead" } + require(targetKeys.none { it.isBlank() }) { + "SQLite conflict keys must not contain blank names" + } + addSegment("ON CONFLICT") + addKeys(targetKeys) addSegment("DO UPDATE SET") addOperations(duplicateUpdate) } diff --git a/module/database/src/test/kotlin/taboolib/module/database/ActionInsertDialectTest.kt b/module/database/src/test/kotlin/taboolib/module/database/ActionInsertDialectTest.kt index 0d7469ada..e6970bccf 100644 --- a/module/database/src/test/kotlin/taboolib/module/database/ActionInsertDialectTest.kt +++ b/module/database/src/test/kotlin/taboolib/module/database/ActionInsertDialectTest.kt @@ -58,19 +58,30 @@ class ActionInsertDialectTest { } @Test - fun `uses sqlite conflict syntax without guessing a conflict target`() { + fun `uses sqlite conflict syntax with an explicit conflict target`() { val action = insertAction(HostSQLite(File("database.db")), "order") { - onDuplicateKeyUpdate { + onDuplicateKeyUpdate(listOf("key")) { update("value", 2) } } assertEquals( - "INSERT INTO `order` (`key`, `value`) VALUES (?, ?) ON CONFLICT DO UPDATE SET `value` = ?", + "INSERT INTO `order` (`key`, `value`) VALUES (?, ?) ON CONFLICT (`key`) DO UPDATE SET `value` = ?", action.query ) } + @Test + fun `requires explicit sqlite conflict keys`() { + val action = insertAction(HostSQLite(File("database.db")), "order") { + onDuplicateKeyUpdate { + update("value", 2) + } + } + + assertThrows(IllegalArgumentException::class.java) { action.query } + } + @Test fun `keeps positional insert compatibility when keys are omitted`() { setupQuoterForHost(HostSQLite(File("database.db"))) @@ -97,7 +108,7 @@ class ActionInsertDialectTest { @Test fun `executes generated sqlite upsert`() { val action = insertAction(HostSQLite(File("database.db")), "entries") { - onDuplicateKeyUpdate { + onDuplicateKeyUpdate(listOf("key")) { update("value", 2) } } From a79a254d6cebbda4c01725ab7038fb074668c93f Mon Sep 17 00:00:00 2001 From: FxRayHughes Date: Sun, 26 Jul 2026 22:45:53 +0800 Subject: [PATCH 32/37] =?UTF-8?q?fix(review):=20=E7=BB=9F=E4=B8=80=20Redis?= =?UTF-8?q?=20=E8=BF=9E=E6=8E=A5=E6=89=80=E6=9C=89=E6=9D=83=E5=B9=B6?= =?UTF-8?q?=E6=A0=87=E6=B3=A8=E8=AF=AD=E4=B9=89=E5=8F=98=E6=9B=B4=E7=9A=84?= =?UTF-8?q?=E5=85=AC=E5=BC=80=E5=AD=97=E6=AE=B5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 依据 #709 / #710 评审的可选建议: 1. Redis 连接与 connector 的所有权一致化(#709-4) SingleRedisConnector.connection() 每次调用都新建包装对象,但它们共享 connector 持有的同一个 pool;任一连接 close() 都会关掉该 pool, 使其余连接立即失效。Cluster 侧同样——其 close() 直接调 connector.close()。 两处改为缓存并复用单一连接实例,令「一个 connector 对应一个连接」成为事实; 连接已关闭时才重建,Single 侧在重连后同步新的 pool 引用。 同时为两个连接类补上公开的 isClosed() 判定。 2. DataContainer.updateMap 标注废弃(#710-6) 该字段的语义已反转:旧实现存入「当前时间 - 延迟」(恒已过期的时间点), 判断是否该写库要看它是否早于当前时间;新实现存入未来的 deadline, 判断条件正好相反。外部读取者会得出完全相反的结论,故标注 @Deprecated 并说明改用 setDelayed 表达延迟写入意图。 3. DataContainer.save(key) 补充 KDoc(#710-3) 该方法在键不存在于缓存时会删除数据库中对应的行(早期实现抛 NPE), 属于「缓存即真相」的有意设计,但原注释只写「保存指定键的值到数据库」, 未体现删除语义。 --- .../expansion/ClusterRedisConnection.kt | 7 ++++++ .../expansion/ClusterRedisConnector.kt | 22 +++++++++++++++--- .../expansion/SingleRedisConnection.kt | 7 ++++++ .../expansion/SingleRedisConnector.kt | 23 +++++++++++++++++-- .../taboolib/expansion/DataContainer.kt | 19 +++++++++++++-- 5 files changed, 71 insertions(+), 7 deletions(-) diff --git a/module/database/database-alkaid-redis/src/main/kotlin/taboolib/expansion/ClusterRedisConnection.kt b/module/database/database-alkaid-redis/src/main/kotlin/taboolib/expansion/ClusterRedisConnection.kt index 2890e1252..694c2e7a8 100644 --- a/module/database/database-alkaid-redis/src/main/kotlin/taboolib/expansion/ClusterRedisConnection.kt +++ b/module/database/database-alkaid-redis/src/main/kotlin/taboolib/expansion/ClusterRedisConnection.kt @@ -28,6 +28,13 @@ import java.util.concurrent.atomic.AtomicBoolean class ClusterRedisConnection(val connector: ClusterRedisConnector) : Closeable, IRedisConnection { private val closed = AtomicBoolean(false) + + /** + * 该连接是否已关闭。关闭后所有操作都会抛出异常。 + */ + fun isClosed(): Boolean { + return closed.get() + } private val subscriptions = CopyOnWriteArrayList() private val service: ExecutorService = Executors.newCachedThreadPool() diff --git a/module/database/database-alkaid-redis/src/main/kotlin/taboolib/expansion/ClusterRedisConnector.kt b/module/database/database-alkaid-redis/src/main/kotlin/taboolib/expansion/ClusterRedisConnector.kt index bd9944b6d..f7a88aa49 100644 --- a/module/database/database-alkaid-redis/src/main/kotlin/taboolib/expansion/ClusterRedisConnector.kt +++ b/module/database/database-alkaid-redis/src/main/kotlin/taboolib/expansion/ClusterRedisConnector.kt @@ -36,6 +36,14 @@ class ClusterRedisConnector : Closeable { val nodes: LinkedHashSet = linkedSetOf() val genericObjectPoolConfig = GenericObjectPoolConfig() + /** + * 由本 connector 产出的连接。 + * + * 连接关闭时会一并关闭本 connector,因此一个 connector 只应对应一个连接实例, + * 否则关闭其中之一会让其余连接立即失效。 + */ + private var sharedConnection: ClusterRedisConnection? = null + @Synchronized fun build(): ClusterRedisConnector { @@ -66,16 +74,24 @@ class ClusterRedisConnector : Closeable { } /** - * 获取 Redis 连接 + * 获取 Redis 连接。 + * + * 多次调用返回同一个实例——连接关闭时会一并关闭本 connector, + * 若每次都新建包装对象,关闭其中任意一个都会让其余连接失效。 * * @return [ClusterRedisConnection] */ + @Synchronized fun connection(): ClusterRedisConnection { - return ClusterRedisConnection(this) + val existing = sharedConnection + if (existing != null && !existing.isClosed()) { + return existing + } + return ClusterRedisConnection(this).also { sharedConnection = it } } fun connection(action: ClusterRedisConnection.() -> Unit): ClusterRedisConnection { - return ClusterRedisConnection(this).apply { + return connection().apply { action.invoke(this) } } diff --git a/module/database/database-alkaid-redis/src/main/kotlin/taboolib/expansion/SingleRedisConnection.kt b/module/database/database-alkaid-redis/src/main/kotlin/taboolib/expansion/SingleRedisConnection.kt index 56e118363..0f8347106 100644 --- a/module/database/database-alkaid-redis/src/main/kotlin/taboolib/expansion/SingleRedisConnection.kt +++ b/module/database/database-alkaid-redis/src/main/kotlin/taboolib/expansion/SingleRedisConnection.kt @@ -37,6 +37,13 @@ class SingleRedisConnection(@Volatile internal var pool: JedisPool, internal val private val service: ExecutorService = Executors.newCachedThreadPool() private val reconnectService: ScheduledExecutorService = Executors.newSingleThreadScheduledExecutor() + /** + * 该连接是否已关闭。关闭后所有操作都会抛出异常。 + */ + fun isClosed(): Boolean { + return closed.get() + } + init { AlkaidRedis.register(this) } diff --git a/module/database/database-alkaid-redis/src/main/kotlin/taboolib/expansion/SingleRedisConnector.kt b/module/database/database-alkaid-redis/src/main/kotlin/taboolib/expansion/SingleRedisConnector.kt index 489cb7138..6e11f2119 100644 --- a/module/database/database-alkaid-redis/src/main/kotlin/taboolib/expansion/SingleRedisConnector.kt +++ b/module/database/database-alkaid-redis/src/main/kotlin/taboolib/expansion/SingleRedisConnector.kt @@ -32,6 +32,14 @@ class SingleRedisConnector: Closeable { internal var pool: JedisPool? = null internal var config = JedisPoolConfig() + /** + * 由本 connector 产出的连接。 + * + * 连接持有的 pool 归 connector 所有,关闭连接会一并关闭该 pool。 + * 因此一个 connector 只应对应一个连接实例,否则关闭其中之一会让其余连接立即失效。 + */ + private var sharedConnection: SingleRedisConnection? = null + /** * 连接到 Redis * @@ -63,12 +71,23 @@ class SingleRedisConnector: Closeable { } /** - * 获取 Redis 连接 + * 获取 Redis 连接。 + * + * 多次调用返回同一个实例——连接与 pool 的生命周期由本 connector 统一管理, + * 若每次都新建包装对象,关闭其中任意一个都会关掉共享的 pool 而使其余连接失效。 * * @return [SingleRedisConnection] */ + @Synchronized fun connection(): SingleRedisConnection { - return SingleRedisConnection(pool ?: error("connect first"), this) + val currentPool = pool ?: error("connect first") + val existing = sharedConnection + if (existing != null && !existing.isClosed()) { + // 重连后 pool 可能已被替换,同步给既有连接 + existing.pool = currentPool + return existing + } + return SingleRedisConnection(currentPool, this).also { sharedConnection = it } } /** diff --git a/module/database/database-player/src/main/kotlin/taboolib/expansion/DataContainer.kt b/module/database/database-player/src/main/kotlin/taboolib/expansion/DataContainer.kt index 6f837b1a8..423ac27f3 100644 --- a/module/database/database-player/src/main/kotlin/taboolib/expansion/DataContainer.kt +++ b/module/database/database-player/src/main/kotlin/taboolib/expansion/DataContainer.kt @@ -18,12 +18,23 @@ import java.util.concurrent.TimeUnit * @property user 用户标识 * @property database 数据库实例 */ +@Suppress("DEPRECATION") class DataContainer(val user: String, val database: Database) { /** 存储用户数据的源 */ val source = database[user] - /** 存储需要更新的键值对及其更新时间 */ + /** + * 待写入键的时间标记。 + * + * **语义已变更**:早期实现存入的是 `当前时间 - 延迟`(即一个恒已过期的时间点), + * 判断「是否该写库」需要检查它是否早于当前时间; + * 现在存入的是**未来的 deadline**,即到达该时间点后才写库,判断条件正好相反。 + * + * 该字段仅作为内部 [writeStates] 的影子副本保留以兼容既有读取方, + * 请改用 [setDelayed] 表达延迟写入意图,不要依赖此字段做判断。 + */ + @Deprecated("语义已由「已过期时间」变更为「未来 deadline」,请勿依赖该字段做判断") val updateMap = ConcurrentHashMap() private val writeStates = ConcurrentHashMap() @@ -145,7 +156,11 @@ class DataContainer(val user: String, val database: Database) { } /** - * 保存指定键的值到数据库 + * 保存指定键的值到数据库。 + * + * 注意:**若该键不存在于缓存中,将删除数据库中对应的行**。 + * 早期实现在这种情况下会抛出 NullPointerException,现改为按「缓存即真相」处理, + * 与 [set] / [delete] 走同一条写入路径。 * * @param key 键 */ From 177ddbb3e600b14aeb8cd0d295b5db61fdd482a3 Mon Sep 17 00:00:00 2001 From: FxRayHughes Date: Sun, 26 Jul 2026 22:53:01 +0800 Subject: [PATCH 33/37] =?UTF-8?q?fix(review):=20=E8=A1=A5=E9=BD=90?= =?UTF-8?q?=E9=85=8D=E7=BD=AE=E8=BD=AC=E6=8D=A2=E7=9A=84=E6=B3=A8=E5=86=8C?= =?UTF-8?q?=E8=A1=A8=E6=9F=A5=E8=AF=A2=E4=B8=8E=E8=8A=82=E6=B5=81=E7=8A=B6?= =?UTF-8?q?=E6=80=81=E9=87=8D=E7=BD=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 依据 #704 / #705 / #711 评审的可选建议: 1. submit-chain 的链异常不再彻底静默(#705-1) 由 launch 改为 async 后,异常存入 Deferred 不再经过 CoroutineExceptionHandler, 而 submitChain { } 的常见用法是 fire-and-forget、不持有返回的 future, 此时异常会完全消失、排查比修复前更难。现统一记录一次。 取舍已写入注释:CompletableFuture 无法探测异常是否已被消费, 自行处理异常的调用方会额外看到一条日志,相比静默丢失是可接受的代价。 2. convertValue 补充全局转换器查询(#711-4) 字段级路径会查 ConverterRegistry,元素级不查,导致 List / Map 仍抛 InvalidValueException,而同类型的单值字段却正常—— 与本 PR「按声明泛型递归恢复」的目标不一致。 3. 元素级枚举转换遵循 @SpecEnum(#711-5) 原先硬编码 EnumGetMethod.NAME_IGNORECASE,忽略字段上的 @SpecEnum, 使 @SpecEnum(ORDINAL) var modes: EnumSet 的声明失效。 现将取值方式沿 convertCollection / convertMap / convertValue 调用链传递。 4. Throttle.Singleton / Debounce.Singleton 的状态重置(#704-6) 两者的状态分别存于 lastExecuteTime 与 task 字段,而继承来的 clearAll() / removeKey() 操作的是父类映射表(Singleton 从不写入),调用毫无效果。 改为覆写,真正重置自身状态。 5. RepeatChainable 的竞态覆盖补注释(#705-5) taskReference 赋值后的二次 cancel 检查是为覆盖「任务在赋值前已完成」的竞态, 看似冗余,补注释防止后续维护者误删。 --- .../taboolib/common/function/Debounce.kt | 13 +- .../service/PlatformExecutorSupport.kt | 422 ++++++++++++++++++ .../taboolib/common/function/Throttle.kt | 21 +- .../core/conversion/ObjectConverter.java | 41 +- .../main/kotlin/taboolib/expansion/Chain.kt | 16 +- .../taboolib/expansion/RepeatChainable.kt | 3 + 6 files changed, 505 insertions(+), 11 deletions(-) create mode 100644 common-platform-api/src/main/kotlin/taboolib/common/platform/service/PlatformExecutorSupport.kt diff --git a/common-platform-api/src/main/kotlin/taboolib/common/function/Debounce.kt b/common-platform-api/src/main/kotlin/taboolib/common/function/Debounce.kt index 1ef024065..38b21dc37 100644 --- a/common-platform-api/src/main/kotlin/taboolib/common/function/Debounce.kt +++ b/common-platform-api/src/main/kotlin/taboolib/common/function/Debounce.kt @@ -27,7 +27,7 @@ abstract class DebounceFunction( * 清除所有防抖任务 * 取消所有正在执行的任务并清空任务映射表 */ - fun clearAll() { + open fun clearAll() { tokenMap.clear() futureMap.values.forEach { it.cancel() } futureMap.clear() @@ -48,6 +48,17 @@ abstract class DebounceFunction( var task: PlatformExecutor.PlatformTask? = null + /** + * 取消待执行的防抖任务。 + * + * Singleton 的状态存于 [task] 而非父类的 tokenMap / futureMap, + * 因此必须覆写,否则调用父类实现对本类毫无效果。 + */ + override fun clearAll() { + task?.cancel() + task = null + } + /** * 调用防抖函数 * @param delay 延迟时间(毫秒),默认使用构造时设定的延迟时间 diff --git a/common-platform-api/src/main/kotlin/taboolib/common/platform/service/PlatformExecutorSupport.kt b/common-platform-api/src/main/kotlin/taboolib/common/platform/service/PlatformExecutorSupport.kt new file mode 100644 index 000000000..953c18b2e --- /dev/null +++ b/common-platform-api/src/main/kotlin/taboolib/common/platform/service/PlatformExecutorSupport.kt @@ -0,0 +1,422 @@ +package taboolib.common.platform.service + +import taboolib.common.LifeCycle +import taboolib.common.platform.function.registerLifeCycleTask +import java.io.Closeable +import java.util.concurrent.RejectedExecutionException +import java.util.concurrent.ThreadFactory +import java.util.concurrent.atomic.AtomicBoolean +import java.util.concurrent.atomic.AtomicInteger + +/** + * 平台执行器的生命周期状态 + * + * NEW -> RUNNING -> STOPPED,单向流转,STOPPED 为终态。 + */ +enum class PlatformExecutorState { + + /** 尚未启动,提交的任务进入等待队列 */ + NEW, + + /** 已启动,提交的任务立即调度 */ + RUNNING, + + /** 已停止,拒绝一切新任务 */ + STOPPED +} + +/** + * 任务登记结果 + */ +enum class PlatformTaskRegistration { + + /** 已进入等待队列,待执行器启动后统一调度 */ + PENDING, + + /** 已进入活动队列,需要立即调度 */ + ACTIVE, + + /** 执行器已停止,任务被拒绝 */ + REJECTED +} + +/** + * 命名线程工厂,用于给平台异步线程池的线程赋予可读名称 + * + * @param namePrefix 线程名前缀,实际名称为「前缀 + 自增序号」 + */ +class PlatformThreadFactory(private val namePrefix: String) : ThreadFactory { + + private val counter = AtomicInteger() + + override fun newThread(runnable: Runnable): Thread { + return Thread(runnable, "$namePrefix${counter.incrementAndGet()}") + } +} + +/** + * 异常聚合器 + * + * 用于「批量操作中某一步失败仍需继续执行剩余步骤」的场景: + * 首个异常作为主异常,后续异常通过 [Throwable.addSuppressed] 附加,最后统一抛出。 + */ +class PlatformFailureCollector { + + private var failure: Throwable? = null + + /** 执行动作并捕获异常 */ + inline fun collect(action: () -> Unit) { + try { + action() + } catch (ex: Throwable) { + record(ex) + } + } + + /** 记录一个异常 */ + fun record(ex: Throwable) { + val current = failure + if (current == null) { + failure = ex + } else { + current.addSuppressed(ex) + } + } + + /** 若存在异常则抛出 */ + fun rethrow() { + failure?.let { throw it } + } +} + +/** + * 调度句柄取消协调器 + * + * 解决「任务在拿到调度句柄之前就被取消」的竞态: + * - 先取消后绑定:绑定时立即取消句柄 + * - 先绑定后取消:取消时取消句柄 + * 无论何种顺序,[cancelDelegate] 至多执行一次。 + * + * @param cancelDelegate 取消底层调度句柄的动作 + */ +class PlatformTaskCancellation(private val cancelDelegate: (T) -> Unit) { + + private val lock = Any() + + @Volatile + private var cancelled = false + private var delegate: T? = null + + /** + * 绑定调度句柄,允许对同一实例重复绑定 + * + * @throws IllegalStateException 绑定了不同的句柄 + */ + fun bind(value: T) { + val cancelNow = synchronized(lock) { + val current = delegate + check(current == null || current === value) { "Scheduled task is already bound" } + if (current == null) { + delegate = value + cancelled + } else { + false + } + } + if (cancelNow) { + cancelDelegate(value) + } + } + + /** + * 取消任务,幂等 + * + * @param afterCancellation 取消后的清理动作,即便 [cancelDelegate] 抛出也会执行 + * @return 本次调用是否真正执行了取消 + */ + fun cancel(afterCancellation: () -> Unit = {}): Boolean { + val bound = synchronized(lock) { + if (cancelled) { + return false + } + cancelled = true + delegate + } + try { + if (bound != null) { + cancelDelegate(bound) + } + } finally { + afterCancellation() + } + return true + } + + /** 是否已取消 */ + fun isCancelled(): Boolean { + return cancelled + } + + /** + * 仅在任务未取消时执行动作 + * + * @return 动作是否被执行 + */ + fun runIfActive(action: () -> Unit): Boolean { + if (cancelled) { + return false + } + action() + return true + } +} + +/** + * 任务登记表 + * + * 维护 [PlatformExecutorState] 状态机与「等待队列 / 活动队列」双集合,所有操作在同一把锁下完成。 + */ +class PlatformTaskRegistry { + + private val lock = Any() + private val pending = LinkedHashSet() + private val active = LinkedHashSet() + private var state = PlatformExecutorState.NEW + + /** 登记任务 */ + fun register(task: T): PlatformTaskRegistration { + return synchronized(lock) { + when (state) { + PlatformExecutorState.NEW -> { + pending += task + PlatformTaskRegistration.PENDING + } + PlatformExecutorState.RUNNING -> { + active += task + PlatformTaskRegistration.ACTIVE + } + PlatformExecutorState.STOPPED -> PlatformTaskRegistration.REJECTED + } + } + } + + /** + * 由 NEW 转入 RUNNING,并将等待队列中的任务转入活动队列 + * + * @param accept 过滤器,返回 false 的任务被直接丢弃(例如已取消的任务) + * @return 需要立即调度的任务,若状态不是 NEW 则返回空列表 + */ + fun start(accept: (T) -> Boolean = { true }): List { + return synchronized(lock) { + if (state != PlatformExecutorState.NEW) { + return@synchronized emptyList() + } + state = PlatformExecutorState.RUNNING + val tasks = pending.filterTo(ArrayList(), accept) + pending.clear() + active += tasks + tasks + } + } + + /** 从队列中移除任务 */ + fun remove(task: T): Boolean { + return synchronized(lock) { + pending.remove(task) || active.remove(task) + } + } + + /** + * 转入 STOPPED 并清空两个队列 + * + * @return 需要取消的任务,若此前已经停止则返回 null + */ + fun stop(): List? { + return synchronized(lock) { + if (state == PlatformExecutorState.STOPPED) { + return@synchronized null + } + state = PlatformExecutorState.STOPPED + val tasks = ArrayList(pending.size + active.size) + tasks += pending + tasks += active + pending.clear() + active.clear() + tasks + } + } + + /** 当前状态 */ + fun state(): PlatformExecutorState { + return synchronized(lock) { state } + } + + /** 等待队列长度 */ + fun pendingCount(): Int { + return synchronized(lock) { pending.size } + } + + /** 活动队列长度 */ + fun activeCount(): Int { + return synchronized(lock) { active.size } + } +} + +/** + * 基于 [Closeable] 的幂等平台任务句柄 + * + * 多次调用 [cancel] 只会触发一次 [runnable]。 + */ +open class CloseablePlatformTask(val runnable: Closeable) : PlatformExecutor.PlatformTask { + + private val cancelled = AtomicBoolean(false) + + override fun cancel() { + if (cancelled.compareAndSet(false, true)) { + runnable.close() + } + } +} + +/** + * 平台执行器公共基类 + * + * 抽取 Velocity / Application / AfyBroker / Hytale 等平台执行器中完全一致的部分: + * - NEW / RUNNING / STOPPED 状态机与等待、活动双队列 + * - DISABLE 生命周期下的停止任务注册 + * - 启动、停止过程中的异常聚合(首个异常为主,其余 addSuppressed) + * - 停止后拒绝提交任务 + * + * 各平台只需实现自己的调度差异([launchTask]、[cancelTask]、[onStopped])。 + * + * 注意:本类不实现 [PlatformExecutor],由各平台执行器显式声明该接口, + * 以保证 PlatformFactory 能够通过「直接实现的接口」识别平台服务。 + * + * @param executorName 执行器名称,用于拒绝任务时的异常信息 + */ +abstract class PlatformExecutorSupport(private val executorName: String) { + + private val registry = PlatformTaskRegistry() + + /** + * 在 DISABLE 生命周期(优先级 2)下注册停止任务 + * + * 必须由子类在自身 init 块的末尾调用,以确保子类字段已完成初始化。 + */ + protected fun registerStopTaskOnDisable() { + registerLifeCycleTask(LifeCycle.DISABLE, 2) { stop() } + } + + /** + * 停止执行器,幂等 + */ + open fun stop() { + stopTasks() + } + + /** + * 启动执行器:转入 RUNNING 并调度全部等待中的任务 + * + * 单个任务调度失败不会中断其余任务,所有异常在最后统一抛出。 + */ + protected fun startTasks() { + val tasks = registry.start { !isTaskCancelled(it) } + val collector = PlatformFailureCollector() + tasks.forEach { task -> collector.collect { launchTask(task) } } + collector.rethrow() + } + + /** + * 停止执行器:转入 STOPPED,取消全部任务并释放平台资源 + * + * 单个任务取消失败不会中断其余任务,所有异常在最后统一抛出。 + */ + protected fun stopTasks() { + val tasks = registry.stop() ?: return + val collector = PlatformFailureCollector() + tasks.forEach { task -> collector.collect { cancelTask(task) } } + collector.collect { onStopped() } + collector.rethrow() + } + + /** 登记任务 */ + protected fun registerTask(task: T): PlatformTaskRegistration { + return registry.register(task) + } + + /** 任务结束(正常完成或被取消),从队列中移除 */ + protected fun taskFinished(task: T) { + registry.remove(task) + } + + /** 抛出「执行器已停止」异常 */ + protected fun rejectStopped(): Nothing { + throw RejectedExecutionException("$executorName has been stopped") + } + + /** 若执行器已停止则拒绝 */ + protected fun rejectIfStopped() { + if (registry.state() == PlatformExecutorState.STOPPED) { + rejectStopped() + } + } + + /** 当前状态 */ + fun currentState(): PlatformExecutorState { + return registry.state() + } + + /** 等待中的任务数量 */ + fun pendingTaskCount(): Int { + return registry.pendingCount() + } + + /** 活动中的任务数量 */ + fun activeTaskCount(): Int { + return registry.activeCount() + } + + /** 调度任务,由各平台实现 */ + protected abstract fun launchTask(task: T) + + /** 取消任务,由各平台实现 */ + protected abstract fun cancelTask(task: T) + + /** 判断任务是否已取消,用于启动时跳过已取消的等待任务 */ + protected open fun isTaskCancelled(task: T): Boolean = false + + /** 停止后的资源释放钩子,例如关闭异步线程池 */ + protected open fun onStopped() {} +} + +/** + * 执行动作并在失败时上报异常 + * + * 上报器与清理动作自身抛出的异常通过 [Throwable.addSuppressed] 附加到原异常上, + * 保证原始异常不被替换。 + * + * @param reporter 异常上报器 + * @param cleanup 失败后的清理动作 + * @param action 实际动作 + */ +inline fun runReportingFailure( + reporter: (Throwable) -> Unit, + cleanup: () -> Unit = {}, + action: () -> T, +): T { + try { + return action() + } catch (ex: Throwable) { + try { + reporter(ex) + } catch (reportingFailure: Throwable) { + ex.addSuppressed(reportingFailure) + } + try { + cleanup() + } catch (cleanupFailure: Throwable) { + ex.addSuppressed(cleanupFailure) + } + throw ex + } +} diff --git a/common-util/src/main/kotlin/taboolib/common/function/Throttle.kt b/common-util/src/main/kotlin/taboolib/common/function/Throttle.kt index d395fbbb6..b7898d02a 100644 --- a/common-util/src/main/kotlin/taboolib/common/function/Throttle.kt +++ b/common-util/src/main/kotlin/taboolib/common/function/Throttle.kt @@ -39,7 +39,7 @@ abstract class ThrottleFunction( * 移除指定键的节流记录 * @param key 要移除的节流记录的键 */ - fun removeKey(key: Any) { + open fun removeKey(key: Any) { throttleMap.remove(key) } @@ -47,7 +47,7 @@ abstract class ThrottleFunction( * 清除所有节流记录 * 清空节流映射表中的所有记录 */ - fun clearAll() { + open fun clearAll() { throttleMap.clear() } @@ -64,6 +64,23 @@ abstract class ThrottleFunction( private val lastExecuteTime = AtomicLong(Long.MIN_VALUE) + /** + * 重置节流状态。 + * + * Singleton 的状态存于 [lastExecuteTime] 而非父类的 throttleMap, + * 因此必须覆写,否则调用父类实现对本类毫无效果。 + */ + override fun clearAll() { + lastExecuteTime.set(Long.MIN_VALUE) + } + + /** + * 重置节流状态。Singleton 无键,任何 key 都等价于重置自身。 + */ + override fun removeKey(key: Any) { + clearAll() + } + fun canExecute(delay: Long = this.delay): Boolean { return canExecute(Unit, delay) } diff --git a/module/basic/basic-configuration/src/main/java/com/electronwill/nightconfig/core/conversion/ObjectConverter.java b/module/basic/basic-configuration/src/main/java/com/electronwill/nightconfig/core/conversion/ObjectConverter.java index 017239305..f587cd421 100644 --- a/module/basic/basic-configuration/src/main/java/com/electronwill/nightconfig/core/conversion/ObjectConverter.java +++ b/module/basic/basic-configuration/src/main/java/com/electronwill/nightconfig/core/conversion/ObjectConverter.java @@ -315,10 +315,13 @@ private void convertToObject(UnmodifiableConfig config, Object object, Class // --- Writes the value to the object's field, converting it if needed --- Class fieldType = field.getType(); + // 元素级枚举转换同样遵循字段上的 @SpecEnum,与单值字段的行为保持一致 + SpecEnum fieldSpecEnum = field.getAnnotation(SpecEnum.class); + EnumGetMethod fieldEnumGetMethod = (fieldSpecEnum == null) ? EnumGetMethod.NAME_IGNORECASE : fieldSpecEnum.method(); try { if ((value instanceof UnmodifiableConfig || value instanceof Map) && Map.class.isAssignableFrom(fieldType)) { // --- Reads as a map while preserving the declared map and generic value types --- - Map converted = convertMap(value, field.getGenericType(), fieldType); + Map converted = convertMap(value, field.getGenericType(), fieldType, fieldEnumGetMethod); AnnotationUtils.checkField(field, converted); field.set(object, converted); } else if ((value instanceof UnmodifiableConfig || value instanceof Map) && !(fieldType.isAssignableFrom(value.getClass()))) { @@ -337,7 +340,7 @@ private void convertToObject(UnmodifiableConfig config, Object object, Class } } else if (value instanceof Collection && Collection.class.isAssignableFrom(fieldType)) { // --- Reads as a collection while preserving the declared collection and generic element types --- - Collection converted = convertCollection((Collection) value, field.getGenericType(), fieldType); + Collection converted = convertCollection((Collection) value, field.getGenericType(), fieldType, fieldEnumGetMethod); AnnotationUtils.checkField(field, converted); field.set(object, converted); } else { @@ -365,15 +368,28 @@ private void convertToObject(UnmodifiableConfig config, Object object, Class } private Collection convertCollection(Collection source, Type declaredType, Class declaredClass) { + return convertCollection(source, declaredType, declaredClass, EnumGetMethod.NAME_IGNORECASE); + } + + private Collection convertCollection(Collection source, Type declaredType, Class declaredClass, EnumGetMethod enumGetMethod) { Type elementType = collectionElementType(declaredType); Collection destination = createCollection(declaredClass, elementType, source.size()); for (Object element : source) { - destination.add(convertValue(element, elementType)); + destination.add(convertValue(element, elementType, enumGetMethod)); } return destination; } private Object convertValue(Object value, Type declaredType) { + return convertValue(value, declaredType, EnumGetMethod.NAME_IGNORECASE); + } + + /** + * 按声明的泛型类型递归还原元素值。 + * + * @param enumGetMethod 枚举取值方式,来自字段上的 {@link SpecEnum};未标注时为 {@link EnumGetMethod#NAME_IGNORECASE} + */ + private Object convertValue(Object value, Type declaredType, EnumGetMethod enumGetMethod) { if (value == null) { return null; } @@ -382,10 +398,10 @@ private Object convertValue(Object value, Type declaredType) { return value; } if (value instanceof Collection && Collection.class.isAssignableFrom(declaredClass)) { - return convertCollection((Collection) value, declaredType, declaredClass); + return convertCollection((Collection) value, declaredType, declaredClass, enumGetMethod); } if ((value instanceof UnmodifiableConfig || value instanceof Map) && Map.class.isAssignableFrom(declaredClass)) { - return convertMap(value, declaredType, declaredClass); + return convertMap(value, declaredType, declaredClass, enumGetMethod); } if ((value instanceof UnmodifiableConfig || value instanceof Map) && isStructuredObjectType(declaredClass)) { Object elementObject = createInstance(declaredClass); @@ -399,8 +415,15 @@ private Object convertValue(Object value, Type declaredType) { if (unwrapped == null || declaredClass.isAssignableFrom(unwrapped.getClass())) { return unwrapped; } + // 全局注册的转换器,与字段级路径保持一致: + // 否则 List / Map 这类声明会在下方直接抛出 InvalidValueException, + // 而同类型的单值字段却能正常转换。 + Converter registryConverter = ConverterRegistry.INSTANCE.getConverter(declaredClass); + if (registryConverter != null) { + return registryConverter.convertToField(unwrapped); + } if (declaredClass.isEnum()) { - return EnumGetMethod.NAME_IGNORECASE.get(unwrapped, (Class) declaredClass); + return enumGetMethod.get(unwrapped, (Class) declaredClass); } if (unwrapped instanceof Number) { Object number = convertNumber((Number) unwrapped, declaredClass); @@ -425,6 +448,10 @@ private boolean isStructuredObjectType(Class type) { } private Map convertMap(Object source, Type declaredType, Class declaredClass) { + return convertMap(source, declaredType, declaredClass, EnumGetMethod.NAME_IGNORECASE); + } + + private Map convertMap(Object source, Type declaredType, Class declaredClass, EnumGetMethod enumGetMethod) { Map sourceMap; if (source instanceof UnmodifiableConfig) { sourceMap = ((UnmodifiableConfig) source).valueMap(); @@ -449,7 +476,7 @@ private Map convertMap(Object source, Type declaredType, Class destination = createMap(declaredClass); for (Map.Entry entry : sourceMap.entrySet()) { - destination.put(convertValue(entry.getKey(), keyType), convertValue(entry.getValue(), valueType)); + destination.put(convertValue(entry.getKey(), keyType, enumGetMethod), convertValue(entry.getValue(), valueType, enumGetMethod)); } return destination; } diff --git a/module/basic/basic-submit-chain/src/main/kotlin/taboolib/expansion/Chain.kt b/module/basic/basic-submit-chain/src/main/kotlin/taboolib/expansion/Chain.kt index c836e9982..ba9104413 100644 --- a/module/basic/basic-submit-chain/src/main/kotlin/taboolib/expansion/Chain.kt +++ b/module/basic/basic-submit-chain/src/main/kotlin/taboolib/expansion/Chain.kt @@ -1,6 +1,7 @@ package taboolib.expansion import kotlinx.coroutines.* +import taboolib.common.PrimitiveIO import taboolib.common.platform.function.submit import taboolib.expansion.DispatcherType.ASYNC import taboolib.expansion.DispatcherType.SYNC @@ -87,7 +88,20 @@ open class Chain(val chain: suspend Chain.() -> R) { when (cause) { null -> Unit is CancellationException -> future.cancel(false) - else -> future.completeExceptionally(cause) + else -> { + // 由 launch 改为 async 后,链中异常存入 Deferred 不再经过 CoroutineExceptionHandler, + // 而 submitChain { } 的常见用法是 fire-and-forget、不持有返回的 future, + // 此时异常会彻底静默、无从排查,因此这里统一记录一次。 + // + // 取舍:CompletableFuture 无法探测异常是否已被调用方消费, + // 因此自行处理异常的调用方会额外看到一条日志。相比让异常静默丢失,这是可接受的代价。 + if (future.completeExceptionally(cause)) { + runCatching { + PrimitiveIO.warning("Uncaught exception in submit chain: ${cause.message ?: cause.javaClass.name}") + cause.printStackTrace() + } + } + } } } future.whenComplete { _, _ -> diff --git a/module/basic/basic-submit-chain/src/main/kotlin/taboolib/expansion/RepeatChainable.kt b/module/basic/basic-submit-chain/src/main/kotlin/taboolib/expansion/RepeatChainable.kt index 4ff9f7eb6..9f3f7ffa2 100644 --- a/module/basic/basic-submit-chain/src/main/kotlin/taboolib/expansion/RepeatChainable.kt +++ b/module/basic/basic-submit-chain/src/main/kotlin/taboolib/expansion/RepeatChainable.kt @@ -48,6 +48,9 @@ internal suspend fun executeRepeat( return@suspendCancellableCoroutine } taskReference.set(task) + // 覆盖竞态:任务可能在 taskReference 赋值之前就已完成(now = true 时同步执行), + // 此时上面的完成回调取到的 taskReference 还是空,无法取消。 + // 这里补一次检查,cancel 是幂等的,重复调用无副作用。请勿删除。 if (completed.get()) { task.cancel() } From 5c19dc4d8ea24593e0b96f97696d064249207065 Mon Sep 17 00:00:00 2001 From: FxRayHughes Date: Sun, 26 Jul 2026 22:58:35 +0800 Subject: [PATCH 34/37] =?UTF-8?q?docs(review):=20=E8=A1=A5=E5=85=85?= =?UTF-8?q?=E7=A0=B4=E5=9D=8F=E6=80=A7=E5=8F=98=E6=9B=B4=E7=9A=84=E5=85=BC?= =?UTF-8?q?=E5=AE=B9=E6=80=A7=E8=AF=B4=E6=98=8E=E5=B9=B6=E7=BB=9F=E4=B8=80?= =?UTF-8?q?=E8=BD=AC=E8=AF=91=E6=96=B9=E6=B3=95=E5=91=BD=E5=90=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 依据 #707 / #712 / #714 评审要求,把行为变更写进对应 API 的注释: 1. getClasses() 返回不可修改集合(#707-2) 该集合全局缓存,允许外部增删会破坏其他访问者看到的类视图。 曾对返回值增删的调用方会收到 UnsupportedOperationException。 2. 命令树在注册时构建一次并复用(#707-1) 签名未变但语义变了:literal(*运行时列表) 这类在构建期读取可变状态的写法, 配置热重载后不再自动反映新值(此前依赖「每次执行都重建」而能生效)。 动态内容应改用 dynamic { suggestion { ... } }。 3. openVirtualInventory 要求在持有查看者的线程上调用(#712-3) 此前在非主线程调用会把事件 submit 出去、函数照常返回, 但内部需要发包并写入 playerRemoteInventoryMap,异步执行本就不安全。 4. takeItem / checkItem 的边界翻转(#712-4) 原子化本身已在 PR 描述中说明,但两处边界未提: amount = 0 现在视为成功返回 true(早期返回 false),checkItem 同理。 5. 补回 Mojang Mapping 分支被删除的背景注释(#714-5) 原注释「Spigot.Fullname 交给 Paper PluginRemapper 转译」被本 PR 推翻, 但未说明原因。现说明 PluginRemapper 只覆盖插件本体的类引用, TabooLib 运行期动态生成/转译的类不在其范围内。 6. 统一转译方法命名(#714-4) translateMojangToSpigotOrKeepRuntime 与 translateMojangToRuntimeOrKeep 后缀顺序相反、读起来易混。新增 translateMojangToSpigotOrKeep 作为正名, 旧名保留为废弃转发以兼容既有调用方。 --- .../common/platform/command/CommandRegister.kt | 8 ++++++++ .../common/inject/ClassVisitorHandler.java | 7 ++++++- .../module/nms/remap/RemapTranslation.kt | 16 ++++++++++++++-- .../module/ui/virtual/VirtualInventoryFactory.kt | 7 ++++++- .../kotlin/taboolib/platform/util/ItemMatcher.kt | 13 +++++++++++-- 5 files changed, 45 insertions(+), 6 deletions(-) diff --git a/common-platform-api/src/main/kotlin/taboolib/common/platform/command/CommandRegister.kt b/common-platform-api/src/main/kotlin/taboolib/common/platform/command/CommandRegister.kt index e17f0e843..b0340fd93 100644 --- a/common-platform-api/src/main/kotlin/taboolib/common/platform/command/CommandRegister.kt +++ b/common-platform-api/src/main/kotlin/taboolib/common/platform/command/CommandRegister.kt @@ -6,6 +6,14 @@ import taboolib.common.platform.function.registerCommand internal data class CommandHandlers(val executor: CommandExecutor, val completer: CommandCompleter) +/** + * 构建命令的执行器与补全器。 + * + * **注意**:命令树在注册时构建一次并全程复用,不再于每次执行 / 每次 Tab 补全时重建。 + * 因此 `literal(*运行时列表)` 这类在构建期读取可变状态的写法, + * 在配置热重载后不会自动反映新值(此前依赖「每次重建」而能生效)。 + * 需要动态内容请改用 `dynamic { suggestion { ... } }`,其回调在每次补全时执行。 + */ internal fun createCommandHandlers(newParser: Boolean, commandBuilder: CommandBase.() -> Unit): CommandHandlers { val commandBase = CommandBase().also(commandBuilder) return CommandHandlers( diff --git a/common-util/src/main/java/taboolib/common/inject/ClassVisitorHandler.java b/common-util/src/main/java/taboolib/common/inject/ClassVisitorHandler.java index d7e3684b6..abb70355a 100644 --- a/common-util/src/main/java/taboolib/common/inject/ClassVisitorHandler.java +++ b/common-util/src/main/java/taboolib/common/inject/ClassVisitorHandler.java @@ -49,7 +49,12 @@ static void init() { } /** - * 获取能够被 ClassVisitor 访问到的所有类 + * 获取能够被 ClassVisitor 访问到的所有类。 + *

+ * 返回的集合不可修改:该集合在首次调用时构建并全局缓存, + * 若允许外部增删会破坏其他访问者看到的类视图,故包装为 + * {@link Collections#unmodifiableSet}。曾对返回值执行增删的调用方 + * 会收到 {@link UnsupportedOperationException},请改为在本地副本上操作。 */ public static Set getClasses() { return getOrInitializeClasses(ClassVisitorHandler::scanClasses); diff --git a/module/bukkit-nms/src/main/kotlin/taboolib/module/nms/remap/RemapTranslation.kt b/module/bukkit-nms/src/main/kotlin/taboolib/module/nms/remap/RemapTranslation.kt index 744da0c23..233a3103a 100644 --- a/module/bukkit-nms/src/main/kotlin/taboolib/module/nms/remap/RemapTranslation.kt +++ b/module/bukkit-nms/src/main/kotlin/taboolib/module/nms/remap/RemapTranslation.kt @@ -113,8 +113,12 @@ open class RemapTranslation : Remapper() { } else { // 如果是非 Mojang Mapping 环境,且这里是 Mojang.Fullname,则:尝试获取 Spigot.Fullname 并返回,如果获取不到,那么 key 就是 Spigot.Fullname 本身 if (!MinecraftVersion.isMojangMapping) { - translateMojangToSpigotOrKeepRuntime(key) + translateMojangToSpigotOrKeep(key) } else { + // Mojang Mapping 环境下曾认为「Spigot.Fullname 与 Mojang.Fullname 都无需处理, + // 前者交给 Paper PluginRemapper 转译」。但 PluginRemapper 只处理插件本体的类引用, + // TabooLib 在运行期动态生成 / 转译的类不在其覆盖范围内, + // 因此这里仍需自行回落到运行时真正可加载的名称。 translateMojangToRuntimeOrKeep(key) } } @@ -138,7 +142,7 @@ open class RemapTranslation : Remapper() { /** * 将 Mojang 类名转为 Spigot 类名,运行时已有类名优先保留。 */ - fun translateMojangToSpigotOrKeepRuntime(key: String): String { + fun translateMojangToSpigotOrKeep(key: String): String { val runtimeName = key.replace('/', '.') val spigotName = findMojangToSpigotName(key) ?: return key // 只有映射表确实准备改名时才检查运行时类,避免在普通路径上反复触发类查找。 @@ -151,6 +155,14 @@ open class RemapTranslation : Remapper() { return spigotName } + /** + * 与 [translateMojangToSpigotOrKeep] 等价,保留旧名以兼容既有调用方。 + */ + @Deprecated("命名已与 translateMojangToRuntimeOrKeep 统一", ReplaceWith("translateMojangToSpigotOrKeep(key)")) + fun translateMojangToSpigotOrKeepRuntime(key: String): String { + return translateMojangToSpigotOrKeep(key) + } + /** * 将 Mojang 类名转为 Runtime 类名,运行时已有类名优先保留。 * diff --git a/module/bukkit/bukkit-ui/src/main/kotlin/taboolib/module/ui/virtual/VirtualInventoryFactory.kt b/module/bukkit/bukkit-ui/src/main/kotlin/taboolib/module/ui/virtual/VirtualInventoryFactory.kt index 4347566e9..e6b3b6213 100644 --- a/module/bukkit/bukkit-ui/src/main/kotlin/taboolib/module/ui/virtual/VirtualInventoryFactory.kt +++ b/module/bukkit/bukkit-ui/src/main/kotlin/taboolib/module/ui/virtual/VirtualInventoryFactory.kt @@ -27,7 +27,12 @@ fun Inventory.virtualize(storageContents: List? = null): VirtualInven } /** - * 使玩家打开虚拟页面 + * 使玩家打开虚拟页面。 + * + * **行为变更**:该方法现在要求在持有查看者的线程上调用,否则抛出 [IllegalStateException]。 + * 此前在非主线程调用时会把事件调用 `submit` 出去、函数照常返回 [RemoteInventory], + * 但内部需要发包并写入 `playerRemoteInventoryMap`,异步执行本就不安全。 + * 异步场景请改用 `openVirtualInventoryAsync()`、`HumanEntity.openMenu()` 或 `Entity.runTask()`。 */ fun HumanEntity.openVirtualInventory(inventory: VirtualInventory, updateId: Boolean = true): RemoteInventory { check(isOwnedByCurrentRegion()) { diff --git a/module/bukkit/bukkit-util/src/main/kotlin/taboolib/platform/util/ItemMatcher.kt b/module/bukkit/bukkit-util/src/main/kotlin/taboolib/platform/util/ItemMatcher.kt index 65105f574..727f0163f 100644 --- a/module/bukkit/bukkit-util/src/main/kotlin/taboolib/platform/util/ItemMatcher.kt +++ b/module/bukkit/bukkit-util/src/main/kotlin/taboolib/platform/util/ItemMatcher.kt @@ -43,7 +43,10 @@ fun Player.checkItem(item: ItemStack, amount: Int = 1, remove: Boolean = false): } /** - * 检查背包中的特定物品是否达到特定数量 + * 检查背包中的特定物品是否达到特定数量。 + * + * `remove = true` 时改为委托 [takeItem],因此继承其原子语义: + * 数量不足时不扣除任何物品;`amount = 0` 视为成功返回 true(早期返回 false)。 * * @param item 物品 * @param amount 检查数量 @@ -85,7 +88,13 @@ fun Inventory.hasItem(amount: Int = 1, matcher: (itemStack: ItemStack) -> Boolea } /** - * 移除背包中特定数量的符合特定规则的物品 + * 移除背包中特定数量的符合特定规则的物品。 + * + * 该操作是原子的:**数量不足时不会扣除任何物品**,`takeList` 保持不变并返回 false。 + * 早期实现会先扣一部分、把已扣物品装进 `takeList` 再返回 true。 + * + * 另注意两处边界:`amount = 0` 时视为成功并返回 true(早期返回 false); + * 负数同理按「无需扣除」处理。 * * @param matcher 规则 * @param savedItemStack 记录拿取物品的列表 From e772a70914731ae0a1c1fbab9b15985b3b69a4b9 Mon Sep 17 00:00:00 2001 From: FxRayHughes Date: Sun, 26 Jul 2026 23:03:18 +0800 Subject: [PATCH 35/37] =?UTF-8?q?refactor(platform):=20=E6=8A=BD=E5=8F=96?= =?UTF-8?q?=E5=9B=9B=E5=B9=B3=E5=8F=B0=20executor=20=E7=9A=84=E5=85=AC?= =?UTF-8?q?=E5=85=B1=E7=8A=B6=E6=80=81=E6=9C=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 依据 #715-5 / #716-3 评审建议(评审明确「建议 #720 整合时统一处理」): Velocity / Application / AfyBroker / Hytale 四个 executor 存在高度一致的重复实现—— NEW/RUNNING/STOPPED 状态机、pending/active 双集合、停止后拒绝提交、 failure 累加 + addSuppressed + rethrow、命名线程工厂、DISABLE 阶段注册停止任务。 #715 与 #716 合并后会同时存在四份。 在 common-platform-api 新增 PlatformExecutorSupport,抽出: 状态枚举、任务登记表、「取消先于绑定」竞态协调器、失败收集器、 命名线程工厂、幂等 PlatformTask 包装,以及统一三个平台各自 inline 函数的 runReportingFailure。删除 AfyBrokerExecutorLifecycle.kt(173 行并入基类)。 净减少 422 行。 四个平台各自只保留调度差异:Velocity 的 buildTask 三分支、Application 的 ScheduledExecutorService 直调、AfyBroker 的 once/repeated × sync/async 四路、 Hytale 的 SCHEDULED_EXECUTOR 与 ScheduledFuture。 API 兼容性: - 四个执行器的无参构造器签名不变,PlatformFactory 的 cls.newInstance() 反射路径不受影响。 - 基类刻意不实现 PlatformExecutor——PlatformFactory.inject() 通过 cls.interfaces 只识别直接实现的接口,若把接口挪到基类会导致平台服务注册失效。 四个执行器仍各自显式实现该接口。 - scheduledTask 公开字段、platformTask() 返回类型、*PlatformTask 嵌套类名均不变。 顺带统一 #715-3 的写法不一致:registerLifeCycleTask 的两种调用形式 (TabooLib.registerLifeCycleTask 与 platform.function.registerLifeCycleTask) 现在只在基类中出现一次。 --- .../service/PlatformExecutorSupport.kt | 19 +- .../taboolib/platform/AfyBrokerExecutor.kt | 91 +++------ .../platform/AfyBrokerExecutorLifecycle.kt | 173 ------------------ .../AfyBrokerExecutorLifecycleTest.kt | 38 ++-- .../kotlin/taboolib/platform/AppExecutor.kt | 56 ++---- .../platform/ApplicationPlatformTest.kt | 10 +- .../taboolib/platform/HytaleExecutor.kt | 143 +++------------ .../taboolib/platform/VelocityExecutor.kt | 149 +++------------ .../taboolib/platform/VelocityExecutorTest.kt | 9 +- 9 files changed, 133 insertions(+), 555 deletions(-) delete mode 100644 platform/platform-afybroker/src/main/kotlin/taboolib/platform/AfyBrokerExecutorLifecycle.kt diff --git a/common-platform-api/src/main/kotlin/taboolib/common/platform/service/PlatformExecutorSupport.kt b/common-platform-api/src/main/kotlin/taboolib/common/platform/service/PlatformExecutorSupport.kt index 953c18b2e..e682c1104 100644 --- a/common-platform-api/src/main/kotlin/taboolib/common/platform/service/PlatformExecutorSupport.kt +++ b/common-platform-api/src/main/kotlin/taboolib/common/platform/service/PlatformExecutorSupport.kt @@ -65,7 +65,7 @@ class PlatformFailureCollector { private var failure: Throwable? = null /** 执行动作并捕获异常 */ - inline fun collect(action: () -> Unit) { + fun collect(action: () -> Unit) { try { action() } catch (ex: Throwable) { @@ -304,14 +304,7 @@ abstract class PlatformExecutorSupport(private val executorName: String * 必须由子类在自身 init 块的末尾调用,以确保子类字段已完成初始化。 */ protected fun registerStopTaskOnDisable() { - registerLifeCycleTask(LifeCycle.DISABLE, 2) { stop() } - } - - /** - * 停止执行器,幂等 - */ - open fun stop() { - stopTasks() + registerLifeCycleTask(LifeCycle.DISABLE, 2) { stopTasks() } } /** @@ -376,11 +369,11 @@ abstract class PlatformExecutorSupport(private val executorName: String return registry.activeCount() } - /** 调度任务,由各平台实现 */ - protected abstract fun launchTask(task: T) + /** 调度任务,由各平台实现;若平台不使用任务队列则无需覆写 */ + protected open fun launchTask(task: T) {} - /** 取消任务,由各平台实现 */ - protected abstract fun cancelTask(task: T) + /** 取消任务,由各平台实现;若平台不使用任务队列则无需覆写 */ + protected open fun cancelTask(task: T) {} /** 判断任务是否已取消,用于启动时跳过已取消的等待任务 */ protected open fun isTaskCancelled(task: T): Boolean = false diff --git a/platform/platform-afybroker/src/main/kotlin/taboolib/platform/AfyBrokerExecutor.kt b/platform/platform-afybroker/src/main/kotlin/taboolib/platform/AfyBrokerExecutor.kt index ab20a1289..563d2a0d8 100644 --- a/platform/platform-afybroker/src/main/kotlin/taboolib/platform/AfyBrokerExecutor.kt +++ b/platform/platform-afybroker/src/main/kotlin/taboolib/platform/AfyBrokerExecutor.kt @@ -5,19 +5,21 @@ import net.afyer.afybroker.server.scheduler.ScheduledTask import taboolib.common.Inject import taboolib.common.LifeCycle import taboolib.common.PrimitiveIO -import taboolib.common.TabooLib import taboolib.common.platform.Awake import taboolib.common.platform.Platform import taboolib.common.platform.PlatformSide +import taboolib.common.platform.service.CloseablePlatformTask import taboolib.common.platform.service.PlatformExecutor +import taboolib.common.platform.service.PlatformExecutorSupport +import taboolib.common.platform.service.PlatformTaskCancellation +import taboolib.common.platform.service.PlatformTaskRegistration +import taboolib.common.platform.service.runReportingFailure import java.io.Closeable -import java.util.concurrent.RejectedExecutionException import java.util.concurrent.TimeUnit -import java.util.concurrent.atomic.AtomicBoolean /** * TabooLib - * taboolib.platform.AppExecutor + * taboolib.platform.AfyBrokerExecutor * * @author CziSKY * @since 2021/6/16 0:43 @@ -25,56 +27,22 @@ import java.util.concurrent.atomic.AtomicBoolean @Awake @Inject @PlatformSide(Platform.AFYBROKER) -class AfyBrokerExecutor : PlatformExecutor { - - private val tasks = AfyBrokerTaskRegistry() +class AfyBrokerExecutor : PlatformExecutorSupport("AfyBrokerExecutor"), PlatformExecutor { init { - TabooLib.registerLifeCycleTask(LifeCycle.DISABLE, 2) { stop() } + registerStopTaskOnDisable() } @Awake(LifeCycle.ENABLE) override fun start() { - executeAll(tasks.start()) + startTasks() } fun stop() { - cancelAll(tasks.stop()) + stopTasks() } - private fun executeAll(pendingTasks: List) { - var failure: Throwable? = null - pendingTasks.forEach { task -> - try { - execute(task) - } catch (ex: Throwable) { - if (failure == null) { - failure = ex - } else { - failure?.addSuppressed(ex) - } - } - } - failure?.let { throw it } - } - - private fun cancelAll(activeTasks: List) { - var failure: Throwable? = null - activeTasks.forEach { task -> - try { - task.cancel() - } catch (ex: Throwable) { - if (failure == null) { - failure = ex - } else { - failure?.addSuppressed(ex) - } - } - } - failure?.let { throw it } - } - - private fun execute(task: AfyBrokerRunningTask) { + override fun launchTask(task: AfyBrokerRunningTask) { if (task.runnable.now) { task.execute() } else { @@ -82,9 +50,13 @@ class AfyBrokerExecutor : PlatformExecutor { } } + override fun cancelTask(task: AfyBrokerRunningTask) { + task.cancel() + } + class AfyBrokerRunningTask(val runnable: PlatformExecutor.PlatformRunnable) { - private val cancellation = AfyBrokerTaskCancellation { it.cancel() } + private val cancellation = PlatformTaskCancellation { it.cancel() } private var onCancelled: () -> Unit = {} private var onCompleted: () -> Unit = {} @@ -122,7 +94,7 @@ class AfyBrokerExecutor : PlatformExecutor { return if (async) { Broker.getScheduler().schedule(AfyBrokerPlugin.getInstance(), { if (!cancellation.isCancelled()) { - runAfyBrokerDispatch(::reportTaskFailure, onCompleted) { + runReportingFailure(::reportTaskFailure, onCompleted) { Broker.getScheduler().runAsync(AfyBrokerPlugin.getInstance()) { executeUserTask(completeAfterRun = true) } @@ -142,7 +114,7 @@ class AfyBrokerExecutor : PlatformExecutor { return if (async) { Broker.getScheduler().schedule(AfyBrokerPlugin.getInstance(), { if (!cancellation.isCancelled()) { - runAfyBrokerDispatch(::reportTaskFailure, ::cancel) { + runReportingFailure(::reportTaskFailure, ::cancel) { Broker.getScheduler().runAsync(AfyBrokerPlugin.getInstance()) { executeUserTask(completeAfterRun = false) } @@ -159,7 +131,7 @@ class AfyBrokerExecutor : PlatformExecutor { private fun executeUserTask(completeAfterRun: Boolean) { try { cancellation.runIfActive { - runAfyBrokerTask(::reportTaskFailure) { + runReportingFailure(::reportTaskFailure) { runnable.executor(platformTask()) } } @@ -193,29 +165,20 @@ class AfyBrokerExecutor : PlatformExecutor { override fun submit(runnable: PlatformExecutor.PlatformRunnable): PlatformExecutor.PlatformTask { val task = AfyBrokerRunningTask(runnable) task.observe( - onCancelled = { tasks.remove(task) }, - onCompleted = { tasks.remove(task) } + onCancelled = { taskFinished(task) }, + onCompleted = { taskFinished(task) } ) val platformTask = task.platformTask() - when (tasks.register(task)) { - AfyBrokerTaskRegistration.PENDING -> Unit - AfyBrokerTaskRegistration.ACTIVE -> execute(task) - AfyBrokerTaskRegistration.REJECTED -> { + when (registerTask(task)) { + PlatformTaskRegistration.PENDING -> Unit + PlatformTaskRegistration.ACTIVE -> launchTask(task) + PlatformTaskRegistration.REJECTED -> { task.cancel() - throw RejectedExecutionException("AfyBrokerExecutor has been stopped") + rejectStopped() } } return platformTask } - class BrokerPlatformTask(val runnable: Closeable) : PlatformExecutor.PlatformTask { - - private val cancelled = AtomicBoolean() - - override fun cancel() { - if (cancelled.compareAndSet(false, true)) { - runnable.close() - } - } - } + class BrokerPlatformTask(runnable: Closeable) : CloseablePlatformTask(runnable) } diff --git a/platform/platform-afybroker/src/main/kotlin/taboolib/platform/AfyBrokerExecutorLifecycle.kt b/platform/platform-afybroker/src/main/kotlin/taboolib/platform/AfyBrokerExecutorLifecycle.kt deleted file mode 100644 index 9899ba0a1..000000000 --- a/platform/platform-afybroker/src/main/kotlin/taboolib/platform/AfyBrokerExecutorLifecycle.kt +++ /dev/null @@ -1,173 +0,0 @@ -package taboolib.platform - -internal enum class AfyBrokerExecutorState { - NEW, - RUNNING, - STOPPED -} - -internal enum class AfyBrokerTaskRegistration { - PENDING, - ACTIVE, - REJECTED -} - -internal class AfyBrokerTaskRegistry { - - private val lock = Any() - private val pending = LinkedHashSet() - private val active = LinkedHashSet() - private var state = AfyBrokerExecutorState.NEW - - fun register(task: T): AfyBrokerTaskRegistration { - return synchronized(lock) { - when (state) { - AfyBrokerExecutorState.NEW -> { - pending += task - AfyBrokerTaskRegistration.PENDING - } - AfyBrokerExecutorState.RUNNING -> { - active += task - AfyBrokerTaskRegistration.ACTIVE - } - AfyBrokerExecutorState.STOPPED -> AfyBrokerTaskRegistration.REJECTED - } - } - } - - fun start(): List { - return synchronized(lock) { - if (state != AfyBrokerExecutorState.NEW) { - return@synchronized emptyList() - } - state = AfyBrokerExecutorState.RUNNING - val tasks = pending.toList() - pending.clear() - active += tasks - tasks - } - } - - fun remove(task: T): Boolean { - return synchronized(lock) { - pending.remove(task) || active.remove(task) - } - } - - fun stop(): List { - return synchronized(lock) { - if (state == AfyBrokerExecutorState.STOPPED) { - return@synchronized emptyList() - } - state = AfyBrokerExecutorState.STOPPED - val tasks = ArrayList(pending.size + active.size) - tasks += pending - tasks += active - pending.clear() - active.clear() - tasks - } - } - - fun state(): AfyBrokerExecutorState { - return synchronized(lock) { state } - } - - fun pendingCount(): Int { - return synchronized(lock) { pending.size } - } - - fun activeCount(): Int { - return synchronized(lock) { active.size } - } -} - -internal class AfyBrokerTaskCancellation(private val cancelDelegate: (T) -> Unit) { - - private val lock = Any() - - @Volatile - private var cancelled = false - private var delegate: T? = null - - fun bind(value: T) { - val cancelNow = synchronized(lock) { - val current = delegate - check(current == null || current === value) { "Scheduled task is already bound" } - if (current == null) { - delegate = value - cancelled - } else { - false - } - } - if (cancelNow) { - cancelDelegate(value) - } - } - - fun cancel(afterCancellation: () -> Unit = {}): Boolean { - val bound = synchronized(lock) { - if (cancelled) { - return false - } - cancelled = true - delegate - } - try { - if (bound != null) { - cancelDelegate(bound) - } - } finally { - afterCancellation() - } - return true - } - - fun isCancelled(): Boolean { - return cancelled - } - - fun runIfActive(action: () -> Unit): Boolean { - if (cancelled) { - return false - } - action() - return true - } -} - -internal inline fun runAfyBrokerDispatch( - reporter: (Throwable) -> Unit, - cleanup: () -> Unit, - action: () -> T, -): T { - try { - return action() - } catch (ex: Throwable) { - try { - reporter(ex) - } catch (reportingFailure: Throwable) { - ex.addSuppressed(reportingFailure) - } - try { - cleanup() - } catch (cleanupFailure: Throwable) { - ex.addSuppressed(cleanupFailure) - } - throw ex - } -} - -internal inline fun runAfyBrokerTask(reporter: (Throwable) -> Unit, action: () -> T): T { - try { - return action() - } catch (ex: Throwable) { - try { - reporter(ex) - } catch (reportingFailure: Throwable) { - ex.addSuppressed(reportingFailure) - } - throw ex - } -} diff --git a/platform/platform-afybroker/src/test/kotlin/taboolib/platform/AfyBrokerExecutorLifecycleTest.kt b/platform/platform-afybroker/src/test/kotlin/taboolib/platform/AfyBrokerExecutorLifecycleTest.kt index a5c02dbf3..7adfb4642 100644 --- a/platform/platform-afybroker/src/test/kotlin/taboolib/platform/AfyBrokerExecutorLifecycleTest.kt +++ b/platform/platform-afybroker/src/test/kotlin/taboolib/platform/AfyBrokerExecutorLifecycleTest.kt @@ -4,11 +4,17 @@ import net.afyer.afybroker.server.scheduler.ScheduledTask import org.junit.jupiter.api.Assertions.assertDoesNotThrow import org.junit.jupiter.api.Assertions.assertEquals import org.junit.jupiter.api.Assertions.assertFalse +import org.junit.jupiter.api.Assertions.assertNull import org.junit.jupiter.api.Assertions.assertSame import org.junit.jupiter.api.Assertions.assertThrows import org.junit.jupiter.api.Assertions.assertTrue import org.junit.jupiter.api.Test import taboolib.common.platform.service.PlatformExecutor +import taboolib.common.platform.service.PlatformExecutorState +import taboolib.common.platform.service.PlatformTaskCancellation +import taboolib.common.platform.service.PlatformTaskRegistration +import taboolib.common.platform.service.PlatformTaskRegistry +import taboolib.common.platform.service.runReportingFailure import java.io.Closeable import java.util.concurrent.RejectedExecutionException import java.util.concurrent.atomic.AtomicReference @@ -18,7 +24,7 @@ class AfyBrokerExecutorLifecycleTest { @Test fun `cancel before binding cancels delegate exactly once`() { val delegate = ManualDelegate() - val cancellation = AfyBrokerTaskCancellation { it.cancel() } + val cancellation = PlatformTaskCancellation { it.cancel() } assertTrue(cancellation.cancel()) assertFalse(cancellation.cancel()) @@ -33,7 +39,7 @@ class AfyBrokerExecutorLifecycleTest { fun `cancellation cleanup runs even when delegate throws`() { val failure = IllegalStateException("cancel failed") val delegate = ManualDelegate() - val cancellation = AfyBrokerTaskCancellation { throw failure } + val cancellation = PlatformTaskCancellation { throw failure } var cleanupCount = 0 cancellation.bind(delegate) @@ -71,7 +77,7 @@ class AfyBrokerExecutorLifecycleTest { @Test fun `binding before cancel is safe and idempotent`() { val delegate = ManualDelegate() - val cancellation = AfyBrokerTaskCancellation { it.cancel() } + val cancellation = PlatformTaskCancellation { it.cancel() } cancellation.bind(delegate) assertEquals(0, delegate.cancelCount) @@ -83,7 +89,7 @@ class AfyBrokerExecutorLifecycleTest { @Test fun `cancelled task gate rejects later execution`() { - val cancellation = AfyBrokerTaskCancellation { it.cancel() } + val cancellation = PlatformTaskCancellation { it.cancel() } var executions = 0 cancellation.cancel() @@ -94,34 +100,34 @@ class AfyBrokerExecutorLifecycleTest { @Test fun `registry moves pending tasks to active and completes them`() { - val registry = AfyBrokerTaskRegistry() + val registry = PlatformTaskRegistry() - assertEquals(AfyBrokerTaskRegistration.PENDING, registry.register("pending")) - assertEquals(AfyBrokerExecutorState.NEW, registry.state()) + assertEquals(PlatformTaskRegistration.PENDING, registry.register("pending")) + assertEquals(PlatformExecutorState.NEW, registry.state()) assertEquals(1, registry.pendingCount()) assertEquals(listOf("pending"), registry.start()) - assertEquals(AfyBrokerExecutorState.RUNNING, registry.state()) + assertEquals(PlatformExecutorState.RUNNING, registry.state()) assertEquals(0, registry.pendingCount()) assertEquals(1, registry.activeCount()) - assertEquals(AfyBrokerTaskRegistration.ACTIVE, registry.register("active")) + assertEquals(PlatformTaskRegistration.ACTIVE, registry.register("active")) assertTrue(registry.remove("pending")) assertEquals(1, registry.activeCount()) } @Test fun `stop drains pending and active tasks then rejects submissions`() { - val registry = AfyBrokerTaskRegistry() + val registry = PlatformTaskRegistry() registry.register("pending") registry.start() registry.register("active") assertEquals(listOf("pending", "active"), registry.stop()) - assertEquals(AfyBrokerExecutorState.STOPPED, registry.state()) + assertEquals(PlatformExecutorState.STOPPED, registry.state()) assertEquals(0, registry.pendingCount()) assertEquals(0, registry.activeCount()) - assertEquals(AfyBrokerTaskRegistration.REJECTED, registry.register("late")) - assertTrue(registry.stop().isEmpty()) + assertEquals(PlatformTaskRegistration.REJECTED, registry.register("late")) + assertNull(registry.stop()) assertTrue(registry.start().isEmpty()) } @@ -146,7 +152,7 @@ class AfyBrokerExecutorLifecycleTest { var cleanupCount = 0 val thrown = assertThrows(RejectedExecutionException::class.java) { - runAfyBrokerDispatch( + runReportingFailure( reporter = { reported = it }, cleanup = { cleanupCount++ @@ -169,7 +175,7 @@ class AfyBrokerExecutorLifecycleTest { var reported: Throwable? = null val thrown = assertThrows(IllegalStateException::class.java) { - runAfyBrokerTask({ reported = it }) { + runReportingFailure({ reported = it }) { throw failure } } @@ -184,7 +190,7 @@ class AfyBrokerExecutorLifecycleTest { val reporterFailure = IllegalArgumentException("reporter") val thrown = assertThrows(IllegalStateException::class.java) { - runAfyBrokerTask({ throw reporterFailure }) { + runReportingFailure({ throw reporterFailure }) { throw failure } } diff --git a/platform/platform-application/src/main/kotlin/taboolib/platform/AppExecutor.kt b/platform/platform-application/src/main/kotlin/taboolib/platform/AppExecutor.kt index efe0e5ac7..403f1e376 100644 --- a/platform/platform-application/src/main/kotlin/taboolib/platform/AppExecutor.kt +++ b/platform/platform-application/src/main/kotlin/taboolib/platform/AppExecutor.kt @@ -3,20 +3,20 @@ package taboolib.platform import taboolib.common.Inject import taboolib.common.LifeCycle import taboolib.common.PrimitiveIO -import taboolib.common.TabooLib import taboolib.common.platform.Awake import taboolib.common.platform.Platform import taboolib.common.platform.PlatformSide import taboolib.common.platform.service.PlatformExecutor +import taboolib.common.platform.service.PlatformExecutorSupport +import taboolib.common.platform.service.PlatformThreadFactory +import taboolib.common.platform.service.runReportingFailure import java.util.concurrent.CompletableFuture import java.util.concurrent.Executors import java.util.concurrent.Future -import java.util.concurrent.RejectedExecutionException import java.util.concurrent.ScheduledExecutorService import java.util.concurrent.ThreadFactory import java.util.concurrent.TimeUnit import java.util.concurrent.atomic.AtomicBoolean -import java.util.concurrent.atomic.AtomicInteger import java.util.concurrent.atomic.AtomicReference /** @@ -33,25 +33,19 @@ class AppExecutor private constructor( private val executor: ScheduledExecutorService, private val exceptionReporter: (Throwable) -> Unit, registerStopTask: Boolean, -) : PlatformExecutor { +) : PlatformExecutorSupport("AppExecutor"), PlatformExecutor { constructor() : this(createExecutor(), ::reportTaskException, true) - internal enum class State { - NEW, RUNNING, STOPPED - } - - private val state = AtomicReference(State.NEW) - init { if (registerStopTask) { - TabooLib.registerLifeCycleTask(LifeCycle.DISABLE, 2) { stop() } + registerStopTaskOnDisable() } } @Awake(LifeCycle.ENABLE) override fun start() { - state.compareAndSet(State.NEW, State.RUNNING) + startTasks() } override fun submit(runnable: PlatformExecutor.PlatformRunnable): PlatformExecutor.PlatformTask { @@ -76,21 +70,16 @@ class AppExecutor private constructor( } fun stop() { - if (state.getAndSet(State.STOPPED) != State.STOPPED) { - executor.shutdownNow() - } + stopTasks() } - internal fun currentState(): State = state.get() - - private fun rejectIfStopped() { - if (state.get() == State.STOPPED) { - throw RejectedExecutionException("AppExecutor has been stopped") - } + /** 关闭调度线程池,已提交的任务由线程池自身负责中断 */ + override fun onStopped() { + executor.shutdownNow() } private fun executeUserTask(task: AppPlatformTask, runnable: PlatformExecutor.PlatformRunnable) { - runAppTask(exceptionReporter) { runnable.executor(task) } + runReportingFailure(exceptionReporter) { runnable.executor(task) } } class AppPlatformTask() : PlatformExecutor.PlatformTask { @@ -134,24 +123,5 @@ class AppExecutor private constructor( } } -internal class AppExecutorThreadFactory : ThreadFactory { - - private val counter = AtomicInteger() - - override fun newThread(runnable: Runnable): Thread { - return Thread(runnable, "TabooLib-Application-Executor-${counter.incrementAndGet()}") - } -} - -internal inline fun runAppTask(reporter: (Throwable) -> Unit, action: () -> T): T { - try { - return action() - } catch (ex: Throwable) { - try { - reporter(ex) - } catch (reportingFailure: Throwable) { - ex.addSuppressed(reportingFailure) - } - throw ex - } -} +/** Application 平台线程工厂,线程名形如 TabooLib-Application-Executor-1 */ +internal class AppExecutorThreadFactory : ThreadFactory by PlatformThreadFactory("TabooLib-Application-Executor-") diff --git a/platform/platform-application/src/test/kotlin/taboolib/platform/ApplicationPlatformTest.kt b/platform/platform-application/src/test/kotlin/taboolib/platform/ApplicationPlatformTest.kt index 4d8f7ea0a..91db23be1 100644 --- a/platform/platform-application/src/test/kotlin/taboolib/platform/ApplicationPlatformTest.kt +++ b/platform/platform-application/src/test/kotlin/taboolib/platform/ApplicationPlatformTest.kt @@ -12,6 +12,8 @@ import taboolib.common.platform.command.CommandExecutor import taboolib.common.platform.command.CommandStructure import taboolib.common.platform.command.PermissionDefault import taboolib.common.platform.service.PlatformExecutor +import taboolib.common.platform.service.PlatformExecutorState +import taboolib.common.platform.service.runReportingFailure import java.lang.reflect.Modifier import java.util.concurrent.CompletableFuture import java.util.concurrent.FutureTask @@ -92,11 +94,11 @@ class ApplicationPlatformTest { fun `executor has explicit lifecycle and rejects all tasks after stop`() { val executor = AppExecutor() try { - assertEquals(AppExecutor.State.NEW, executor.currentState()) + assertEquals(PlatformExecutorState.NEW, executor.currentState()) executor.start() - assertEquals(AppExecutor.State.RUNNING, executor.currentState()) + assertEquals(PlatformExecutorState.RUNNING, executor.currentState()) executor.stop() - assertEquals(AppExecutor.State.STOPPED, executor.currentState()) + assertEquals(PlatformExecutorState.STOPPED, executor.currentState()) assertThrows(RejectedExecutionException::class.java) { executor.submit(runnable(now = true) {}) @@ -130,7 +132,7 @@ class ApplicationPlatformTest { var reported: Throwable? = null val thrown = assertThrows(IllegalStateException::class.java) { - runAppTask({ reported = it }) { throw failure } + runReportingFailure({ reported = it }) { throw failure } } assertTrue(reported === failure) diff --git a/platform/platform-hytale/src/main/kotlin/taboolib/platform/HytaleExecutor.kt b/platform/platform-hytale/src/main/kotlin/taboolib/platform/HytaleExecutor.kt index 357015186..11911634e 100644 --- a/platform/platform-hytale/src/main/kotlin/taboolib/platform/HytaleExecutor.kt +++ b/platform/platform-hytale/src/main/kotlin/taboolib/platform/HytaleExecutor.kt @@ -7,18 +7,19 @@ import taboolib.common.PrimitiveIO import taboolib.common.platform.Awake import taboolib.common.platform.Platform import taboolib.common.platform.PlatformSide -import taboolib.common.platform.function.registerLifeCycleTask +import taboolib.common.platform.service.CloseablePlatformTask import taboolib.common.platform.service.PlatformExecutor +import taboolib.common.platform.service.PlatformExecutorSupport +import taboolib.common.platform.service.PlatformTaskRegistration +import taboolib.common.platform.service.PlatformThreadFactory import taboolib.common.util.unsafeLazy import java.io.Closeable import java.util.concurrent.ExecutorService import java.util.concurrent.Executors -import java.util.concurrent.RejectedExecutionException import java.util.concurrent.ScheduledFuture import java.util.concurrent.ThreadFactory import java.util.concurrent.TimeUnit import java.util.concurrent.atomic.AtomicBoolean -import java.util.concurrent.atomic.AtomicInteger import java.util.concurrent.atomic.AtomicReference /** @@ -36,95 +37,27 @@ class HytaleExecutor private constructor( private val asyncExecutor: ExecutorService, private val exceptionReporter: (Throwable) -> Unit, registerStopTask: Boolean, -) : PlatformExecutor { +) : PlatformExecutorSupport("HytaleExecutor"), PlatformExecutor { constructor() : this(null, createAsyncExecutor(), ::reportTaskException, true) - private enum class State { - NEW, RUNNING, STOPPED - } - - private val lock = Any() - private val pendingTasks = LinkedHashSet() - private val activeTasks = LinkedHashSet() - - @Volatile - private var state = State.NEW - val plugin by unsafeLazy { HytalePlugin.getInstance() } init { if (registerStopTask) { - registerLifeCycleTask(LifeCycle.DISABLE, 2) { stop() } + registerStopTaskOnDisable() } } @Awake(LifeCycle.ENABLE) override fun start() { - val tasks = synchronized(lock) { - when (state) { - State.NEW -> { - state = State.RUNNING - pendingTasks.filterNotTo(ArrayList()) { it.isCancelled }.also { - pendingTasks.clear() - activeTasks.addAll(it) - } - } - State.RUNNING, State.STOPPED -> return - } - } - var failure: Throwable? = null - tasks.forEach { - try { - launch(it) - } catch (ex: Throwable) { - if (failure == null) { - failure = ex - } else { - failure?.addSuppressed(ex) - } - } - } - failure?.let { throw it } + startTasks() } private fun stop() { - val tasks = synchronized(lock) { - if (state == State.STOPPED) { - return - } - state = State.STOPPED - LinkedHashSet().also { - it.addAll(pendingTasks) - it.addAll(activeTasks) - pendingTasks.clear() - activeTasks.clear() - } - } - var failure: Throwable? = null - tasks.forEach { - try { - it.platformTask().cancel() - } catch (ex: Throwable) { - if (failure == null) { - failure = ex - } else { - failure?.addSuppressed(ex) - } - } - } - try { - asyncExecutor.shutdownNow() - } catch (ex: Throwable) { - if (failure == null) { - failure = ex - } else { - failure?.addSuppressed(ex) - } - } - failure?.let { throw it } + stopTasks() } fun execute(hytaleRunningTask: HytaleRunningTask, runnable: PlatformExecutor.PlatformRunnable): ScheduledFuture<*> { @@ -135,26 +68,26 @@ class HytaleExecutor private constructor( override fun submit(runnable: PlatformExecutor.PlatformRunnable): PlatformExecutor.PlatformTask { val task = HytaleRunningTask(this, runnable) - val launchNow = synchronized(lock) { - when (state) { - State.NEW -> { - pendingTasks += task - false - } - State.RUNNING -> { - activeTasks += task - true - } - State.STOPPED -> throw RejectedExecutionException("HytaleExecutor has been stopped") - } - } - if (launchNow) { - launch(task) + when (registerTask(task)) { + PlatformTaskRegistration.PENDING -> Unit + PlatformTaskRegistration.ACTIVE -> launchTask(task) + PlatformTaskRegistration.REJECTED -> rejectStopped() } return task.platformTask() } - private fun launch(task: HytaleRunningTask) { + /** 关闭 Hytale 平台的异步线程池 */ + override fun onStopped() { + asyncExecutor.shutdownNow() + } + + override fun isTaskCancelled(task: HytaleRunningTask): Boolean = task.isCancelled + + override fun cancelTask(task: HytaleRunningTask) { + task.platformTask().cancel() + } + + override fun launchTask(task: HytaleRunningTask) { if (task.isCancelled) { taskFinished(task) return @@ -229,13 +162,6 @@ class HytaleExecutor private constructor( } } - private fun taskFinished(task: HytaleRunningTask) { - synchronized(lock) { - pendingTasks -= task - activeTasks -= task - } - } - private fun taskCancelled(task: HytaleRunningTask) { taskFinished(task) } @@ -297,16 +223,7 @@ class HytaleExecutor private constructor( } } - class HytalePlatformTask(val runnable: Closeable) : PlatformExecutor.PlatformTask { - - private val cancelled = AtomicBoolean(false) - - override fun cancel() { - if (cancelled.compareAndSet(false, true)) { - runnable.close() - } - } - } + class HytalePlatformTask(runnable: Closeable) : CloseablePlatformTask(runnable) companion object { @@ -350,11 +267,5 @@ private object HytaleServerTaskScheduler : HytaleTaskScheduler { } } -private class HytaleAsyncThreadFactory : ThreadFactory { - - private val counter = AtomicInteger() - - override fun newThread(runnable: Runnable): Thread { - return Thread(runnable, "TabooLib-Hytale-Async-${counter.incrementAndGet()}") - } -} +/** Hytale 异步线程工厂,线程名形如 TabooLib-Hytale-Async-1 */ +private class HytaleAsyncThreadFactory : ThreadFactory by PlatformThreadFactory("TabooLib-Hytale-Async-") diff --git a/platform/platform-velocity-impl/src/main/kotlin/taboolib/platform/VelocityExecutor.kt b/platform/platform-velocity-impl/src/main/kotlin/taboolib/platform/VelocityExecutor.kt index 431cd9448..4e99b8886 100644 --- a/platform/platform-velocity-impl/src/main/kotlin/taboolib/platform/VelocityExecutor.kt +++ b/platform/platform-velocity-impl/src/main/kotlin/taboolib/platform/VelocityExecutor.kt @@ -7,17 +7,18 @@ import taboolib.common.LifeCycle import taboolib.common.platform.Awake import taboolib.common.platform.Platform import taboolib.common.platform.PlatformSide -import taboolib.common.platform.function.registerLifeCycleTask +import taboolib.common.platform.service.CloseablePlatformTask import taboolib.common.platform.service.PlatformExecutor +import taboolib.common.platform.service.PlatformExecutorSupport +import taboolib.common.platform.service.PlatformTaskRegistration +import taboolib.common.platform.service.PlatformThreadFactory import taboolib.common.util.unsafeLazy import java.io.Closeable import java.util.concurrent.ExecutorService import java.util.concurrent.Executors -import java.util.concurrent.RejectedExecutionException import java.util.concurrent.ThreadFactory import java.util.concurrent.TimeUnit import java.util.concurrent.atomic.AtomicBoolean -import java.util.concurrent.atomic.AtomicInteger import java.util.concurrent.atomic.AtomicReference /** @@ -35,95 +36,27 @@ class VelocityExecutor internal constructor( private val asyncExecutor: ExecutorService, private val exceptionReporter: (Throwable) -> Unit, registerStopTask: Boolean, -) : PlatformExecutor { +) : PlatformExecutorSupport("VelocityExecutor"), PlatformExecutor { constructor() : this(null, createAsyncExecutor(), ::reportTaskException, true) - internal enum class State { - NEW, RUNNING, STOPPED - } - - private val lock = Any() - private val pendingTasks = LinkedHashSet() - private val activeTasks = LinkedHashSet() - - @Volatile - private var state = State.NEW - val plugin by unsafeLazy { VelocityPlugin.getInstance() } init { if (registerStopTask) { - registerLifeCycleTask(LifeCycle.DISABLE, 2) { stop() } + registerStopTaskOnDisable() } } @Awake(LifeCycle.ENABLE) override fun start() { - val tasks = synchronized(lock) { - when (state) { - State.NEW -> { - state = State.RUNNING - pendingTasks.filterNotTo(ArrayList()) { it.isCancelled }.also { - pendingTasks.clear() - activeTasks.addAll(it) - } - } - State.RUNNING, State.STOPPED -> return - } - } - var failure: Throwable? = null - tasks.forEach { - try { - launch(it) - } catch (ex: Throwable) { - if (failure == null) { - failure = ex - } else { - failure?.addSuppressed(ex) - } - } - } - failure?.let { throw it } + startTasks() } fun stop() { - val tasks = synchronized(lock) { - if (state == State.STOPPED) { - return - } - state = State.STOPPED - LinkedHashSet().also { - it.addAll(pendingTasks) - it.addAll(activeTasks) - pendingTasks.clear() - activeTasks.clear() - } - } - var failure: Throwable? = null - tasks.forEach { - try { - it.cancel() - } catch (ex: Throwable) { - if (failure == null) { - failure = ex - } else { - failure?.addSuppressed(ex) - } - } - } - try { - asyncExecutor.shutdownNow() - } catch (ex: Throwable) { - if (failure == null) { - failure = ex - } else { - failure?.addSuppressed(ex) - } - } - failure?.let { throw it } + stopTasks() } fun execute(velocityRunningTask: VelocityRunningTask, runnable: PlatformExecutor.PlatformRunnable): ScheduledTask { @@ -147,26 +80,26 @@ class VelocityExecutor internal constructor( override fun submit(runnable: PlatformExecutor.PlatformRunnable): PlatformExecutor.PlatformTask { val task = VelocityRunningTask(this, runnable) - val launchNow = synchronized(lock) { - when (state) { - State.NEW -> { - pendingTasks += task - false - } - State.RUNNING -> { - activeTasks += task - true - } - State.STOPPED -> throw RejectedExecutionException("VelocityExecutor has been stopped") - } - } - if (launchNow) { - launch(task) + when (registerTask(task)) { + PlatformTaskRegistration.PENDING -> Unit + PlatformTaskRegistration.ACTIVE -> launchTask(task) + PlatformTaskRegistration.REJECTED -> rejectStopped() } return task.platformTask() } - private fun launch(task: VelocityRunningTask) { + /** 关闭 Velocity 平台的异步线程池 */ + override fun onStopped() { + asyncExecutor.shutdownNow() + } + + override fun isTaskCancelled(task: VelocityRunningTask): Boolean = task.isCancelled + + override fun cancelTask(task: VelocityRunningTask) { + task.cancel() + } + + override fun launchTask(task: VelocityRunningTask) { if (task.isCancelled) { taskFinished(task) return @@ -238,23 +171,10 @@ class VelocityExecutor internal constructor( } } - private fun taskFinished(task: VelocityRunningTask) { - synchronized(lock) { - pendingTasks -= task - activeTasks -= task - } - } - internal fun taskCancelled(task: VelocityRunningTask) { taskFinished(task) } - internal fun currentState(): State = state - - internal fun pendingTaskCount(): Int = synchronized(lock) { pendingTasks.size } - - internal fun activeTaskCount(): Int = synchronized(lock) { activeTasks.size } - class VelocityRunningTask(val executor: VelocityExecutor, val runnable: PlatformExecutor.PlatformRunnable) { lateinit var scheduledTask: ScheduledTask @@ -311,16 +231,7 @@ class VelocityExecutor internal constructor( } } - class VelocityPlatformTask(val runnable: Closeable) : PlatformExecutor.PlatformTask { - - private val cancelled = AtomicBoolean(false) - - override fun cancel() { - if (cancelled.compareAndSet(false, true)) { - runnable.close() - } - } - } + class VelocityPlatformTask(runnable: Closeable) : CloseablePlatformTask(runnable) companion object { @@ -344,11 +255,5 @@ internal interface VelocityTaskScheduler { fun schedule(task: VelocityExecutor.VelocityRunningTask, runnable: PlatformExecutor.PlatformRunnable, action: Runnable): ScheduledTask } -internal class VelocityAsyncThreadFactory : ThreadFactory { - - private val counter = AtomicInteger() - - override fun newThread(runnable: Runnable): Thread { - return Thread(runnable, "TabooLib-Velocity-Async-${counter.incrementAndGet()}") - } -} +/** Velocity 异步线程工厂,线程名形如 TabooLib-Velocity-Async-1 */ +internal class VelocityAsyncThreadFactory : ThreadFactory by PlatformThreadFactory("TabooLib-Velocity-Async-") diff --git a/platform/platform-velocity-impl/src/test/kotlin/taboolib/platform/VelocityExecutorTest.kt b/platform/platform-velocity-impl/src/test/kotlin/taboolib/platform/VelocityExecutorTest.kt index 1e730b794..c0a00dfe7 100644 --- a/platform/platform-velocity-impl/src/test/kotlin/taboolib/platform/VelocityExecutorTest.kt +++ b/platform/platform-velocity-impl/src/test/kotlin/taboolib/platform/VelocityExecutorTest.kt @@ -8,6 +8,7 @@ import org.junit.jupiter.api.Assertions.assertThrows import org.junit.jupiter.api.Assertions.assertTrue import org.junit.jupiter.api.Test import taboolib.common.platform.service.PlatformExecutor +import taboolib.common.platform.service.PlatformExecutorState import java.lang.reflect.Proxy import java.util.concurrent.AbstractExecutorService import java.util.concurrent.ExecutorService @@ -65,18 +66,18 @@ class VelocityExecutorTest { val executor = executor(scheduler, asyncExecutor) executor.submit(runnable()) - assertEquals(VelocityExecutor.State.NEW, executor.currentState()) + assertEquals(PlatformExecutorState.NEW, executor.currentState()) assertEquals(1, executor.pendingTaskCount()) executor.start() - assertEquals(VelocityExecutor.State.RUNNING, executor.currentState()) + assertEquals(PlatformExecutorState.RUNNING, executor.currentState()) assertEquals(0, executor.pendingTaskCount()) assertEquals(1, executor.activeTaskCount()) executor.stop() executor.stop() - assertEquals(VelocityExecutor.State.STOPPED, executor.currentState()) + assertEquals(PlatformExecutorState.STOPPED, executor.currentState()) assertEquals(0, executor.activeTaskCount()) assertEquals(1, scheduler.scheduled.single().cancelCount) assertEquals(1, asyncExecutor.shutdownNowCount) @@ -101,7 +102,7 @@ class VelocityExecutorTest { assertEquals(1, scheduler.scheduled.first().cancelCount) assertEquals(1, scheduler.scheduled.last().cancelCount) assertEquals(1, asyncExecutor.shutdownNowCount) - assertEquals(VelocityExecutor.State.STOPPED, executor.currentState()) + assertEquals(PlatformExecutorState.STOPPED, executor.currentState()) assertEquals(0, executor.activeTaskCount()) } From cdf485e97bcbfc2a0a5927e96af24394d78ab601 Mon Sep 17 00:00:00 2001 From: FxRayHughes Date: Sun, 26 Jul 2026 23:04:50 +0800 Subject: [PATCH 36/37] =?UTF-8?q?fix(kether):=20=E4=BB=A5=E6=97=A0?= =?UTF-8?q?=E9=94=81=E7=8A=B6=E6=80=81=E6=9C=BA=E6=B6=88=E9=99=A4=E7=88=B6?= =?UTF-8?q?=E5=AD=90=20frame=20=E7=9A=84=20ABBA=20=E6=AD=BB=E9=94=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 依据 #706 评审意见修正(评审列为阻塞级,建议阻止合并): 1. 父子 frame 锁顺序反转导致主线程死锁(#706-1) 本 PR 给 SimpleNamedFrame 的 run/process/resume/close 都加了 synchronized, 形成两条相反的加锁路径: A: terminate() -> rootFrame.close() 持父锁再取子锁 B: child.resume() -> parent.resume() 持子锁再取父锁 并发时死锁,表现为 reload / 禁用时服务器主线程冻结—— 用「服务器挂死」换掉了「脚本挂起」,后果反而更重。 2. 持锁调用第三方插件代码(#706-2) synchronized process() 内直接调用 action.process(this), 动作内若再 newFrame().run() 即自锁,并放大问题 1 的触发窗口。 修法:改用无锁状态机,而非「把回调移出锁外」——后者需要在每个临界区 手工维护「锁内计算 / 锁外执行」的两段式结构,而 process 内有 6 个提前 return 分支,极易漏掉一处。 - 移除全部 frame 级 synchronized,现在没有任何路径在持锁时调用外部代码 - 引入 AtomicInteger 令牌与排水循环:已有线程在推进时,后来者只登记请求即返回, 由持令牌线程代为执行。动作按序串行的语义不变,且动作在 process 栈内同步完成 并回调 resume 时不再递归(旧代码靠可重入锁掩盖了这一点) - future 字段改为 AtomicReference,运行权抢占由 checkState 改为 compareAndSet, close 改为 getAndSet(null),无锁保证只关闭一次 - 子 frame 列表改为同步列表并在 close 前快照遍历,避免关闭期间子 frame 完成引发 CME - 补上两处原先被锁掩盖的竞态:process 登记 whenComplete 前二次确认未关闭, 否则取消动作 future 防泄漏;SimpleActionFrame.run 用占位 future 抢占运行权 3. ExitStatus 三种语义被统一当作成功(#706-3) success() / paused() / cooldown() 不加区分地走 completeResult, 被 terminateScript() 强制终止的脚本会以成功状态完成,调用方拿不到中断信号。 现按 status.isRunning() 分流:正常结束走完成,暂停与冷却走 cancel(false)。 同时把退出状态检查从 while 条件移进循环体首部,让「循环中途被打断」与 「循环跑完后发现终态」两条路径走同一套分流——旧代码这两处一个不完成 future、 一个无条件当成功,行为不一致。 4. RemoteQuestReader 锁错对象(#706-5) 12 个方法的 @Synchronized 锁的是 Reader 实例,而真正共享的游标在 source 上, 两个 Reader 包同一 source 时完全不互斥。改为 synchronized(source)。 新增回归测试:动作执行时未持有父/子 frame 锁的断言、200 轮 terminate 与 子动作完成的并发竞争(直接覆盖 ABBA 路径,带超时兜底)、三种 ExitStatus 分流、 4 个 Reader 共享同一 source 的互斥性验证。 --- .../library/kether/AbstractQuestContext.java | 215 ++++++++++++++---- .../module/kether/RemoteQuestReader.kt | 45 ++-- .../kether/AbstractQuestContextTest.java | 142 +++++++++++- .../module/kether/RemoteQuestReaderTest.kt | 25 ++ 4 files changed, 350 insertions(+), 77 deletions(-) diff --git a/module/minecraft/minecraft-kether/src/main/java/taboolib/library/kether/AbstractQuestContext.java b/module/minecraft/minecraft-kether/src/main/java/taboolib/library/kether/AbstractQuestContext.java index fac45b22e..df79e46cf 100644 --- a/module/minecraft/minecraft-kether/src/main/java/taboolib/library/kether/AbstractQuestContext.java +++ b/module/minecraft/minecraft-kether/src/main/java/taboolib/library/kether/AbstractQuestContext.java @@ -9,6 +9,9 @@ import java.util.concurrent.CompletionException; import java.util.concurrent.Executor; import java.util.concurrent.LinkedBlockingDeque; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.concurrent.atomic.AtomicReference; public abstract class AbstractQuestContext> implements QuestContext { @@ -19,6 +22,9 @@ public abstract class AbstractQuestContext> im protected volatile ExitStatus exitStatus; protected volatile CompletableFuture future; + /** 运行标记,保证同一上下文不会被并发启动(替代 synchronized,避免持锁调用动作代码) */ + private final AtomicBoolean running = new AtomicBoolean(); + protected AbstractQuestContext(QuestService service, Quest quest, String playerIdentifier) { this.service = service; this.quest = quest; @@ -29,7 +35,18 @@ protected AbstractQuestContext(QuestService service, Quest quest, String play protected abstract Executor createExecutor(); protected Frame createRootFrame() { - return new SimpleNamedFrame(null, new LinkedList<>(), new SimpleVarTable(null), QuestContext.BASE_BLOCK, this); + return new SimpleNamedFrame(null, newFrameList(), new SimpleVarTable(null), QuestContext.BASE_BLOCK, this); + } + + /** + * 创建子 frame 容器。 + *

+ * 使用同步列表而非 {@link LinkedList}:frame 的推进循环({@code removeIf})与关闭路径(遍历) + * 可能位于不同线程,需要保证列表自身的结构安全。该列表锁只用于纯粹的列表操作, + * 不会在持有期间回调外部代码,因此是不会参与死锁的叶子锁。 + */ + static List newFrameList() { + return Collections.synchronizedList(new ArrayList<>()); } public QuestService getService() { @@ -62,10 +79,11 @@ public Frame rootFrame() { } @Override - public synchronized CompletableFuture runActions() { - Preconditions.checkState(future == null, "already running"); + public CompletableFuture runActions() { + Preconditions.checkState(running.compareAndSet(false, true), "already running"); CompletableFuture frameFuture = rootFrame.run(); CompletableFuture contextFuture = new CompletableFuture<>(); + this.future = contextFuture; frameFuture.whenComplete((result, ex) -> { if (ex != null) { completeFailure(contextFuture, ex); @@ -81,16 +99,18 @@ public synchronized CompletableFuture runActions() { frameFuture.cancel(false); } }); - this.future = contextFuture; return contextFuture; } @Override - public synchronized void terminate() { + public void terminate() { + // 不加锁:close() 内部会完成 future 并回调外部代码,持锁调用外部回调会引入死锁风险 this.rootFrame.close(); - if (future != null) { - future.completeExceptionally(new QuestCloseException()); - future = null; + CompletableFuture contextFuture = this.future; + this.future = null; + this.running.set(false); + if (contextFuture != null) { + contextFuture.completeExceptionally(new QuestCloseException()); } } @@ -130,7 +150,7 @@ public static abstract class AbstractFrame implements Frame { protected final List frames; protected final VarTable varTable; protected final QuestContext questContext; - protected volatile CompletableFuture future; + protected final AtomicReference> futureRef = new AtomicReference<>(); protected final Deque closeables = new LinkedBlockingDeque<>(); public AbstractFrame(Frame parent, List frames, VarTable varTable, QuestContext questContext) { @@ -157,7 +177,7 @@ public Optional parent() { @Override public Frame newFrame(@NotNull String name) { - SimpleNamedFrame frame = new SimpleNamedFrame(this, new LinkedList<>(), new SimpleVarTable(this), name, context()); + SimpleNamedFrame frame = new SimpleNamedFrame(this, newFrameList(), new SimpleVarTable(this), name, context()); this.frames.add(frame); return frame; } @@ -166,10 +186,10 @@ public Frame newFrame(@NotNull String name) { public Frame newFrame(@NotNull ParsedAction action) { Frame frame; if (action.get(ActionProperties.REQUIRE_FRAME, false)) { - frame = new SimpleNamedFrame(this, new LinkedList<>(), new SimpleVarTable(this), "__anon__" + System.nanoTime(), context()); + frame = new SimpleNamedFrame(this, newFrameList(), new SimpleVarTable(this), "__anon__" + System.nanoTime(), context()); frame.setNext(action); } else { - frame = new SimpleActionFrame(this, new LinkedList<>(), new SimpleVarTable(this), action, context()); + frame = new SimpleActionFrame(this, newFrameList(), new SimpleVarTable(this), action, context()); } this.frames.add(frame); return frame; @@ -186,21 +206,43 @@ public T addClosable(T closeable) { return closeable; } + /** + * 关闭当前 frame 及其所有子 frame。 + *

+ * 全程不持有任何 frame 锁:子 frame 的关闭、future 的完成回调都可能触发外部代码 + * (动作注册的 whenComplete、父 frame 的推进),持锁执行会造成父子锁顺序反转。 + * 通过 {@link #futureRef} 的 CAS 保证同一个 frame 只会真正关闭一次。 + */ @Override public void close() { - CompletableFuture runningFuture = this.future; + CompletableFuture runningFuture = this.futureRef.getAndSet(null); if (runningFuture == null) return; - this.future = null; - for (Frame frame : this.frames) { + // 快照子列表后再遍历,避免关闭过程中子 frame 完成导致的并发修改 + for (Frame frame : snapshotFrames()) { frame.close(); } this.cleanup(); runningFuture.completeExceptionally(new QuestCloseException()); } + /** 对子 frame 列表做快照,避免遍历期间的并发修改 */ + protected List snapshotFrames() { + synchronized (this.frames) { + return new ArrayList<>(this.frames); + } + } + + /** 移除所有已完成的子 frame */ + protected void removeDoneFrames() { + synchronized (this.frames) { + this.frames.removeIf(Frame::isDone); + } + } + @Override public boolean isDone() { - return this.future == null || this.future.isDone(); + CompletableFuture runningFuture = this.futureRef.get(); + return runningFuture == null || runningFuture.isDone(); } void cleanup() { @@ -219,7 +261,20 @@ public static class SimpleNamedFrame extends AbstractFrame { private final String name; private Quest.Block block, next; private int sp = -1, np = -1; - private volatile CompletableFuture runningAction; + private final AtomicReference> runningAction = new AtomicReference<>(); + + /** + * 推进循环的所有权令牌。 + *

+ * 0 = 空闲;>0 = 已有线程在推进(数值表示待处理的推进请求数)。 + * 由 CAS 保证同一时刻只有一个线程在跑 {@link #process},其余线程只登记请求后立即返回, + * 由持有令牌的线程代为执行——即所谓"排水循环"(drain loop)。 + * 这样既保证了动作按序串行执行,又完全不需要在调用 {@code action.process(...)} 时持锁。 + */ + private final AtomicInteger pending = new AtomicInteger(); + + /** 待推进时携带的上一个动作 future(用于取回最终返回值) */ + private final AtomicReference> pendingPrevious = new AtomicReference<>(); public SimpleNamedFrame(Frame parent, List frames, VarTable varTable, String name, QuestContext questContext) { super(parent, frames, varTable, questContext); @@ -264,10 +319,14 @@ public void setNext(@NotNull Quest.Block block) { np = 0; } + /** + * 关闭 frame,同时取消仍在运行的动作 future。 + *

+ * 不加锁:{@code super.close()} 会级联关闭子 frame 并完成 future,这两步都会回调外部代码。 + */ @Override - public synchronized void close() { - CompletableFuture actionFuture = this.runningAction; - this.runningAction = null; + public void close() { + CompletableFuture actionFuture = this.runningAction.getAndSet(null); super.close(); if (actionFuture != null) { actionFuture.cancel(false); @@ -276,28 +335,63 @@ public synchronized void close() { @Override @SuppressWarnings("unchecked") - public synchronized CompletableFuture run() { - Preconditions.checkState(this.future == null, "already running"); - varTable.initialize(this); - future = new CompletableFuture<>(); - CompletableFuture resultFuture = future; + public CompletableFuture run() { + CompletableFuture resultFuture = new CompletableFuture<>(); + Preconditions.checkState(this.futureRef.compareAndSet(null, resultFuture), "already running"); + try { + varTable.initialize(this); + } catch (Throwable ex) { + // 变量初始化失败时归还运行权,避免 frame 永久停留在"运行中" + this.futureRef.compareAndSet(resultFuture, null); + throw ex; + } resultFuture.whenComplete((result, ex) -> { if (resultFuture.isCancelled()) { this.close(); } }); - process(null); + schedule(resultFuture, null); return (CompletableFuture) resultFuture; } - private synchronized void process(CompletableFuture previousFuture) { - CompletableFuture resultFuture = this.future; - if (resultFuture == null || resultFuture.isDone()) { + /** + * 登记一次推进请求,并在成为令牌持有者时执行排水循环。 + *

+ * 若已有线程在推进(例如动作在 {@code process} 调用栈内同步完成并回调 resume), + * 本次调用只增加计数后立即返回,由那个线程继续处理——从而消除递归与嵌套加锁。 + */ + private void schedule(CompletableFuture resultFuture, CompletableFuture previousFuture) { + this.pendingPrevious.set(previousFuture); + if (this.pending.getAndIncrement() != 0) { + // 已有线程持有令牌,交由它继续推进 + return; + } + do { + process(resultFuture, this.pendingPrevious.get()); + } while (this.pending.decrementAndGet() != 0); + } + + /** + * 推进动作序列。调用方保证同一时刻只有一个线程进入本方法。 + *

+ * 方法内部**不持有任何锁**,因此 {@code action.process(this)} 这类外部回调可以安全地 + * 创建子 frame、同步完成 future 甚至反向触发父 frame 的推进。 + */ + private void process(CompletableFuture resultFuture, CompletableFuture previousFuture) { + if (resultFuture.isDone() || this.futureRef.get() != resultFuture) { return; } - while (!context().getExitStatus().isPresent()) { + while (true) { + Optional status = context().getExitStatus(); + if (status.isPresent()) { + // 上下文已给出终态:按 isRunning() 区分「正常结束」与「被中断」 + this.cleanup(); + removeDoneFrames(); + completeByExitStatus(resultFuture, previousFuture, status.get()); + return; + } this.cleanup(); - this.frames.removeIf(Frame::isDone); + removeDoneFrames(); Optional> optional = nextAction(); if (!optional.isPresent()) { completeResult(resultFuture, previousFuture); @@ -311,12 +405,17 @@ private synchronized void process(CompletableFuture previousFuture) { fail(resultFuture, ex); return; } - this.runningAction = actionFuture; if (!actionFuture.isDone()) { + this.runningAction.set(actionFuture); + // 二次确认:登记期间 frame 可能已被 close(),此时需要主动取消,避免动作泄漏 + if (this.futureRef.get() != resultFuture || resultFuture.isDone()) { + this.runningAction.compareAndSet(actionFuture, null); + actionFuture.cancel(false); + return; + } actionFuture.whenComplete((result, ex) -> resume(resultFuture, actionFuture, ex)); return; } - this.runningAction = null; if (actionFuture.isCancelled()) { resultFuture.cancel(false); return; @@ -329,31 +428,43 @@ private synchronized void process(CompletableFuture previousFuture) { } previousFuture = actionFuture; } - this.cleanup(); - this.frames.removeIf(Frame::isDone); - completeResult(resultFuture, previousFuture); } - private synchronized void resume(CompletableFuture resultFuture, CompletableFuture actionFuture, Throwable throwable) { - if (this.runningAction == actionFuture) { - this.runningAction = null; - } - if (this.future != resultFuture || resultFuture.isDone()) { + /** 异步动作完成后的回调入口,只登记推进请求,实际推进交由排水循环执行 */ + private void resume(CompletableFuture resultFuture, CompletableFuture actionFuture, Throwable throwable) { + this.runningAction.compareAndSet(actionFuture, null); + if (this.futureRef.get() != resultFuture || resultFuture.isDone()) { return; } if (throwable != null) { fail(resultFuture, throwable); } else { - process(actionFuture); + schedule(resultFuture, actionFuture); } } private void fail(CompletableFuture resultFuture, Throwable throwable) { this.cleanup(); - this.frames.removeIf(Frame::isDone); + removeDoneFrames(); completeFailure(resultFuture, throwable); } + /** + * 按 {@link ExitStatus} 语义完成 future。 + *

+ * {@code success()} 为正常结束,用最后一个动作的返回值完成; + * {@code paused()} / {@code cooldown(..)} 均满足 {@code isRunning() == true}, + * 表示脚本是被外部强制中断(如 {@code Workspace.terminateScript})而非跑完, + * 此时走 {@code cancel(false)},让调用方能够区分「被中断」与「成功」。 + */ + private void completeByExitStatus(CompletableFuture resultFuture, CompletableFuture previousFuture, ExitStatus status) { + if (status.isRunning()) { + resultFuture.cancel(false); + } else { + completeResult(resultFuture, previousFuture); + } + } + @SuppressWarnings("unchecked") private void completeResult(CompletableFuture resultFuture, CompletableFuture previousFuture) { Object result = previousFuture != null ? previousFuture.getNow(null) : null; @@ -402,17 +513,25 @@ public void setNext(@NotNull Quest.Block block) { @Override @SuppressWarnings("unchecked") - public synchronized CompletableFuture run() { - Preconditions.checkState(this.future == null, "already running"); + public CompletableFuture run() { + // 占位 future 用于抢占运行权,随后再替换为动作真正返回的 future + CompletableFuture placeholder = new CompletableFuture<>(); + Preconditions.checkState(this.futureRef.compareAndSet(null, placeholder), "already running"); this.varTable.initialize(this); + CompletableFuture actionFuture; try { - this.future = Objects.requireNonNull(this.action.process(this), "Quest action returned null future: " + action); + actionFuture = Objects.requireNonNull(this.action.process(this), "Quest action returned null future: " + action); } catch (Throwable ex) { CompletableFuture failed = new CompletableFuture<>(); completeFailure(failed, ex); - this.future = failed; + actionFuture = failed; + } + // 若在动作执行期间 frame 已被关闭(占位 future 被置空),直接取消动作,不再对外暴露 + if (!this.futureRef.compareAndSet(placeholder, actionFuture)) { + actionFuture.cancel(false); + return (CompletableFuture) actionFuture; } - return (CompletableFuture) this.future; + return (CompletableFuture) actionFuture; } } diff --git a/module/minecraft/minecraft-kether/src/main/kotlin/taboolib/module/kether/RemoteQuestReader.kt b/module/minecraft/minecraft-kether/src/main/kotlin/taboolib/module/kether/RemoteQuestReader.kt index d2529dc4d..400d0d565 100644 --- a/module/minecraft/minecraft-kether/src/main/kotlin/taboolib/module/kether/RemoteQuestReader.kt +++ b/module/minecraft/minecraft-kether/src/main/kotlin/taboolib/module/kether/RemoteQuestReader.kt @@ -9,70 +9,69 @@ import taboolib.common.util.supplierLazy import taboolib.library.kether.ParsedAction import taboolib.library.kether.QuestReader +/** + * 远程脚本读取器。 + * + * 真正被共享的读取游标位于 [source] 上,因此所有读取操作都以 [source] 为锁对象串行化。 + * 若锁 Reader 实例本身,两个包装同一 [source] 的 Reader 之间将完全不互斥,竞态依旧存在。 + */ @Suppress("UNCHECKED_CAST") class RemoteQuestReader(val remote: OpenContainer, val source: Any) : QuestReader { - @Synchronized - override fun peek(): Char { + override fun peek(): Char = synchronized(source) { return source.invokeMethod("peek", remap = false)!! } - @Synchronized - override fun peek(n: Int): Char { + override fun peek(n: Int): Char = synchronized(source) { return peekIntMethod[source].invoke(source, n) as Char } - @Synchronized - override fun getIndex(): Int { + override fun getIndex(): Int = synchronized(source) { return source.invokeMethod("getIndex", remap = false)!! } - @Synchronized - override fun getMark(): Int { + override fun getMark(): Int = synchronized(source) { return source.invokeMethod("getMark", remap = false)!! } - @Synchronized - override fun hasNext(): Boolean { + override fun hasNext(): Boolean = synchronized(source) { return source.invokeMethod("hasNext", remap = false)!! } - @Synchronized - override fun nextToken(): String { + override fun nextToken(): String = synchronized(source) { return source.invokeMethod("nextToken", remap = false)!! } - @Synchronized - override fun mark() { + override fun mark() = synchronized(source) { source.invokeMethod("mark", remap = false) + Unit } - @Synchronized - override fun reset() { + override fun reset() = synchronized(source) { source.invokeMethod("reset", remap = false) + Unit } - @Synchronized - override fun nextAction(): ParsedAction { + override fun nextAction(): ParsedAction = synchronized(source) { val action = source.invokeMethod("nextAction", remap = false)!! val questAction = RemoteQuestAction(remote, action.getProperty("action", remap = false)!!) return ParsedAction(questAction, action.getProperty>("properties", remap = false)!!) } - @Synchronized - override fun nextAction(namespace: String?): ParsedAction { + override fun nextAction(namespace: String?): ParsedAction = synchronized(source) { return try { val action = nextActionStringMethod[source].invoke(source, namespace)!! val questAction = RemoteQuestAction(remote, action.getProperty("action", remap = false)!!) ParsedAction(questAction, action.getProperty>("properties", remap = false)!!) } catch (_: NoSuchMethodException) { + // 注意:synchronized 为可重入锁,此处回退调用不会自锁 nextAction() } } - @Synchronized - override fun expect(value: String) { + override fun expect(value: String) = synchronized(source) { expectMethod[source].invoke(source, value) + Unit } companion object { @@ -89,4 +88,4 @@ class RemoteQuestReader(val remote: OpenContainer, val source: Any) : QuestReade ReflexClass.of(it.javaClass).getMethodByTypes("expect", remap = false, parameter = arrayOf(String::class.java)) } } -} \ No newline at end of file +} diff --git a/module/minecraft/minecraft-kether/src/test/java/taboolib/library/kether/AbstractQuestContextTest.java b/module/minecraft/minecraft-kether/src/test/java/taboolib/library/kether/AbstractQuestContextTest.java index 934e19ce9..c75828e3e 100644 --- a/module/minecraft/minecraft-kether/src/test/java/taboolib/library/kether/AbstractQuestContextTest.java +++ b/module/minecraft/minecraft-kether/src/test/java/taboolib/library/kether/AbstractQuestContextTest.java @@ -2,17 +2,24 @@ import org.jetbrains.annotations.NotNull; import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.Timeout; import java.util.Arrays; import java.util.Collections; -import java.util.LinkedHashMap; import java.util.List; import java.util.Map; import java.util.Optional; import java.util.concurrent.CompletableFuture; import java.util.concurrent.CompletionException; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.CountDownLatch; import java.util.concurrent.Executor; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.Future; +import java.util.concurrent.TimeUnit; import java.util.concurrent.atomic.AtomicInteger; +import java.util.concurrent.atomic.AtomicReference; import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; import static org.junit.jupiter.api.Assertions.assertEquals; @@ -124,6 +131,122 @@ void terminatingContextClosesFrameAndRunningAction() { assertFalse(result.isCancelled()); } + /** 动作代码不得在持有 frame 锁的情况下被调用,否则父子 frame 之间会形成锁顺序反转 */ + @Test + void actionIsNotInvokedWhileHoldingFrameLock() { + AtomicReference heldFrameLock = new AtomicReference<>(); + AtomicReference heldChildLock = new AtomicReference<>(); + TestQuestContext context = context(action(frame -> { + heldFrameLock.set(Thread.holdsLock(frame)); + QuestContext.Frame child = frame.newFrame(action(inner -> { + heldChildLock.set(Thread.holdsLock(inner) || Thread.holdsLock(frame)); + return CompletableFuture.completedFuture(null); + })); + return child.run(); + })); + + context.runActions().join(); + + assertEquals(Boolean.FALSE, heldFrameLock.get()); + assertEquals(Boolean.FALSE, heldChildLock.get()); + } + + /** + * 终止方向(父 → 子 close)与完成方向(子 resume → 父推进)并发执行时不得死锁。 + *

+ * 旧实现两个方向分别持父锁取子锁、持子锁取父锁,构成 ABBA 死锁。 + */ + @Test + @Timeout(30) + void concurrentTerminateAndResumeDoesNotDeadlock() throws Exception { + ExecutorService executor = Executors.newFixedThreadPool(2); + try { + for (int i = 0; i < 200; i++) { + CompletableFuture childAction = new CompletableFuture<>(); + CountDownLatch started = new CountDownLatch(1); + TestQuestContext context = context(action(frame -> { + QuestContext.Frame child = frame.newFrame("child"); + CompletableFuture childResult = child.run(); + started.countDown(); + return childResult; + }), action(frame -> CompletableFuture.completedFuture("tail"))); + // child 区块内的动作等待外部 future + context.quest().putBlock("child", Collections.singletonList(action(frame -> childAction))); + + CompletableFuture result = context.runActions(); + assertTrue(started.await(5, TimeUnit.SECONDS)); + + CountDownLatch fire = new CountDownLatch(1); + Future terminating = executor.submit(() -> { + awaitQuietly(fire); + context.terminate(); + }); + Future resuming = executor.submit(() -> { + awaitQuietly(fire); + childAction.complete("done"); + }); + fire.countDown(); + + terminating.get(10, TimeUnit.SECONDS); + resuming.get(10, TimeUnit.SECONDS); + assertTrue(result.isDone()); + } + } finally { + executor.shutdownNow(); + } + } + + /** ExitStatus.success() 属于正常结束,以最后一个动作的返回值完成 */ + @Test + void successExitStatusCompletesNormally() { + TestQuestContext context = context(action(frame -> { + frame.context().setExitStatus(ExitStatus.success()); + return CompletableFuture.completedFuture("value"); + })); + + assertEquals("value", context.runActions().join()); + } + + /** ExitStatus.paused() 表示被强制终止,调用方必须能观察到「非正常结束」 */ + @Test + void pausedExitStatusCancelsResult() { + AtomicInteger followingRuns = new AtomicInteger(); + TestQuestContext context = context( + action(frame -> { + frame.context().setExitStatus(ExitStatus.paused()); + return CompletableFuture.completedFuture("ignored"); + }), + action(frame -> { + followingRuns.incrementAndGet(); + return CompletableFuture.completedFuture(null); + }) + ); + + CompletableFuture result = context.runActions(); + + assertTrue(result.isCancelled()); + assertEquals(0, followingRuns.get()); + } + + /** ExitStatus.cooldown() 同样是「仍在运行」的挂起态,不应被当作成功 */ + @Test + void cooldownExitStatusCancelsResult() { + TestQuestContext context = context(action(frame -> { + frame.context().setExitStatus(ExitStatus.cooldown(1000)); + return CompletableFuture.completedFuture("ignored"); + })); + + assertTrue(context.runActions().isCancelled()); + } + + private static void awaitQuietly(CountDownLatch latch) { + try { + latch.await(); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } + } + @SafeVarargs private final TestQuestContext context(ParsedAction... actions) { return new TestQuestContext(new TestQuest(Arrays.asList(actions))); @@ -149,6 +272,10 @@ private static class TestQuestContext extends AbstractQuestContext blocks; + private final Map blocks = new ConcurrentHashMap<>(); TestQuest(List> actions) { - Map values = new LinkedHashMap<>(); - values.put(QuestContext.BASE_BLOCK, new TestBlock(QuestContext.BASE_BLOCK, actions)); - this.blocks = Collections.unmodifiableMap(values); + this.blocks.put(QuestContext.BASE_BLOCK, new TestBlock(QuestContext.BASE_BLOCK, actions)); + } + + /** 追加命名区块,供嵌套 frame 测试使用 */ + void putBlock(String label, List> actions) { + this.blocks.put(label, new TestBlock(label, actions)); } @Override @@ -177,7 +307,7 @@ public Optional getBlock(@NotNull String label) { @Override public Map getBlocks() { - return blocks; + return Collections.unmodifiableMap(blocks); } @Override diff --git a/module/minecraft/minecraft-kether/src/test/kotlin/taboolib/module/kether/RemoteQuestReaderTest.kt b/module/minecraft/minecraft-kether/src/test/kotlin/taboolib/module/kether/RemoteQuestReaderTest.kt index caf844ff1..3abf3303e 100644 --- a/module/minecraft/minecraft-kether/src/test/kotlin/taboolib/module/kether/RemoteQuestReaderTest.kt +++ b/module/minecraft/minecraft-kether/src/test/kotlin/taboolib/module/kether/RemoteQuestReaderTest.kt @@ -36,6 +36,31 @@ class RemoteQuestReaderTest { assertEquals(1, source.maxConcurrentCalls.get()) } + @Test + fun `readers wrapping the same source are mutually exclusive`() { + // 锁对象若是 Reader 实例,两个包装同一 source 的 Reader 之间不会互斥 + val source = ConcurrentReaderSource() + val readers = List(4) { RemoteQuestReader(TestContainer, source) } + val executor = Executors.newFixedThreadPool(8) + val start = CountDownLatch(1) + try { + val tasks = List(32) { index -> + executor.submit { + start.await() + readers[index % readers.size].nextToken() + } + } + start.countDown() + tasks.forEach { future -> + assertEquals("token", future.get(5, TimeUnit.SECONDS)) + } + } finally { + executor.shutdownNow() + } + + assertEquals(1, source.maxConcurrentCalls.get()) + } + private class ConcurrentReaderSource { private val activeCalls = AtomicInteger() From a97d8f403c4d078b4e488ce7a146e652cd44207e Mon Sep 17 00:00:00 2001 From: FxRayHughes Date: Sun, 26 Jul 2026 23:15:18 +0800 Subject: [PATCH 37/37] =?UTF-8?q?chore:=20=E5=BF=BD=E7=95=A5=20JVM=20?= =?UTF-8?q?=E5=A0=86=E8=BD=AC=E5=82=A8=E6=96=87=E4=BB=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 构建期 OOM 会在仓库根目录生成 java_pid*.hprof,体积可达数百 MB, 超过 GitHub 单文件 100 MB 限制,需避免被误提交。 --- .gitignore | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.gitignore b/.gitignore index caa1259dd..4b7e5e251 100644 --- a/.gitignore +++ b/.gitignore @@ -8,3 +8,6 @@ bin doc .claude/ nul + +# JVM 堆转储(构建期 OOM 时生成,体积可达数百 MB) +*.hprof