From 3549727dc83f52606664d9227dd45edca889062c Mon Sep 17 00:00:00 2001 From: zhibei <785740487@qq.com> Date: Mon, 13 Jul 2026 12:14:21 +0800 Subject: [PATCH] =?UTF-8?q?fix(core):=20=E4=BF=AE=E5=A4=8D=E5=BC=82?= =?UTF-8?q?=E6=AD=A5=E4=BB=BB=E5=8A=A1=E4=B8=8E=E8=B5=84=E6=BA=90=E7=94=9F?= =?UTF-8?q?=E5=91=BD=E5=91=A8=E6=9C=9F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 确保 Future 异常可观察,消除递归删除死锁并可靠关闭文件监听与加载资源。 --- .../common/env/aether/AetherResolver.java | 52 +++++----- common-legacy-api/build.gradle.kts | 3 +- .../java/taboolib/common5/FileWatcher.java | 97 +++++++++++++------ .../taboolib/common5/FileWatcherTest.kt | 36 +++++++ .../taboolib/common/util/SyncExecutor.kt | 14 ++- .../taboolib/common/util/SyncExecutorTest.kt | 31 ++++++ .../taboolib/common/function/Throttle.kt | 31 ++++-- .../taboolib/common/io/FileDeleteAsync.kt | 60 +++++++----- .../kotlin/taboolib/common/util/Random.kt | 11 ++- .../taboolib/common/function/ThrottleTest.kt | 34 +++++++ .../taboolib/common/io/FileDeleteAsyncTest.kt | 43 ++++++++ .../kotlin/taboolib/common/util/RandomTest.kt | 37 +++++++ .../java/taboolib/common/PrimitiveIO.java | 22 +++-- .../java/taboolib/common/PrimitiveLoader.java | 24 ++++- .../kotlin/taboolib/common/PrimitiveIOTest.kt | 65 +++++++++++++ .../taboolib/common/PrimitiveLoaderTest.kt | 22 +++++ 16 files changed, 472 insertions(+), 110 deletions(-) create mode 100644 common-legacy-api/src/test/kotlin/taboolib/common5/FileWatcherTest.kt create mode 100644 common-platform-api/src/test/kotlin/taboolib/common/util/SyncExecutorTest.kt create mode 100644 common-util/src/test/kotlin/taboolib/common/function/ThrottleTest.kt create mode 100644 common-util/src/test/kotlin/taboolib/common/io/FileDeleteAsyncTest.kt create mode 100644 common-util/src/test/kotlin/taboolib/common/util/RandomTest.kt create mode 100644 common/src/test/kotlin/taboolib/common/PrimitiveIOTest.kt create mode 100644 common/src/test/kotlin/taboolib/common/PrimitiveLoaderTest.kt diff --git a/common-env/src/main/java/taboolib/common/env/aether/AetherResolver.java b/common-env/src/main/java/taboolib/common/env/aether/AetherResolver.java index 9f3769779..f734189b8 100644 --- a/common-env/src/main/java/taboolib/common/env/aether/AetherResolver.java +++ b/common-env/src/main/java/taboolib/common/env/aether/AetherResolver.java @@ -124,30 +124,38 @@ public static AetherResolver of(@NotNull String repository) { String id = file.getParentFile().getParentFile().getPath() + ":" + PrimitiveSettings.IS_ISOLATED_MODE // 区分类加载器 (隔离类加载器或插件类加载器) + ":" + (relocation != null ? relocation.hashCode() : 0); // 区分不同的重定向规则 - if (injectedDependencies.contains(id)) return null; - else injectedDependencies.add(id); - // 如果没有重定向规则,直接注入 - if (relocation == null || relocation.isEmpty()) { - return ClassAppender.addPath(file.toPath(), PrimitiveSettings.IS_ISOLATED_MODE, isExternal); - } else { - // 获取重定向后的文件 - String name = file.getName().substring(0, file.getName().lastIndexOf('.')); - File rel = new File(file.getParentFile(), name + "_r2_" + Math.abs(relocation.hashCode()) + ".jar"); - // 如果文件不存在或者文件大小为 0,就执行重定向逻辑 - if (!rel.exists() || rel.length() == 0) { - try { - // 获取重定向规则 - List rules = relocation.stream().map(JarRelocation::toRelocation).collect(Collectors.toList()); - // 获取临时文件 - File tempSourceFile = PrimitiveIO.copyFile(file, File.createTempFile(file.getName(), ".jar")); - // 运行 - new JarRelocator(tempSourceFile, rel, rules).run(); - } catch (IOException e) { - throw new IllegalStateException(String.format("Unable to relocate %s%n", file), e); + if (!injectedDependencies.add(id)) return null; + try { + // 如果没有重定向规则,直接注入 + if (relocation == null || relocation.isEmpty()) { + return ClassAppender.addPath(file.toPath(), PrimitiveSettings.IS_ISOLATED_MODE, isExternal); + } else { + // 获取重定向后的文件 + String name = file.getName().substring(0, file.getName().lastIndexOf('.')); + File rel = new File(file.getParentFile(), name + "_r2_" + Math.abs(relocation.hashCode()) + ".jar"); + // 如果文件不存在或者文件大小为 0,就执行重定向逻辑 + if (!rel.exists() || rel.length() == 0) { + File tempSourceFile = File.createTempFile(file.getName(), ".jar"); + try { + // 获取重定向规则 + List rules = relocation.stream().map(JarRelocation::toRelocation).collect(Collectors.toList()); + PrimitiveIO.copyFile(file, tempSourceFile); + new JarRelocator(tempSourceFile, rel, rules).run(); + } catch (IOException e) { + throw new IllegalStateException(String.format("Unable to relocate %s%n", file), e); + } finally { + if (!tempSourceFile.delete()) { + tempSourceFile.deleteOnExit(); + } + } } + // 注入重定向后的文件 + return ClassAppender.addPath(rel.toPath(), PrimitiveSettings.IS_ISOLATED_MODE, isExternal); } - // 注入重定向后的文件 - return ClassAppender.addPath(rel.toPath(), PrimitiveSettings.IS_ISOLATED_MODE, isExternal); + } catch (Throwable ex) { + // 注入失败后允许后续调用重试,避免失败状态永久污染缓存。 + injectedDependencies.remove(id); + throw ex; } } } diff --git a/common-legacy-api/build.gradle.kts b/common-legacy-api/build.gradle.kts index d2c35ef5c..e1196e997 100644 --- a/common-legacy-api/build.gradle.kts +++ b/common-legacy-api/build.gradle.kts @@ -1,6 +1,7 @@ dependencies { compileOnly(project(":common")) compileOnly(project(":common-env")) + testImplementation(project(":common")) compileOnly(project(":common-platform-api")) compileOnly(project(":common-util")) -} \ No newline at end of file +} diff --git a/common-legacy-api/src/main/java/taboolib/common5/FileWatcher.java b/common-legacy-api/src/main/java/taboolib/common5/FileWatcher.java index dd927faa4..cc158f14c 100755 --- a/common-legacy-api/src/main/java/taboolib/common5/FileWatcher.java +++ b/common-legacy-api/src/main/java/taboolib/common5/FileWatcher.java @@ -14,6 +14,7 @@ import java.util.concurrent.Executors; import java.util.concurrent.ScheduledExecutorService; import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicBoolean; import java.util.function.Consumer; /** @@ -50,6 +51,11 @@ public class FileWatcher { */ private final WatchService watchService; + /** + * 监听器是否已经释放 + */ + private final AtomicBoolean released = new AtomicBoolean(false); + public FileWatcher(int interval) { WatchService ws; try { @@ -64,29 +70,41 @@ public FileWatcher(int interval) { this.watchService = ws; if (this.watchService != null) { this.executorService.scheduleAtFixedRate(() -> { - WatchKey key; - while ((key = watchService.poll()) != null) { - WatchKey finalKey = key; - key.pollEvents().forEach(event -> { - if (event.context() instanceof Path) { - Path changedPath = (Path) event.context(); - // 通过 WatchKey 获取监听的目录,构建完整路径 - Path watchedPath = (Path) finalKey.watchable(); - Path fullChangedPath = watchedPath.resolve(changedPath); + try { + WatchKey key; + while ((key = watchService.poll()) != null) { + WatchKey finalKey = key; + key.pollEvents().forEach(event -> { + if (event.context() instanceof Path) { + Path changedPath = (Path) event.context(); + // 通过 WatchKey 获取监听的目录,构建完整路径 + Path watchedPath = (Path) finalKey.watchable(); + Path fullChangedPath = watchedPath.resolve(changedPath).toAbsolutePath().normalize(); + fileListenerMap.forEach((file, listener) -> { + try { + listener.handleEvent(fullChangedPath); + } catch (Throwable ex) { + ex.printStackTrace(); + } + }); + } + }); + if (!key.reset()) { fileListenerMap.forEach((file, listener) -> { - try { - listener.handleEvent(fullChangedPath); - } catch (Throwable ex) { - ex.printStackTrace(); + if (listener.watchKey == finalKey) { + fileListenerMap.remove(file, listener); } }); } - }); - key.reset(); + } + } catch (ClosedWatchServiceException ignored) { + // 正常释放时关闭 WatchService,会终止后续轮询 } }, 1000, interval, TimeUnit.MILLISECONDS); // 注册关闭回调 TabooLib.registerLifeCycleTask(LifeCycle.DISABLE, 0, this::release); + } else { + this.executorService.shutdownNow(); } } @@ -108,14 +126,19 @@ public void addSimpleListener(File file, Consumer runnable) { * @param runImmediately 是否在添加监听器时立即执行一次 */ public void addSimpleListener(File file, Consumer runnable, boolean runImmediately) { - if (watchService == null) { + if (watchService == null || released.get()) { return; } if (runImmediately) { runnable.accept(file); } try { - fileListenerMap.put(file, new FileListener(file, runnable, this)); + File canonicalFile = file.getCanonicalFile(); + FileListener listener = new FileListener(canonicalFile, runnable, this); + FileListener previous = fileListenerMap.put(canonicalFile, listener); + if (previous != null) { + previous.cancel(); + } } catch (IOException e) { throw new RuntimeException(e); } @@ -127,7 +150,13 @@ public void addSimpleListener(File file, Consumer runnable, boolean runImm * @param file 要移除监听的文件 */ public void removeListener(File file) { - FileListener listener = fileListenerMap.remove(file); + File canonicalFile; + try { + canonicalFile = file.getCanonicalFile(); + } catch (IOException ignored) { + canonicalFile = file.getAbsoluteFile(); + } + FileListener listener = fileListenerMap.remove(canonicalFile); if (listener != null) { listener.cancel(); } @@ -137,8 +166,18 @@ public void removeListener(File file) { * 释放资源 */ public void release() { - executorService.shutdown(); + if (!released.compareAndSet(false, true)) { + return; + } fileListenerMap.values().forEach(FileListener::cancel); + fileListenerMap.clear(); + if (watchService != null) { + try { + watchService.close(); + } catch (IOException ignored) { + } + } + executorService.shutdownNow(); } /** @@ -170,29 +209,25 @@ static class FileListener { } public void handleEvent(Path fullChangedPath) { + Path watchedFile = file.toPath().toAbsolutePath().normalize(); + Path changedFile = fullChangedPath.toAbsolutePath().normalize(); // 监听目录 if (file.isDirectory()) { - try { - // 使用 relativize 检查路径关系,更加准确 - file.toPath().relativize(fullChangedPath); - callback.accept(fullChangedPath.toFile()); - } catch (IllegalArgumentException ignored) { - // 如果不是子路径,会抛出异常,直接忽略 + if (changedFile.startsWith(watchedFile)) { + callback.accept(changedFile.toFile()); } } - // 监听文件 - else if (isSameFile(fullChangedPath, file.toPath())) { - callback.accept(fullChangedPath.toFile()); + // 监听文件。删除事件发生时目标文件已不存在,Files.isSameFile 会失败, + // 因此先比较规范化路径,再用 isSameFile 兼容符号链接。 + else if (changedFile.equals(watchedFile) || isSameFile(changedFile, watchedFile)) { + callback.accept(changedFile.toFile()); } } public boolean isSameFile(Path path1, Path path2) { try { - // 使用 Files.isSameFile() 判断两个路径是否指向同一个文件 - // 该方法会考虑符号链接等情况 return Files.isSameFile(path1, path2); } catch (IOException e) { - // 如果出现 IO 异常则返回 false return false; } } diff --git a/common-legacy-api/src/test/kotlin/taboolib/common5/FileWatcherTest.kt b/common-legacy-api/src/test/kotlin/taboolib/common5/FileWatcherTest.kt new file mode 100644 index 000000000..e82a11bf5 --- /dev/null +++ b/common-legacy-api/src/test/kotlin/taboolib/common5/FileWatcherTest.kt @@ -0,0 +1,36 @@ +package taboolib.common5 + +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.io.TempDir +import java.nio.file.Files +import java.nio.file.Path +import java.util.concurrent.CountDownLatch +import java.util.concurrent.TimeUnit + +class FileWatcherTest { + + @TempDir + lateinit var tempDirectory: Path + + @Test + fun `file deletion is reported and watcher can be released repeatedly`() { + val file = Files.write(tempDirectory.resolve("watched.txt"), byteArrayOf(1)).toFile() + val deleted = CountDownLatch(1) + val watcher = FileWatcher(20) + try { + watcher.addSimpleListener(file, { changed -> + if (changed.absoluteFile == file.absoluteFile && !changed.exists()) { + deleted.countDown() + } + }) + Files.delete(file.toPath()) + + assertTrue(deleted.await(5, TimeUnit.SECONDS)) + } finally { + watcher.release() + watcher.release() + FileWatcher.INSTANCE.release() + } + } +} diff --git a/common-platform-api/src/main/kotlin/taboolib/common/util/SyncExecutor.kt b/common-platform-api/src/main/kotlin/taboolib/common/util/SyncExecutor.kt index 232b4212a..b2c09789f 100644 --- a/common-platform-api/src/main/kotlin/taboolib/common/util/SyncExecutor.kt +++ b/common-platform-api/src/main/kotlin/taboolib/common/util/SyncExecutor.kt @@ -4,6 +4,14 @@ import taboolib.common.platform.function.isPrimaryThread import taboolib.common.platform.function.submit import java.util.concurrent.CompletableFuture +internal fun CompletableFuture.completeWith(func: () -> T) { + try { + complete(func()) + } catch (ex: Throwable) { + completeExceptionally(ex) + } +} + /** * 在异步线程执行一个同步任务,并等待其完成 * @@ -15,7 +23,7 @@ fun sync(func: () -> T): T { error("Cannot run sync task in main thread.") } val future = CompletableFuture() - submit { future.complete(func()) } + submit { future.completeWith(func) } return future.join() } @@ -30,6 +38,6 @@ fun runSync(func: () -> T): T { return func() } val future = CompletableFuture() - submit { future.complete(func()) } + submit { future.completeWith(func) } return future.join() -} \ No newline at end of file +} diff --git a/common-platform-api/src/test/kotlin/taboolib/common/util/SyncExecutorTest.kt b/common-platform-api/src/test/kotlin/taboolib/common/util/SyncExecutorTest.kt new file mode 100644 index 000000000..a5ba8493e --- /dev/null +++ b/common-platform-api/src/test/kotlin/taboolib/common/util/SyncExecutorTest.kt @@ -0,0 +1,31 @@ +package taboolib.common.util + +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertSame +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.assertThrows +import java.util.concurrent.CompletableFuture +import java.util.concurrent.CompletionException + +class SyncExecutorTest { + + @Test + fun `completeWith completes successful result`() { + val future = CompletableFuture() + + future.completeWith { 42 } + + assertEquals(42, future.join()) + } + + @Test + fun `completeWith propagates task failure`() { + val future = CompletableFuture() + val failure = IllegalStateException("boom") + + future.completeWith { throw failure } + + val thrown = assertThrows { future.join() } + assertSame(failure, thrown.cause) + } +} diff --git a/common-util/src/main/kotlin/taboolib/common/function/Throttle.kt b/common-util/src/main/kotlin/taboolib/common/function/Throttle.kt index 5e05ede51..d395fbbb6 100644 --- a/common-util/src/main/kotlin/taboolib/common/function/Throttle.kt +++ b/common-util/src/main/kotlin/taboolib/common/function/Throttle.kt @@ -2,6 +2,7 @@ package taboolib.common.function import java.util.concurrent.ConcurrentHashMap import java.util.concurrent.CopyOnWriteArrayList +import java.util.concurrent.atomic.AtomicLong abstract class ThrottleFunction( val keyType: Class, @@ -22,11 +23,16 @@ abstract class ThrottleFunction( */ open fun canExecute(key: K, delay: Long = this.delay): Boolean { val currentTime = System.currentTimeMillis() - val lastExecuteTime = throttleMap.getOrDefault(key, 0L) - return if (currentTime - lastExecuteTime >= delay) { - throttleMap[key] = currentTime - true - } else false + var allowed = false + throttleMap.compute(key) { _, lastExecuteTime -> + if (lastExecuteTime == null || delay <= 0 || currentTime < lastExecuteTime || currentTime - lastExecuteTime >= delay) { + allowed = true + currentTime + } else { + lastExecuteTime + } + } + return allowed } /** @@ -56,7 +62,7 @@ abstract class ThrottleFunction( val action: () -> Unit, ) : ThrottleFunction(Unit::class.java, delay) { - private var lastExecuteTime = 0L + private val lastExecuteTime = AtomicLong(Long.MIN_VALUE) fun canExecute(delay: Long = this.delay): Boolean { return canExecute(Unit, delay) @@ -64,10 +70,15 @@ abstract class ThrottleFunction( override fun canExecute(key: Unit, delay: Long): Boolean { val currentTime = System.currentTimeMillis() - return if (currentTime - lastExecuteTime >= delay) { - lastExecuteTime = currentTime - true - } else false + while (true) { + val last = lastExecuteTime.get() + if (last != Long.MIN_VALUE && delay > 0 && currentTime >= last && currentTime - last < delay) { + return false + } + if (lastExecuteTime.compareAndSet(last, currentTime)) { + return true + } + } } /** diff --git a/common-util/src/main/kotlin/taboolib/common/io/FileDeleteAsync.kt b/common-util/src/main/kotlin/taboolib/common/io/FileDeleteAsync.kt index 5c9b770eb..feac06513 100644 --- a/common-util/src/main/kotlin/taboolib/common/io/FileDeleteAsync.kt +++ b/common-util/src/main/kotlin/taboolib/common/io/FileDeleteAsync.kt @@ -1,11 +1,16 @@ package taboolib.common.io import java.io.File -import java.util.concurrent.Executors +import java.io.IOException +import java.nio.file.FileVisitResult +import java.nio.file.Files +import java.nio.file.LinkOption +import java.nio.file.Path +import java.nio.file.SimpleFileVisitor +import java.nio.file.attribute.BasicFileAttributes +import java.util.concurrent.CompletableFuture import java.util.concurrent.Future -private val executor = Executors.newFixedThreadPool(Runtime.getRuntime().availableProcessors())!! - /** * Delete the directory and all its contents asynchronously.
* if you need to wait for the deletion to complete, pass in a set here and use Set#forEach(Future<*>::get) @@ -15,27 +20,32 @@ private val executor = Executors.newFixedThreadPool(Runtime.getRuntime().availab * @author Kylepoops */ fun File.deepDeleteAsync(await: Boolean = false, futures: MutableSet>? = null) { - // first submit the task and get the future - val future = executor.submit { - if (this.exists()) { - if (this.isDirectory) { - listFiles()?.let { files -> - // Construct another future set here - // Because we need all the subdirectories and files to be deleted before this directory is deleted - val thisFutures = mutableSetOf>() - // Pass the future set to this, so we can store all the future - files.forEach { it.deepDeleteAsync(futures = thisFutures) } - // Wait for all the subdirectories and files to be deleted - thisFutures.forEach(Future<*>::get) - } + // Traverse the whole tree in one asynchronous task. Submitting child tasks and waiting for them + // from the same bounded executor can exhaust every worker and deadlock on sufficiently deep trees. + val future = CompletableFuture.runAsync { deleteTree(toPath()) } + futures?.add(future) + if (await) { + future.get() + } +} + +private fun deleteTree(root: Path) { + if (!Files.exists(root, LinkOption.NOFOLLOW_LINKS)) { + return + } + Files.walkFileTree(root, object : SimpleFileVisitor() { + + override fun visitFile(file: Path, attrs: BasicFileAttributes): FileVisitResult { + Files.deleteIfExists(file) + return FileVisitResult.CONTINUE + } + + override fun postVisitDirectory(dir: Path, exc: IOException?): FileVisitResult { + if (exc != null) { + throw exc } - // Finally, delete this file or directory - this.delete() + Files.deleteIfExists(dir) + return FileVisitResult.CONTINUE } - } - // Add the future to the future set - futures?.add(future) - // Wait the task to finish before returning if await is true - // It shouldn't be called inside this function - if (await) future.get() -} \ No newline at end of file + }) +} diff --git a/common-util/src/main/kotlin/taboolib/common/util/Random.kt b/common-util/src/main/kotlin/taboolib/common/util/Random.kt index 8a4aa190b..4c02e42f1 100644 --- a/common-util/src/main/kotlin/taboolib/common/util/Random.kt +++ b/common-util/src/main/kotlin/taboolib/common/util/Random.kt @@ -18,7 +18,7 @@ fun random(): Random { * @param v 0-1 */ fun random(v: Double): Boolean { - return ThreadLocalRandom.current().nextDouble() <= v + return ThreadLocalRandom.current().nextDouble() < v } /** @@ -37,9 +37,12 @@ fun random(v: Int): Int { * @param num2 最大值 */ fun random(num1: Int, num2: Int): Int { - val min = min(num1, num2) - val max = max(num1, num2) - return ThreadLocalRandom.current().nextInt(min, max + 1) + val min = min(num1, num2).toLong() + val max = max(num1, num2).toLong() + if (min == max) { + return min.toInt() + } + return ThreadLocalRandom.current().nextLong(min, max + 1).toInt() } /** diff --git a/common-util/src/test/kotlin/taboolib/common/function/ThrottleTest.kt b/common-util/src/test/kotlin/taboolib/common/function/ThrottleTest.kt new file mode 100644 index 000000000..efc5e7744 --- /dev/null +++ b/common-util/src/test/kotlin/taboolib/common/function/ThrottleTest.kt @@ -0,0 +1,34 @@ +package taboolib.common.function + +import org.junit.jupiter.api.Assertions.assertFalse +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test + +class ThrottleTest { + + @Test + fun `first invocation is allowed for any delay`() { + val throttle = throttle(Long.MAX_VALUE) + + assertTrue(throttle.canExecute()) + assertFalse(throttle.canExecute()) + } + + @Test + fun `non-positive delay never suppresses invocation`() { + val throttle = throttle(0) + + repeat(10) { + assertTrue(throttle.canExecute()) + } + } + + @Test + fun `keyed throttle treats each key independently`() { + val throttle = throttle(Long.MAX_VALUE) + + assertTrue(throttle.canExecute("first")) + assertFalse(throttle.canExecute("first")) + assertTrue(throttle.canExecute("second")) + } +} diff --git a/common-util/src/test/kotlin/taboolib/common/io/FileDeleteAsyncTest.kt b/common-util/src/test/kotlin/taboolib/common/io/FileDeleteAsyncTest.kt new file mode 100644 index 000000000..6acfd696f --- /dev/null +++ b/common-util/src/test/kotlin/taboolib/common/io/FileDeleteAsyncTest.kt @@ -0,0 +1,43 @@ +package taboolib.common.io + +import org.junit.jupiter.api.Assertions.assertFalse +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.io.TempDir +import java.nio.file.Files +import java.nio.file.Path +import java.util.Collections +import java.util.concurrent.Future +import java.util.concurrent.TimeUnit + +class FileDeleteAsyncTest { + + @TempDir + lateinit var tempDirectory: Path + + @Test + fun `large directory tree completes without executor starvation`() { + val root = Files.createDirectory(tempDirectory.resolve("root")) + val branchCount = maxOf(16, Runtime.getRuntime().availableProcessors() * 2) + repeat(branchCount) { index -> + val branch = Files.createDirectory(root.resolve("branch-$index")) + Files.write(branch.resolve("value.txt"), index.toString().toByteArray()) + } + val futures = Collections.synchronizedSet(mutableSetOf>()) + + root.toFile().deepDeleteAsync(futures = futures) + + futures.single().get(10, TimeUnit.SECONDS) + assertFalse(Files.exists(root)) + } + + @Test + fun `missing path is a successful no-op`() { + val missing = tempDirectory.resolve("missing").toFile() + val futures = mutableSetOf>() + + missing.deepDeleteAsync(futures = futures) + + futures.single().get(5, TimeUnit.SECONDS) + assertFalse(missing.exists()) + } +} diff --git a/common-util/src/test/kotlin/taboolib/common/util/RandomTest.kt b/common-util/src/test/kotlin/taboolib/common/util/RandomTest.kt new file mode 100644 index 000000000..c5ca2cfe7 --- /dev/null +++ b/common-util/src/test/kotlin/taboolib/common/util/RandomTest.kt @@ -0,0 +1,37 @@ +package taboolib.common.util + +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertFalse +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test + +class RandomTest { + + @Test + fun `zero probability is always false`() { + repeat(10_000) { + assertFalse(random(0.0)) + } + } + + @Test + fun `probability at least one is always true`() { + repeat(100) { + assertTrue(random(1.0)) + assertTrue(random(Double.POSITIVE_INFINITY)) + } + } + + @Test + fun `inclusive int range supports full integer domain`() { + repeat(10_000) { + val value = random(Int.MIN_VALUE, Int.MAX_VALUE) + assertTrue(value in Int.MIN_VALUE..Int.MAX_VALUE) + } + } + + @Test + fun `equal maximum bounds return maximum value`() { + assertEquals(Int.MAX_VALUE, random(Int.MAX_VALUE, Int.MAX_VALUE)) + } +} diff --git a/common/src/main/java/taboolib/common/PrimitiveIO.java b/common/src/main/java/taboolib/common/PrimitiveIO.java index 3d7308072..3bd4fbe6e 100644 --- a/common/src/main/java/taboolib/common/PrimitiveIO.java +++ b/common/src/main/java/taboolib/common/PrimitiveIO.java @@ -225,16 +225,20 @@ public static File copyFile(File from, File to) { * @param url 地址 * @param out 目标文件 */ - @SuppressWarnings("StatementWithEmptyBody") public static void downloadFile(URL url, File out) throws IOException { - out.getParentFile().mkdirs(); - InputStream ins = url.openStream(); - OutputStream outs = Files.newOutputStream(out.toPath()); - byte[] buffer = new byte[BUFFER_SIZE]; - for (int len; (len = ins.read(buffer)) > 0; outs.write(buffer, 0, len)) - ; - outs.close(); - ins.close(); + File parent = out.getParentFile(); + if (parent != null) { + parent.mkdirs(); + } + try (InputStream input = url.openStream(); OutputStream output = Files.newOutputStream(out.toPath())) { + byte[] buffer = new byte[BUFFER_SIZE]; + int length; + while ((length = input.read(buffer)) != -1) { + if (length > 0) { + output.write(buffer, 0, length); + } + } + } } public static String getRunningFileName() { diff --git a/common/src/main/java/taboolib/common/PrimitiveLoader.java b/common/src/main/java/taboolib/common/PrimitiveLoader.java index 0c85b27ee..6ba6dcb04 100644 --- a/common/src/main/java/taboolib/common/PrimitiveLoader.java +++ b/common/src/main/java/taboolib/common/PrimitiveLoader.java @@ -242,12 +242,18 @@ static void loadFile(File file, boolean isIsolated, boolean isExternal, List 0 && buildNumberNodes.getLength() > 0) { @@ -343,6 +353,10 @@ static void generateSha1File(File jarFile, File shaFile) { } } + static boolean shouldRelocate(File jar, boolean forceRelocate) { + return !jar.exists() || jar.length() == 0 || (IS_FORCE_DOWNLOAD_IN_DEV_MODE && IS_DEV_MODE) || forceRelocate; + } + static int deepHashCode(List array) { int result = 1; for (String[] element : array) { diff --git a/common/src/test/kotlin/taboolib/common/PrimitiveIOTest.kt b/common/src/test/kotlin/taboolib/common/PrimitiveIOTest.kt new file mode 100644 index 000000000..3c47ee7d5 --- /dev/null +++ b/common/src/test/kotlin/taboolib/common/PrimitiveIOTest.kt @@ -0,0 +1,65 @@ +package taboolib.common + +import org.junit.jupiter.api.Assertions.assertArrayEquals +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.assertThrows +import org.junit.jupiter.api.io.TempDir +import java.io.ByteArrayInputStream +import java.io.IOException +import java.net.URL +import java.net.URLConnection +import java.net.URLStreamHandler +import java.nio.file.Files +import java.nio.file.Path + +class PrimitiveIOTest { + + @TempDir + lateinit var tempDirectory: Path + + @Test + fun `download closes input after success`() { + val content = "taboolib".toByteArray() + val input = TrackingInputStream(content) + val target = tempDirectory.resolve("download.bin").toFile() + + PrimitiveIO.downloadFile(memoryUrl(input), target) + + assertTrue(input.closed) + assertArrayEquals(content, Files.readAllBytes(target.toPath())) + } + + @Test + fun `download closes input when output cannot be opened`() { + val input = TrackingInputStream("taboolib".toByteArray()) + val targetDirectory = Files.createDirectory(tempDirectory.resolve("target")).toFile() + + assertThrows { + PrimitiveIO.downloadFile(memoryUrl(input), targetDirectory) + } + assertTrue(input.closed) + } + + private fun memoryUrl(input: TrackingInputStream): URL { + return URL(null, "memory://download", object : URLStreamHandler() { + override fun openConnection(url: URL): URLConnection { + return object : URLConnection(url) { + override fun connect() = Unit + override fun getInputStream() = input + } + } + }) + } + + private class TrackingInputStream(content: ByteArray) : ByteArrayInputStream(content) { + + var closed = false + private set + + override fun close() { + closed = true + super.close() + } + } +} diff --git a/common/src/test/kotlin/taboolib/common/PrimitiveLoaderTest.kt b/common/src/test/kotlin/taboolib/common/PrimitiveLoaderTest.kt new file mode 100644 index 000000000..cde0dddf5 --- /dev/null +++ b/common/src/test/kotlin/taboolib/common/PrimitiveLoaderTest.kt @@ -0,0 +1,22 @@ +package taboolib.common + +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.io.TempDir +import java.nio.file.Files +import java.nio.file.Path + +class PrimitiveLoaderTest { + + @TempDir + lateinit var tempDirectory: Path + + @Test + fun `missing and empty relocation targets are regenerated`() { + val missing = tempDirectory.resolve("missing.jar").toFile() + val empty = Files.createFile(tempDirectory.resolve("empty.jar")).toFile() + + assertTrue(PrimitiveLoader.shouldRelocate(missing, false)) + assertTrue(PrimitiveLoader.shouldRelocate(empty, false)) + } +}