diff --git a/CHANGELOG.md b/CHANGELOG.md index 2d373d4..cf10016 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -20,6 +20,21 @@ Release tags use the `v` prefix (e.g. `v3.0.2`). --- +## [3.4.3] - 2026-07-27 + +### Fixed + +- Fixed `/rtp` intermittently failing (observed on Purpur) with `CompletionException - java.lang.IllegalArgumentException: argument type mismatch` during async location search. The Folia region/global-scheduler reflection bridges in `PaperPlatformScheduler` and `BukkitPlatformScheduler` only caught `ReflectiveOperationException` around `Method.invoke()` calls; they now also catch `RuntimeException` and fall back to the standard Bukkit scheduler instead of letting the exception propagate. This can occur even on non-Folia servers whose classpath carries the Folia regionized-runtime marker class. +- `BukkitPlatformScheduler#executeRegion`/`executeGlobal`/`executeGlobalDelayed`/`executeRegionDelayed` now consistently fall back to the standard Bukkit scheduler when the reflective Folia scheduler call fails, instead of silently dropping the task (matching existing `PaperPlatformScheduler` behavior). + +### Added + +- Regression test coverage for the scheduler reflection-fallback fix: + - `PaperPlatformSchedulerReflectionFallbackTest` and `BukkitPlatformSchedulerReflectionFallbackTest` simulate a broken region/global-scheduler reflection call and verify the submitted task still runs via the standard scheduler fallback. + - `PlatformRuntimeCapabilities.PURPUR_REGIONIZED` constant documenting the exact capability combination that triggered the bug. + +--- + ## [3.4.2] - 2026-07-07 ### Fixed diff --git a/ezrtp-api/pom.xml b/ezrtp-api/pom.xml index 116980f..cbb0be9 100644 --- a/ezrtp-api/pom.xml +++ b/ezrtp-api/pom.xml @@ -5,13 +5,13 @@ com.skyblockexp ezrtp-parent - 3.4.2 + 3.4.3 ../pom.xml com.skyblockexp ezrtp-api - 3.4.2 + 3.4.3 jar EzRTP API Lightweight public API for EzRTP intended for third-party plugins. diff --git a/ezrtp-bukkit/pom.xml b/ezrtp-bukkit/pom.xml index 45c679a..397a905 100644 --- a/ezrtp-bukkit/pom.xml +++ b/ezrtp-bukkit/pom.xml @@ -5,13 +5,13 @@ com.skyblockexp ezrtp-parent - 3.4.2 + 3.4.3 ../pom.xml com.skyblockexp ezrtp-bukkit - 3.4.2 + 3.4.3 jar EzRTP (Bukkit) EzRTP Bukkit-compatible plugin module. @@ -111,7 +111,7 @@ com.skyblockexp ezrtp-common - 3.4.2 + 3.4.3 diff --git a/ezrtp-bukkit/src/main/java/com/skyblockexp/ezrtp/platform/BukkitPlatformScheduler.java b/ezrtp-bukkit/src/main/java/com/skyblockexp/ezrtp/platform/BukkitPlatformScheduler.java index eb50354..7a724fa 100644 --- a/ezrtp-bukkit/src/main/java/com/skyblockexp/ezrtp/platform/BukkitPlatformScheduler.java +++ b/ezrtp-bukkit/src/main/java/com/skyblockexp/ezrtp/platform/BukkitPlatformScheduler.java @@ -8,6 +8,7 @@ import java.lang.reflect.Method; import java.util.concurrent.CompletableFuture; +import java.util.logging.Level; public final class BukkitPlatformScheduler implements PlatformScheduler { @@ -35,12 +36,15 @@ public void executeAsync(Runnable task) { @Override public void executeRegion(World world, int chunkX, int chunkZ, Runnable task) { if (capabilities.regionizedRuntime()) { - if (world != null) { - invokeRegionTask(world, chunkX, chunkZ, task); - } else { - invokeGlobalRun(task); + boolean handled = world != null + ? invokeRegionTask(world, chunkX, chunkZ, task) + : invokeGlobalRun(task); + if (handled) { + return; } - return; + // Regionized runtime was detected but the reflective call failed unexpectedly + // (e.g. an API signature mismatch). Falling through to the standard scheduler + // is safer than silently dropping the task. } plugin.getServer().getScheduler().runTask(plugin, task); } @@ -63,8 +67,7 @@ public PlatformTask scheduleRepeating(Runnable task, long delayTicks, long perio @Override public void executeGlobal(Runnable task) { - if (capabilities.regionizedRuntime()) { - invokeGlobalRun(task); + if (capabilities.regionizedRuntime() && invokeGlobalRun(task)) { return; } plugin.getServer().getScheduler().runTask(plugin, task); @@ -77,7 +80,8 @@ public PlatformTask executeGlobalDelayed(Runnable task, long delayTicks) { if (foliaTask != null) { return foliaTask; } - return () -> {}; + // Reflective call failed unexpectedly; fall through to the standard scheduler + // rather than returning a no-op that would silently drop the task. } org.bukkit.scheduler.BukkitTask bukkit = plugin.getServer().getScheduler().runTaskLater(plugin, task, delayTicks); @@ -88,12 +92,12 @@ public PlatformTask executeGlobalDelayed(Runnable task, long delayTicks) { public void executeRegionDelayed( World world, int chunkX, int chunkZ, Runnable task, long delayTicks) { if (capabilities.regionizedRuntime()) { - if (world != null) { - invokeRegionDelayed(world, chunkX, chunkZ, task, delayTicks); - } else { - invokeGlobalRunDelayed(task, delayTicks); + boolean handled = world != null + ? invokeRegionDelayed(world, chunkX, chunkZ, task, delayTicks) + : invokeGlobalRunDelayed(task, delayTicks) != null; + if (handled) { + return; } - return; } plugin.getServer().getScheduler().runTaskLater(plugin, task, delayTicks); } @@ -114,8 +118,11 @@ private CompletableFuture invokeTeleportAsync(Player player, Location d try { Method method = player.getClass().getMethod("teleportAsync", Location.class); return (CompletableFuture) method.invoke(player, destination); - } catch (ReflectiveOperationException ignored) { - // teleportAsync not available (shouldn't happen on Folia); fall back to sync. + } catch (ReflectiveOperationException | RuntimeException ex) { + // teleportAsync not available, or the reflective call failed due to an API + // signature change on a newer/forked server; fall back to sync teleport rather + // than propagating the failure into the async RTP search chain. + logReflectionFallback("teleportAsync", ex); return CompletableFuture.completedFuture(player.teleport(destination)); } } @@ -128,7 +135,8 @@ private boolean invokeGlobalRun(Runnable task) { run.invoke(globalScheduler, plugin, (java.util.function.Consumer) ignored -> task.run()); return true; - } catch (ReflectiveOperationException ignored) { + } catch (ReflectiveOperationException | RuntimeException ex) { + logReflectionFallback("getGlobalRegionScheduler#run", ex); return false; } } @@ -144,10 +152,11 @@ private PlatformTask invokeGlobalRunDelayed(Runnable task, long delayTicks) { return () -> { try { cancel.invoke(scheduledTask); - } catch (ReflectiveOperationException ignored) { + } catch (ReflectiveOperationException | RuntimeException ignored) { } }; - } catch (ReflectiveOperationException ignored) { + } catch (ReflectiveOperationException | RuntimeException ex) { + logReflectionFallback("getGlobalRegionScheduler#runDelayed", ex); return null; } } @@ -161,7 +170,8 @@ private boolean invokeRegionTask(World world, int chunkX, int chunkZ, Runnable t run.invoke(regionScheduler, plugin, world, chunkX, chunkZ, (java.util.function.Consumer) ignored -> task.run()); return true; - } catch (ReflectiveOperationException ignored) { + } catch (ReflectiveOperationException | RuntimeException ex) { + logReflectionFallback("getRegionScheduler#run", ex); return false; } } @@ -176,7 +186,8 @@ private boolean invokeRegionDelayed( runDelayed.invoke(regionScheduler, plugin, world, chunkX, chunkZ, (java.util.function.Consumer) ignored -> task.run(), delayTicks); return true; - } catch (ReflectiveOperationException ignored) { + } catch (ReflectiveOperationException | RuntimeException ex) { + logReflectionFallback("getRegionScheduler#runDelayed", ex); return false; } } @@ -198,11 +209,27 @@ private PlatformTask scheduleGlobalRepeating(Runnable task, long delayTicks, lon return () -> { try { cancel.invoke(scheduledTask); - } catch (ReflectiveOperationException ignored) { + } catch (ReflectiveOperationException | RuntimeException ignored) { } }; - } catch (ReflectiveOperationException ignored) { + } catch (ReflectiveOperationException | RuntimeException ex) { + logReflectionFallback("getGlobalRegionScheduler#runAtFixedRate", ex); return null; } } + + /** + * Logs a fine-level diagnostic when a Folia/region-scheduler reflection call fails. + * + *

Reflective calls into region-scheduler APIs can fail with more than just + * {@link ReflectiveOperationException} — a signature change on a newer or forked + * server (e.g. Purpur) can surface as {@link IllegalArgumentException} ("argument + * type mismatch") from {@link Method#invoke}. These are caught broadly so a single + * unexpected API drift falls back to the standard scheduler instead of breaking + * the caller (notably the async RTP location search). + */ + private void logReflectionFallback(String operation, Throwable ex) { + plugin.getLogger().log(Level.FINE, "EzRTP: region-scheduler reflection call " + operation + + " failed, falling back to standard scheduler", ex); + } } diff --git a/ezrtp-bukkit/src/test/java/com/skyblockexp/ezrtp/platform/BukkitPlatformSchedulerFoliaTest.java b/ezrtp-bukkit/src/test/java/com/skyblockexp/ezrtp/platform/BukkitPlatformSchedulerFoliaTest.java index 2c05660..f6ce310 100644 --- a/ezrtp-bukkit/src/test/java/com/skyblockexp/ezrtp/platform/BukkitPlatformSchedulerFoliaTest.java +++ b/ezrtp-bukkit/src/test/java/com/skyblockexp/ezrtp/platform/BukkitPlatformSchedulerFoliaTest.java @@ -48,6 +48,7 @@ class BukkitPlatformSchedulerFoliaTest { void setUp() { when(plugin.getServer()).thenReturn(server); when(server.getScheduler()).thenReturn(bukkitScheduler); + when(plugin.getLogger()).thenReturn(java.util.logging.Logger.getLogger("test")); } // --- scheduleRepeating --- @@ -79,7 +80,9 @@ void scheduleRepeating_withoutFoliaAwareness_propagatesUnsupportedOperationExcep */ @Test void scheduleRepeating_withFoliaCapabilities_doesNotThrow() { - // No stub needed: the fixed implementation never reaches the Bukkit scheduler on Folia. + // scheduleRepeating intentionally stays a no-op on reflection failure (rather than + // falling back to the standard scheduler) since Folia forbids synchronous scheduling + // outright; see the comment in BukkitPlatformScheduler#scheduleRepeating. BukkitPlatformScheduler scheduler = new BukkitPlatformScheduler(plugin, PlatformRuntimeCapabilities.PAPER_FOLIA); @@ -110,11 +113,16 @@ void scheduleRepeating_withBukkitCapabilities_delegatesToBukkitScheduler() { @Test void executeGlobal_withFoliaCapabilities_doesNotThrow() { - // No stub: the fix never reaches the Bukkit scheduler when regionizedRuntime=true. + // Reflection naturally fails in the test JVM (no Folia classes present), so this + // now falls through to the standard Bukkit scheduler, matching PaperPlatformScheduler. + BukkitTask mockTask = mock(BukkitTask.class); + when(bukkitScheduler.runTask(any(Plugin.class), any(Runnable.class))).thenReturn(mockTask); + BukkitPlatformScheduler scheduler = new BukkitPlatformScheduler(plugin, PlatformRuntimeCapabilities.PAPER_FOLIA); assertDoesNotThrow(() -> scheduler.executeGlobal(() -> {})); + verify(bukkitScheduler).runTask(eq(plugin), any(Runnable.class)); } @Test @@ -134,7 +142,12 @@ void executeGlobal_withBukkitCapabilities_delegatesToBukkitScheduler() { @Test void executeGlobalDelayed_withFoliaCapabilities_doesNotThrow() { - // No stub: the fix never reaches the Bukkit scheduler when regionizedRuntime=true. + // Reflection naturally fails in the test JVM (no Folia classes present), so this + // now falls through to the standard Bukkit scheduler, matching PaperPlatformScheduler. + BukkitTask mockTask = mock(BukkitTask.class); + when(bukkitScheduler.runTaskLater(any(Plugin.class), any(Runnable.class), anyLong())) + .thenReturn(mockTask); + BukkitPlatformScheduler scheduler = new BukkitPlatformScheduler(plugin, PlatformRuntimeCapabilities.PAPER_FOLIA); @@ -161,22 +174,33 @@ void executeGlobalDelayed_withBukkitCapabilities_delegatesToBukkitScheduler() { @Test void executeRegion_withFoliaCapabilities_doesNotThrow() { - // No stub: the fix never reaches the Bukkit scheduler when regionizedRuntime=true. + // Reflection naturally fails in the test JVM (no Folia classes present), so this + // now falls through to the standard Bukkit scheduler, matching PaperPlatformScheduler. + BukkitTask mockTask = mock(BukkitTask.class); + when(bukkitScheduler.runTask(any(Plugin.class), any(Runnable.class))).thenReturn(mockTask); + BukkitPlatformScheduler scheduler = new BukkitPlatformScheduler(plugin, PlatformRuntimeCapabilities.PAPER_FOLIA); assertDoesNotThrow(() -> scheduler.executeRegion(null, 0, 0, () -> {})); + verify(bukkitScheduler).runTask(eq(plugin), any(Runnable.class)); } // --- executeRegionDelayed --- @Test void executeRegionDelayed_withFoliaCapabilities_doesNotThrow() { - // No stub: the fix never reaches the Bukkit scheduler when regionizedRuntime=true. + // Reflection naturally fails in the test JVM (no Folia classes present), so this + // now falls through to the standard Bukkit scheduler, matching PaperPlatformScheduler. + BukkitTask mockTask = mock(BukkitTask.class); + when(bukkitScheduler.runTaskLater(any(Plugin.class), any(Runnable.class), anyLong())) + .thenReturn(mockTask); + BukkitPlatformScheduler scheduler = new BukkitPlatformScheduler(plugin, PlatformRuntimeCapabilities.PAPER_FOLIA); assertDoesNotThrow(() -> scheduler.executeRegionDelayed(null, 0, 0, () -> {}, 20L)); + verify(bukkitScheduler).runTaskLater(eq(plugin), any(Runnable.class), eq(20L)); } // --- teleportAsync --- diff --git a/ezrtp-bukkit/src/test/java/com/skyblockexp/ezrtp/platform/BukkitPlatformSchedulerReflectionFallbackTest.java b/ezrtp-bukkit/src/test/java/com/skyblockexp/ezrtp/platform/BukkitPlatformSchedulerReflectionFallbackTest.java new file mode 100644 index 0000000..5f15e80 --- /dev/null +++ b/ezrtp-bukkit/src/test/java/com/skyblockexp/ezrtp/platform/BukkitPlatformSchedulerReflectionFallbackTest.java @@ -0,0 +1,159 @@ +package com.skyblockexp.ezrtp.platform; + +import io.papermc.paper.threadedregions.scheduler.GlobalRegionScheduler; +import io.papermc.paper.threadedregions.scheduler.RegionScheduler; +import org.bukkit.Bukkit; +import org.bukkit.Server; +import org.bukkit.World; +import org.bukkit.plugin.Plugin; +import org.bukkit.scheduler.BukkitScheduler; +import org.bukkit.scheduler.BukkitTask; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.MockedStatic; +import org.mockito.junit.jupiter.MockitoExtension; +import org.mockito.junit.jupiter.MockitoSettings; +import org.mockito.quality.Strictness; + +import java.util.concurrent.atomic.AtomicBoolean; + +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyInt; +import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.doAnswer; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.mockStatic; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +/** + * Regression coverage mirroring {@code PaperPlatformSchedulerReflectionFallbackTest}, for the + * {@link BukkitPlatformScheduler} fallback that is used when the Paper runtime module is absent. + * + *

Confirms that when {@code regionizedRuntime()} is {@code true} (the region-scheduler API is + * present on the classpath) but the reflective {@code Method.invoke} call fails unexpectedly — + * e.g. with {@link IllegalArgumentException} ("argument type mismatch") rather than + * {@link ReflectiveOperationException} — the scheduler falls back to the standard Bukkit + * scheduler and still runs the submitted task, instead of throwing or silently dropping it. + */ +@ExtendWith(MockitoExtension.class) +@MockitoSettings(strictness = Strictness.LENIENT) +class BukkitPlatformSchedulerReflectionFallbackTest { + + @Mock + private Plugin plugin; + + @Mock + private Server server; + + @Mock + private BukkitScheduler bukkitScheduler; + + @BeforeEach + void setUp() { + when(plugin.getServer()).thenReturn(server); + when(server.getScheduler()).thenReturn(bukkitScheduler); + when(plugin.getLogger()).thenReturn(java.util.logging.Logger.getLogger("test")); + } + + @Test + void executeRegion_fallsBackAndRunsTaskWhenRegionSchedulerReflectionThrows() { + World world = mock(World.class); + RegionScheduler brokenRegionScheduler = mock(RegionScheduler.class); + when(brokenRegionScheduler.run(any(Plugin.class), any(World.class), anyInt(), anyInt(), any())) + .thenThrow(new IllegalArgumentException("argument type mismatch")); + + AtomicBoolean taskRan = new AtomicBoolean(false); + stubRunTaskToExecuteImmediately(); + + try (MockedStatic mockedBukkit = mockStatic(Bukkit.class)) { + mockedBukkit.when(Bukkit::getRegionScheduler).thenReturn(brokenRegionScheduler); + + BukkitPlatformScheduler scheduler = + new BukkitPlatformScheduler(plugin, PlatformRuntimeCapabilities.PURPUR_REGIONIZED); + + assertDoesNotThrow(() -> scheduler.executeRegion(world, 0, 0, () -> taskRan.set(true))); + } + + assertTrue(taskRan.get(), "Task must still run via the Bukkit-scheduler fallback"); + verify(bukkitScheduler).runTask(eq(plugin), any(Runnable.class)); + } + + @Test + void executeGlobal_fallsBackAndRunsTaskWhenGlobalRegionSchedulerReflectionThrows() { + GlobalRegionScheduler brokenGlobalScheduler = mock(GlobalRegionScheduler.class); + when(brokenGlobalScheduler.run(any(Plugin.class), any())) + .thenThrow(new IllegalArgumentException("argument type mismatch")); + + AtomicBoolean taskRan = new AtomicBoolean(false); + stubRunTaskToExecuteImmediately(); + + try (MockedStatic mockedBukkit = mockStatic(Bukkit.class)) { + mockedBukkit.when(Bukkit::getGlobalRegionScheduler).thenReturn(brokenGlobalScheduler); + + BukkitPlatformScheduler scheduler = + new BukkitPlatformScheduler(plugin, PlatformRuntimeCapabilities.PURPUR_REGIONIZED); + + assertDoesNotThrow(() -> scheduler.executeGlobal(() -> taskRan.set(true))); + } + + assertTrue(taskRan.get(), "Task must still run via the Bukkit-scheduler fallback"); + verify(bukkitScheduler).runTask(eq(plugin), any(Runnable.class)); + } + + @Test + void executeGlobalDelayed_fallsBackWhenGlobalRegionSchedulerReflectionThrows() { + GlobalRegionScheduler brokenGlobalScheduler = mock(GlobalRegionScheduler.class); + when(brokenGlobalScheduler.runDelayed(any(Plugin.class), any(), anyLong())) + .thenThrow(new IllegalArgumentException("argument type mismatch")); + + BukkitTask bukkitTask = mock(BukkitTask.class); + when(bukkitScheduler.runTaskLater(any(Plugin.class), any(Runnable.class), anyLong())) + .thenReturn(bukkitTask); + + try (MockedStatic mockedBukkit = mockStatic(Bukkit.class)) { + mockedBukkit.when(Bukkit::getGlobalRegionScheduler).thenReturn(brokenGlobalScheduler); + + BukkitPlatformScheduler scheduler = + new BukkitPlatformScheduler(plugin, PlatformRuntimeCapabilities.PURPUR_REGIONIZED); + + PlatformTask task = assertDoesNotThrow(() -> scheduler.executeGlobalDelayed(() -> {}, 20L)); + assertNotNull(task); + } + + verify(bukkitScheduler).runTaskLater(eq(plugin), any(Runnable.class), eq(20L)); + } + + @Test + void executeRegionDelayed_fallsBackWhenRegionSchedulerReflectionThrows() { + World world = mock(World.class); + RegionScheduler brokenRegionScheduler = mock(RegionScheduler.class); + when(brokenRegionScheduler.runDelayed(any(Plugin.class), any(World.class), anyInt(), anyInt(), any(), anyLong())) + .thenThrow(new IllegalArgumentException("argument type mismatch")); + + try (MockedStatic mockedBukkit = mockStatic(Bukkit.class)) { + mockedBukkit.when(Bukkit::getRegionScheduler).thenReturn(brokenRegionScheduler); + + BukkitPlatformScheduler scheduler = + new BukkitPlatformScheduler(plugin, PlatformRuntimeCapabilities.PURPUR_REGIONIZED); + + assertDoesNotThrow(() -> scheduler.executeRegionDelayed(world, 0, 0, () -> {}, 20L)); + } + + verify(bukkitScheduler).runTaskLater(eq(plugin), any(Runnable.class), eq(20L)); + } + + private void stubRunTaskToExecuteImmediately() { + doAnswer(invocation -> { + Runnable task = invocation.getArgument(1); + task.run(); + return mock(BukkitTask.class); + }).when(bukkitScheduler).runTask(any(Plugin.class), any(Runnable.class)); + } +} diff --git a/ezrtp-common/pom.xml b/ezrtp-common/pom.xml index 45c8778..236192d 100644 --- a/ezrtp-common/pom.xml +++ b/ezrtp-common/pom.xml @@ -5,13 +5,13 @@ com.skyblockexp ezrtp-parent - 3.4.2 + 3.4.3 ../pom.xml com.skyblockexp ezrtp-common - 3.4.2 + 3.4.3 jar EzRTP Common Shared utilities for EzRTP (platform-independent) diff --git a/ezrtp-common/src/main/java/com/skyblockexp/ezrtp/platform/PlatformRuntimeCapabilities.java b/ezrtp-common/src/main/java/com/skyblockexp/ezrtp/platform/PlatformRuntimeCapabilities.java index cd2840b..e66bf70 100644 --- a/ezrtp-common/src/main/java/com/skyblockexp/ezrtp/platform/PlatformRuntimeCapabilities.java +++ b/ezrtp-common/src/main/java/com/skyblockexp/ezrtp/platform/PlatformRuntimeCapabilities.java @@ -13,6 +13,15 @@ public record PlatformRuntimeCapabilities(boolean paperApi, boolean purpurApi, b public static final PlatformRuntimeCapabilities PAPER_FOLIA = new PlatformRuntimeCapabilities(true, false, true); public static final PlatformRuntimeCapabilities PURPUR = new PlatformRuntimeCapabilities(true, true, false); + /** + * A non-Folia Purpur (or Paper) build whose server classpath nonetheless carries the + * Folia regionized-runtime marker class, so {@code regionizedRuntime()} is detected as + * {@code true} even though the server is not actually running region-threaded. This is + * the exact combination reported against Purpur builds where the region-scheduler + * reflection path is exercised but not necessarily functional. + */ + public static final PlatformRuntimeCapabilities PURPUR_REGIONIZED = new PlatformRuntimeCapabilities(true, true, true); + public boolean isStrictPaper() { return paperApi && !purpurApi; } diff --git a/ezrtp-common/src/main/java/com/skyblockexp/ezrtp/util/compat/ParticleCompat.java b/ezrtp-common/src/main/java/com/skyblockexp/ezrtp/util/compat/ParticleCompat.java index 3901d0c..9347cff 100644 --- a/ezrtp-common/src/main/java/com/skyblockexp/ezrtp/util/compat/ParticleCompat.java +++ b/ezrtp-common/src/main/java/com/skyblockexp/ezrtp/util/compat/ParticleCompat.java @@ -23,7 +23,7 @@ public static void spawnParticle(World world, Particle particle, Location loc, i double.class, double.class, double.class, double.class, Object.class, boolean.class); m.invoke(world, particle, loc, count, offsetX, offsetY, offsetZ, extra, data, force); return; - } catch (ReflectiveOperationException | LinkageError ignored) {} + } catch (ReflectiveOperationException | LinkageError | RuntimeException ignored) {} try { // Try alternate signature: spawnParticle(Particle, Location, int, double, double, double, double) @@ -31,7 +31,7 @@ public static void spawnParticle(World world, Particle particle, Location loc, i double.class, double.class, double.class, double.class); m2.invoke(world, particle, loc, count, offsetX, offsetY, offsetZ, extra); return; - } catch (ReflectiveOperationException | LinkageError ignored) {} + } catch (ReflectiveOperationException | LinkageError | RuntimeException ignored) {} try { // Legacy: spawnParticle(Location, Particle, int, double, double, double) @@ -39,7 +39,7 @@ public static void spawnParticle(World world, Particle particle, Location loc, i double.class, double.class, double.class); m3.invoke(world, loc, particle, count, offsetX, offsetY, offsetZ); return; - } catch (ReflectiveOperationException | LinkageError ignored) {} + } catch (ReflectiveOperationException | LinkageError | RuntimeException ignored) {} // Give up silently if no compatible method is found. } diff --git a/ezrtp-paper/pom.xml b/ezrtp-paper/pom.xml index fdf43c3..619bf5b 100644 --- a/ezrtp-paper/pom.xml +++ b/ezrtp-paper/pom.xml @@ -5,13 +5,13 @@ com.skyblockexp ezrtp-parent - 3.4.2 + 3.4.3 ../pom.xml com.skyblockexp ezrtp-paper - 3.4.2 + 3.4.3 jar EzRTP (Paper) Paper-optimized module for EzRTP @@ -32,7 +32,7 @@ com.skyblockexp ezrtp-common - 3.4.2 + 3.4.3 provided diff --git a/ezrtp-paper/src/main/java/com/skyblockexp/ezrtp/platform/PaperPlatformScheduler.java b/ezrtp-paper/src/main/java/com/skyblockexp/ezrtp/platform/PaperPlatformScheduler.java index 7ee4642..4e2fa6f 100644 --- a/ezrtp-paper/src/main/java/com/skyblockexp/ezrtp/platform/PaperPlatformScheduler.java +++ b/ezrtp-paper/src/main/java/com/skyblockexp/ezrtp/platform/PaperPlatformScheduler.java @@ -8,6 +8,7 @@ import java.lang.reflect.Method; import java.util.concurrent.CompletableFuture; +import java.util.logging.Level; public final class PaperPlatformScheduler implements PlatformScheduler { @@ -55,7 +56,8 @@ private boolean invokeFoliaAsync(Runnable task) { Method runNow = asyncScheduler.getClass().getMethod("runNow", Plugin.class, java.util.function.Consumer.class); runNow.invoke(asyncScheduler, plugin, (java.util.function.Consumer) scheduledTask -> task.run()); return true; - } catch (ReflectiveOperationException ignored) { + } catch (ReflectiveOperationException | RuntimeException ex) { + logReflectionFallback("getAsyncScheduler#runNow", ex); return false; } } @@ -69,7 +71,8 @@ private boolean invokeRegionTask(World world, int chunkX, int chunkZ, Runnable t run.invoke(regionScheduler, plugin, world, chunkX, chunkZ, (java.util.function.Consumer) scheduledTask -> task.run()); return true; - } catch (ReflectiveOperationException ignored) { + } catch (ReflectiveOperationException | RuntimeException ex) { + logReflectionFallback("getRegionScheduler#run", ex); return false; } } @@ -120,7 +123,8 @@ private boolean invokeGlobalRun(Runnable task) { run.invoke(globalScheduler, plugin, (java.util.function.Consumer) ignored -> task.run()); return true; - } catch (ReflectiveOperationException ignored) { + } catch (ReflectiveOperationException | RuntimeException ex) { + logReflectionFallback("getGlobalRegionScheduler#run", ex); return false; } } @@ -136,10 +140,11 @@ private PlatformTask invokeGlobalRunDelayed(Runnable task, long delayTicks) { return () -> { try { cancel.invoke(scheduledTask); - } catch (ReflectiveOperationException ignored) { + } catch (ReflectiveOperationException | RuntimeException ignored) { } }; - } catch (ReflectiveOperationException ignored) { + } catch (ReflectiveOperationException | RuntimeException ex) { + logReflectionFallback("getGlobalRegionScheduler#runDelayed", ex); return null; } } @@ -154,7 +159,8 @@ private boolean invokeRegionDelayed( runDelayed.invoke(regionScheduler, plugin, world, chunkX, chunkZ, (java.util.function.Consumer) ignored -> task.run(), delayTicks); return true; - } catch (ReflectiveOperationException ignored) { + } catch (ReflectiveOperationException | RuntimeException ex) { + logReflectionFallback("getRegionScheduler#runDelayed", ex); return false; } } @@ -177,11 +183,27 @@ private PlatformTask scheduleGlobalRepeating(Runnable task, long delayTicks, lon return () -> { try { cancel.invoke(scheduledTask); - } catch (ReflectiveOperationException ignored) { + } catch (ReflectiveOperationException | RuntimeException ignored) { } }; - } catch (ReflectiveOperationException ignored) { + } catch (ReflectiveOperationException | RuntimeException ex) { + logReflectionFallback("getGlobalRegionScheduler#runAtFixedRate", ex); return null; } } + + /** + * Logs a fine-level diagnostic when a Folia/region-scheduler reflection call fails. + * + *

Reflective calls into region-scheduler APIs can fail with more than just + * {@link ReflectiveOperationException} — a signature change on a newer or forked + * server (e.g. Purpur) can surface as {@link IllegalArgumentException} ("argument + * type mismatch") from {@link Method#invoke}. These are caught broadly so a single + * unexpected API drift falls back to the standard scheduler instead of breaking + * the caller (notably the async RTP location search). + */ + private void logReflectionFallback(String operation, Throwable ex) { + plugin.getLogger().log(Level.FINE, "EzRTP: region-scheduler reflection call " + operation + + " failed, falling back to standard scheduler", ex); + } } diff --git a/ezrtp-paper/src/test/java/com/skyblockexp/ezrtp/platform/PaperPlatformSchedulerReflectionFallbackTest.java b/ezrtp-paper/src/test/java/com/skyblockexp/ezrtp/platform/PaperPlatformSchedulerReflectionFallbackTest.java new file mode 100644 index 0000000..d82710e --- /dev/null +++ b/ezrtp-paper/src/test/java/com/skyblockexp/ezrtp/platform/PaperPlatformSchedulerReflectionFallbackTest.java @@ -0,0 +1,247 @@ +package com.skyblockexp.ezrtp.platform; + +import io.papermc.paper.threadedregions.scheduler.GlobalRegionScheduler; +import io.papermc.paper.threadedregions.scheduler.RegionScheduler; +import org.bukkit.Bukkit; +import org.bukkit.Server; +import org.bukkit.World; +import org.bukkit.entity.Player; +import org.bukkit.plugin.Plugin; +import org.bukkit.scheduler.BukkitScheduler; +import org.bukkit.scheduler.BukkitTask; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.MockedStatic; +import org.mockito.junit.jupiter.MockitoExtension; +import org.mockito.junit.jupiter.MockitoSettings; +import org.mockito.quality.Strictness; + +import java.util.concurrent.atomic.AtomicBoolean; + +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyInt; +import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.doAnswer; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.mockStatic; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +/** + * Regression coverage for a real-world "/rtp" failure reported on Purpur. + * + *

{@link PlatformRuntimeCapabilitiesDetector} marks a server as {@code regionizedRuntime} + * purely by the presence of a Folia marker class on the classpath. As Paper continues merging + * Folia's regionized-threading model upstream, that marker class can be present on a Purpur (or + * Paper) server jar even when the server is not actually running region-threaded. When that + * happens, {@link PaperPlatformScheduler} still routes {@code executeRegion}/{@code executeGlobal} + * /etc. through reflective calls into {@code Bukkit.getRegionScheduler()} / + * {@code Bukkit.getGlobalRegionScheduler()}. + * + *

Previously, if those reflective {@code Method.invoke} calls failed with anything other than + * {@link ReflectiveOperationException} — for example {@link IllegalArgumentException} ("argument + * type mismatch") from an API signature drift — the exception propagated uncaught out of the + * scheduler and into the async RTP location-search {@link java.util.concurrent.CompletableFuture} + * chain, surfacing to players as "/rtp" always failing (logged as + * {@code CompletionException - java.lang.IllegalArgumentException: argument type mismatch}). + * + *

These tests simulate that exact failure (the reflective method lookup succeeds, but invoking + * it throws) and confirm the scheduler falls back to the standard Bukkit scheduler — without + * throwing, and without silently dropping the submitted task. + */ +@ExtendWith(MockitoExtension.class) +@MockitoSettings(strictness = Strictness.LENIENT) +class PaperPlatformSchedulerReflectionFallbackTest { + + @Mock + private Plugin plugin; + + @Mock + private Server server; + + @Mock + private BukkitScheduler bukkitScheduler; + + @BeforeEach + void setUp() { + when(plugin.getServer()).thenReturn(server); + when(server.getScheduler()).thenReturn(bukkitScheduler); + when(plugin.getLogger()).thenReturn(java.util.logging.Logger.getLogger("test")); + } + + @Test + void executeRegion_fallsBackAndRunsTaskWhenRegionSchedulerReflectionThrows() { + World world = mock(World.class); + RegionScheduler brokenRegionScheduler = mock(RegionScheduler.class); + when(brokenRegionScheduler.run(any(Plugin.class), any(World.class), anyInt(), anyInt(), any())) + .thenThrow(new IllegalArgumentException("argument type mismatch")); + + AtomicBoolean taskRan = new AtomicBoolean(false); + stubRunTaskToExecuteImmediately(); + + try (MockedStatic mockedBukkit = mockStatic(Bukkit.class)) { + mockedBukkit.when(Bukkit::getRegionScheduler).thenReturn(brokenRegionScheduler); + + PaperPlatformScheduler scheduler = + new PaperPlatformScheduler(plugin, PlatformRuntimeCapabilities.PURPUR_REGIONIZED); + + assertDoesNotThrow(() -> scheduler.executeRegion(world, 0, 0, () -> taskRan.set(true))); + } + + assertTrue(taskRan.get(), "Task must still run via the Bukkit-scheduler fallback"); + verify(bukkitScheduler).runTask(eq(plugin), any(Runnable.class)); + } + + @Test + void executeGlobal_fallsBackAndRunsTaskWhenGlobalRegionSchedulerReflectionThrows() { + GlobalRegionScheduler brokenGlobalScheduler = mock(GlobalRegionScheduler.class); + when(brokenGlobalScheduler.run(any(Plugin.class), any())) + .thenThrow(new IllegalArgumentException("argument type mismatch")); + + AtomicBoolean taskRan = new AtomicBoolean(false); + stubRunTaskToExecuteImmediately(); + + try (MockedStatic mockedBukkit = mockStatic(Bukkit.class)) { + mockedBukkit.when(Bukkit::getGlobalRegionScheduler).thenReturn(brokenGlobalScheduler); + + PaperPlatformScheduler scheduler = + new PaperPlatformScheduler(plugin, PlatformRuntimeCapabilities.PURPUR_REGIONIZED); + + assertDoesNotThrow(() -> scheduler.executeGlobal(() -> taskRan.set(true))); + } + + assertTrue(taskRan.get(), "Task must still run via the Bukkit-scheduler fallback"); + verify(bukkitScheduler).runTask(eq(plugin), any(Runnable.class)); + } + + @Test + void executeGlobalDelayed_fallsBackWhenGlobalRegionSchedulerReflectionThrows() { + GlobalRegionScheduler brokenGlobalScheduler = mock(GlobalRegionScheduler.class); + when(brokenGlobalScheduler.runDelayed(any(Plugin.class), any(), anyLong())) + .thenThrow(new IllegalArgumentException("argument type mismatch")); + + BukkitTask bukkitTask = mock(BukkitTask.class); + when(bukkitScheduler.runTaskLater(any(Plugin.class), any(Runnable.class), anyLong())) + .thenReturn(bukkitTask); + + try (MockedStatic mockedBukkit = mockStatic(Bukkit.class)) { + mockedBukkit.when(Bukkit::getGlobalRegionScheduler).thenReturn(brokenGlobalScheduler); + + PaperPlatformScheduler scheduler = + new PaperPlatformScheduler(plugin, PlatformRuntimeCapabilities.PURPUR_REGIONIZED); + + PlatformTask task = assertDoesNotThrow(() -> scheduler.executeGlobalDelayed(() -> {}, 20L)); + assertNotNull(task); + } + + verify(bukkitScheduler).runTaskLater(eq(plugin), any(Runnable.class), eq(20L)); + } + + @Test + void executeRegionDelayed_fallsBackWhenRegionSchedulerReflectionThrows() { + World world = mock(World.class); + RegionScheduler brokenRegionScheduler = mock(RegionScheduler.class); + when(brokenRegionScheduler.runDelayed(any(Plugin.class), any(World.class), anyInt(), anyInt(), any(), anyLong())) + .thenThrow(new IllegalArgumentException("argument type mismatch")); + + try (MockedStatic mockedBukkit = mockStatic(Bukkit.class)) { + mockedBukkit.when(Bukkit::getRegionScheduler).thenReturn(brokenRegionScheduler); + + PaperPlatformScheduler scheduler = + new PaperPlatformScheduler(plugin, PlatformRuntimeCapabilities.PURPUR_REGIONIZED); + + assertDoesNotThrow(() -> scheduler.executeRegionDelayed(world, 0, 0, () -> {}, 20L)); + } + + verify(bukkitScheduler).runTaskLater(eq(plugin), any(Runnable.class), eq(20L)); + } + + @Test + void scheduleRepeating_fallsBackWhenGlobalRegionSchedulerReflectionThrows() { + GlobalRegionScheduler brokenGlobalScheduler = mock(GlobalRegionScheduler.class); + when(brokenGlobalScheduler.runAtFixedRate(any(Plugin.class), any(), anyLong(), anyLong())) + .thenThrow(new IllegalArgumentException("argument type mismatch")); + + BukkitTask bukkitTask = mock(BukkitTask.class); + when(bukkitScheduler.runTaskTimer(any(Plugin.class), any(Runnable.class), anyLong(), anyLong())) + .thenReturn(bukkitTask); + + try (MockedStatic mockedBukkit = mockStatic(Bukkit.class)) { + mockedBukkit.when(Bukkit::getGlobalRegionScheduler).thenReturn(brokenGlobalScheduler); + + PaperPlatformScheduler scheduler = + new PaperPlatformScheduler(plugin, PlatformRuntimeCapabilities.PURPUR_REGIONIZED); + + PlatformTask task = assertDoesNotThrow(() -> scheduler.scheduleRepeating(() -> {}, 1L, 20L)); + assertNotNull(task); + } + + verify(bukkitScheduler).runTaskTimer(eq(plugin), any(Runnable.class), eq(1L), eq(20L)); + } + + /** + * Feature-level check: a full teleport-style hop (region-thread candidate resolution + * followed by a global-thread completion callback) must still complete end-to-end when + * the region-scheduler reflection is broken, mirroring the "/rtp" search-and-teleport flow. + */ + @Test + void regionThenGlobalSequence_completesEndToEndWhenReflectionIsBroken() { + World world = mock(World.class); + RegionScheduler brokenRegionScheduler = mock(RegionScheduler.class); + when(brokenRegionScheduler.run(any(Plugin.class), any(World.class), anyInt(), anyInt(), any())) + .thenThrow(new IllegalArgumentException("argument type mismatch")); + GlobalRegionScheduler brokenGlobalScheduler = mock(GlobalRegionScheduler.class); + when(brokenGlobalScheduler.run(any(Plugin.class), any())) + .thenThrow(new IllegalArgumentException("argument type mismatch")); + + stubRunTaskToExecuteImmediately(); + + AtomicBoolean candidateResolved = new AtomicBoolean(false); + AtomicBoolean teleportCompleted = new AtomicBoolean(false); + + try (MockedStatic mockedBukkit = mockStatic(Bukkit.class)) { + mockedBukkit.when(Bukkit::getRegionScheduler).thenReturn(brokenRegionScheduler); + mockedBukkit.when(Bukkit::getGlobalRegionScheduler).thenReturn(brokenGlobalScheduler); + + PaperPlatformScheduler scheduler = + new PaperPlatformScheduler(plugin, PlatformRuntimeCapabilities.PURPUR_REGIONIZED); + + assertDoesNotThrow(() -> { + scheduler.executeRegion(world, 0, 0, () -> { + candidateResolved.set(true); + scheduler.executeGlobal(() -> teleportCompleted.set(true)); + }); + }); + } + + assertTrue(candidateResolved.get(), "Candidate resolution must still run on the region fallback"); + assertTrue(teleportCompleted.get(), "Teleport completion must still run on the global fallback"); + } + + @Test + void teleportAsync_doesNotDependOnRegionSchedulerReflection() { + Player player = mock(Player.class); + org.bukkit.Location destination = mock(org.bukkit.Location.class); + when(player.teleportAsync(destination)) + .thenReturn(java.util.concurrent.CompletableFuture.completedFuture(true)); + + PaperPlatformScheduler scheduler = + new PaperPlatformScheduler(plugin, PlatformRuntimeCapabilities.PURPUR_REGIONIZED); + + assertDoesNotThrow(() -> scheduler.teleportAsync(player, destination).join()); + } + + private void stubRunTaskToExecuteImmediately() { + doAnswer(invocation -> { + Runnable task = invocation.getArgument(1); + task.run(); + return mock(BukkitTask.class); + }).when(bukkitScheduler).runTask(any(Plugin.class), any(Runnable.class)); + } +} diff --git a/ezrtp-purpur/pom.xml b/ezrtp-purpur/pom.xml index b8af9a2..e8069a4 100644 --- a/ezrtp-purpur/pom.xml +++ b/ezrtp-purpur/pom.xml @@ -5,7 +5,7 @@ com.skyblockexp ezrtp-parent - 3.4.2 + 3.4.3 ../pom.xml @@ -18,12 +18,12 @@ com.skyblockexp ezrtp-paper - 3.4.2 + 3.4.3 com.skyblockexp ezrtp-common - 3.4.2 + 3.4.3 provided diff --git a/ezrtp-spigot/pom.xml b/ezrtp-spigot/pom.xml index 7afd140..d45c95d 100644 --- a/ezrtp-spigot/pom.xml +++ b/ezrtp-spigot/pom.xml @@ -5,13 +5,13 @@ com.skyblockexp ezrtp-parent - 3.4.2 + 3.4.3 ../pom.xml com.skyblockexp ezrtp-spigot - 3.4.2 + 3.4.3 jar EzRTP (Spigot) Spigot-optimized module for EzRTP @@ -26,7 +26,7 @@ com.skyblockexp ezrtp-common - 3.4.2 + 3.4.3 diff --git a/pom.xml b/pom.xml index 1b2d690..8ab3878 100644 --- a/pom.xml +++ b/pom.xml @@ -4,7 +4,7 @@ com.skyblockexp ezrtp-parent - 3.4.2 + 3.4.3 pom EzRTP Parent Aggregator POM for EzRTP multi-module build