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-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..b65a20bfe 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")) - compileOnly(project(":common-platform-api")) - compileOnly(project(":common-util")) -} \ No newline at end of file + compileOnlyApi(project(":common")) + compileOnlyApi(project(":common-env")) + compileOnlyApi(project(":common-platform-api")) + compileOnlyApi(project(":common-util")) + testImplementation(project(":common")) +} 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/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/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/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/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/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-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/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/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/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/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/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/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) + } + } +} 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/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/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/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/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/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/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)) + } +} 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 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/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 + } + } +} 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-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)) + } +} 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/module/database/build.gradle.kts b/module/database/build.gradle.kts index 5f7c7b7cb..8dfd0ade3 100644 --- a/module/database/build.gradle.kts +++ b/module/database/build.gradle.kts @@ -1,12 +1,14 @@ 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-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..6d6df4b69 100644 --- a/module/database/database-player/build.gradle.kts +++ b/module/database/database-player/build.gradle.kts @@ -5,4 +5,13 @@ 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.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 369f29f36..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,19 +1,45 @@ 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 -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)) + + private val table = type.tableVar() + private val uniqueIndexName = createUniqueIndexName(table.name) + private val migrationLock = migrationLocks.computeIfAbsent("${type.host().connectionUrl}|${table.name}") { Any() } init { - type.tableVar().createTable(dataSource) + try { + table.createTable(dataSource) + ensureUniqueKeyIndex() + } catch (ex: Throwable) { + close() + throw ex + } } /** * 根据用户获取用户所有的数据 */ operator fun get(user: String): MutableMap { - return type.tableVar().select(dataSource) { + return table.select(dataSource) { rows("key", "value") where("user" eq user) }.map { @@ -25,7 +51,7 @@ class Database(val type: Type, val dataSource: DataSource = type.host().createDa * 根据用户和键获取数据 */ 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) @@ -43,15 +69,15 @@ class Database(val type: Type, val dataSource: DataSource = type.host().createDa 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) } } @@ -60,7 +86,7 @@ class Database(val type: Type, val dataSource: DataSource = type.host().createDa * 如果数据不存在则返回 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 { @@ -72,7 +98,7 @@ class Database(val type: Type, val dataSource: DataSource = type.host().createDa * 返回所有满足 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 { @@ -84,7 +110,7 @@ class Database(val type: Type, val dataSource: DataSource = type.host().createDa * 根据 Key 来返回一个 的Map */ fun getListByKey(key: String): MutableMap { - return type.tableVar().select(dataSource) { + return table.select(dataSource) { rows("user", "value") where("key" eq key) }.map { @@ -97,7 +123,7 @@ class Database(val type: Type, val dataSource: DataSource = type.host().createDa * 例如 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 { @@ -109,8 +135,291 @@ class Database(val type: Type, val dataSource: DataSource = type.host().createDa * 删除符合条件的数据 */ 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)) } } -} \ No newline at end of file + + 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(), + ) + + /** + * 关闭由当前实例创建的数据源。 + * + * 外部传入的数据源默认由调用方管理,可通过三参数构造函数显式转移所有权。 + */ + override fun close() { + if (!closed.compareAndSet(false, true) || !ownsDataSource) { + return + } + (dataSource as? AutoCloseable)?.close() + } + + 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 { + 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 + } + } +} 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/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 + } +} 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, + ) +} 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/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/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-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")) + } +} 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/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() } } } /** 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() + } +} 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(); + } +} 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 + } + } +} 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/build.gradle.kts b/platform/platform-bukkit-impl/build.gradle.kts index 2044a3cab..9943c68a2 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 @@ -29,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/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/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/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/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/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-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-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-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()); + } } 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/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/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/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/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 + } + } +} 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 } + } + } +}