From 08f1f71430ad77cb06953f0e00ad83c64a00cf68 Mon Sep 17 00:00:00 2001 From: Lewis Gibson <12851394+lewisgibson@users.noreply.github.com> Date: Thu, 1 Oct 2026 21:45:00 +0100 Subject: [PATCH 01/13] Use PaperLib's asynchronous teleports and chunk loads on 26.x PaperLib 1.0.6 reads the server version with a regex that only accepts a single digit major version, so on 26.x it sees version 0 and falls back to its synchronous handlers. Every PaperLib.teleportAsync, getChunkAtAsync and getBedSpawnLocationAsync call in EssentialsX then ran a blocking teleport or chunk load instead of the asynchronous Paper API. When PaperLib cannot read the version of a server that is newer than 1.13, install an environment that selects the handlers PaperLib would have chosen for a modern Paper. Co-authored-by: Raw2d <255907405+Raw2d@users.noreply.github.com> --- .../com/earth2me/essentials/Essentials.java | 5 ++ .../utils/ModernPaperEnvironment.java | 23 +++++++ .../utils/ModernPaperEnvironmentTest.java | 62 +++++++++++++++++++ 3 files changed, 90 insertions(+) create mode 100644 Essentials/src/main/java/com/earth2me/essentials/utils/ModernPaperEnvironment.java create mode 100644 Essentials/src/test/java/com/earth2me/essentials/utils/ModernPaperEnvironmentTest.java diff --git a/Essentials/src/main/java/com/earth2me/essentials/Essentials.java b/Essentials/src/main/java/com/earth2me/essentials/Essentials.java index 1629f8e432..d6ad1f93e5 100644 --- a/Essentials/src/main/java/com/earth2me/essentials/Essentials.java +++ b/Essentials/src/main/java/com/earth2me/essentials/Essentials.java @@ -46,6 +46,7 @@ import com.earth2me.essentials.updatecheck.UpdateChecker; import com.earth2me.essentials.userstorage.ModernUserMap; import com.earth2me.essentials.utils.FormatUtil; +import com.earth2me.essentials.utils.ModernPaperEnvironment; import com.earth2me.essentials.utils.PasteUtil; import com.earth2me.essentials.utils.VersionUtil; import io.papermc.lib.PaperLib; @@ -261,6 +262,10 @@ public void onEnable() { getLogger().severe(getAdventureFacet().miniToLegacy(tlLiteral("serverSnapshot"))); } + if (PaperLib.isPaper() && !PaperLib.isVersion(13) && VersionUtil.getServerBukkitVersion().isHigherThanOrEqualTo(VersionUtil.v1_13_0_R01)) { + PaperLib.setCustomEnvironment(new ModernPaperEnvironment()); + } + final PluginManager pm = getServer().getPluginManager(); for (final Plugin plugin : pm.getPlugins()) { if (plugin.getDescription().getName().startsWith("Essentials") && !plugin.getDescription().getVersion().equals(this.getDescription().getVersion()) && !plugin.getDescription().getName().equals("EssentialsAntiCheat")) { diff --git a/Essentials/src/main/java/com/earth2me/essentials/utils/ModernPaperEnvironment.java b/Essentials/src/main/java/com/earth2me/essentials/utils/ModernPaperEnvironment.java new file mode 100644 index 0000000000..1311f223d9 --- /dev/null +++ b/Essentials/src/main/java/com/earth2me/essentials/utils/ModernPaperEnvironment.java @@ -0,0 +1,23 @@ +package com.earth2me.essentials.utils; + +import io.papermc.lib.environments.PaperEnvironment; +import io.papermc.lib.features.asyncchunks.AsyncChunksPaper_15; +import io.papermc.lib.features.asyncteleport.AsyncTeleportPaper_13; +import io.papermc.lib.features.bedspawnlocation.BedSpawnLocationPaper; +import io.papermc.lib.features.chunkisgenerated.ChunkIsGeneratedApiExists; + +/** + * A PaperLib environment for Paper servers whose version PaperLib cannot read. + *

+ * PaperLib 1.0.6 expects a single digit major version in {@code Bukkit#getVersion()}, so it reads 26.x as version 0 and + * selects its synchronous fallbacks for chunk loading, teleporting and bed lookups. All of those exist on every Paper + * version that reports a year based version. + */ +public class ModernPaperEnvironment extends PaperEnvironment { + public ModernPaperEnvironment() { + asyncChunksHandler = new AsyncChunksPaper_15(); + asyncTeleportHandler = new AsyncTeleportPaper_13(); + isGeneratedHandler = new ChunkIsGeneratedApiExists(); + bedSpawnLocationHandler = new BedSpawnLocationPaper(); + } +} diff --git a/Essentials/src/test/java/com/earth2me/essentials/utils/ModernPaperEnvironmentTest.java b/Essentials/src/test/java/com/earth2me/essentials/utils/ModernPaperEnvironmentTest.java new file mode 100644 index 0000000000..7bb08cfb76 --- /dev/null +++ b/Essentials/src/test/java/com/earth2me/essentials/utils/ModernPaperEnvironmentTest.java @@ -0,0 +1,62 @@ +package com.earth2me.essentials.utils; + +import io.papermc.lib.environments.PaperEnvironment; +import org.bukkit.Bukkit; +import org.bukkit.Location; +import org.bukkit.World; +import org.bukkit.entity.Entity; +import org.bukkit.event.player.PlayerTeleportEvent.TeleportCause; +import org.junit.jupiter.api.Test; +import org.mockito.MockedStatic; + +import java.util.function.Consumer; +import java.util.function.Supplier; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.mockStatic; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; + +public class ModernPaperEnvironmentTest { + private static final String YEAR_VERSION = "26.3-140-main@a1b2c3d (2026-09-30T10:00:00Z) (MC: 26.3)"; + + @Test + public void testPaperLibFallsBackToSynchronousTeleportOnYearVersions() { + // Documents why ModernPaperEnvironment exists: PaperLib itself picks the synchronous handlers here. + withVersion(YEAR_VERSION, PaperEnvironment::new, env -> { + final Entity entity = mock(Entity.class); + final Location location = new Location(mock(World.class), 0, 64, 0); + env.teleport(entity, location, TeleportCause.PLUGIN); + verify(entity).teleport(location, TeleportCause.PLUGIN); + verify(entity, never()).teleportAsync(any(Location.class), any(TeleportCause.class)); + }); + } + + @Test + public void testTeleportsAsynchronouslyOnYearVersions() { + withVersion(YEAR_VERSION, ModernPaperEnvironment::new, env -> { + final Entity entity = mock(Entity.class); + final Location location = new Location(mock(World.class), 0, 64, 0); + env.teleport(entity, location, TeleportCause.PLUGIN); + verify(entity).teleportAsync(location, TeleportCause.PLUGIN); + verify(entity, never()).teleport(any(Location.class), any(TeleportCause.class)); + }); + } + + @Test + public void testLoadsChunksAsynchronouslyOnYearVersions() { + withVersion(YEAR_VERSION, ModernPaperEnvironment::new, env -> { + final World world = mock(World.class); + env.getChunkAtAsync(world, 1, 2, true); + verify(world).getChunkAtAsync(1, 2, true, false); + }); + } + + private static void withVersion(final String version, final Supplier create, final Consumer test) { + try (MockedStatic bukkit = mockStatic(Bukkit.class)) { + bukkit.when(Bukkit::getVersion).thenReturn(version); + test.accept(create.get()); + } + } +} From 0b162a4d1161dc2edd4d3370ca3792732c6d524c Mon Sep 17 00:00:00 2001 From: Lewis Gibson <12851394+lewisgibson@users.noreply.github.com> Date: Thu, 1 Oct 2026 21:45:00 +0100 Subject: [PATCH 02/13] Add a task scheduler provider that supports Folia Folia does not support the Bukkit scheduler and splits the world between regions, each of which ticks on its own thread. A task must run on the thread that owns the entity or location it touches, and state that no region owns runs on a global thread. TaskSchedulerProvider schedules global, entity, location and asynchronous tasks and answers whether the current thread owns an entity or location. BukkitTaskSchedulerProvider queues everything onto the main thread exactly as the Bukkit scheduler calls it replaces did, and is used on every server but Folia. FoliaTaskSchedulerProvider uses the region, entity, global and async schedulers, and is selected only where Folia's RegionizedServer class exists. It lives in PaperProvider so that Folia's API is not loaded anywhere else. The Bukkit scheduler methods on IEssentials are deprecated, since they cannot work on Folia. Call sites are moved over in the following commits. Co-authored-by: Raw2d <255907405+Raw2d@users.noreply.github.com> --- .../com/earth2me/essentials/Essentials.java | 24 +- .../com/earth2me/essentials/IEssentials.java | 36 +++ .../essentials/economy/EconomyLayers.java | 4 +- .../provider/TaskSchedulerProviderTest.java | 210 ++++++++++++++++++ .../ess3/provider/TaskSchedulerProvider.java | 129 +++++++++++ .../BukkitTaskSchedulerProvider.java | 79 +++++++ .../providers/FoliaTaskSchedulerProvider.java | 127 +++++++++++ 7 files changed, 603 insertions(+), 6 deletions(-) create mode 100644 Essentials/src/test/java/com/earth2me/essentials/provider/TaskSchedulerProviderTest.java create mode 100644 providers/BaseProviders/src/main/java/net/ess3/provider/TaskSchedulerProvider.java create mode 100644 providers/BaseProviders/src/main/java/net/ess3/provider/providers/BukkitTaskSchedulerProvider.java create mode 100644 providers/PaperProvider/src/main/java/net/ess3/provider/providers/FoliaTaskSchedulerProvider.java diff --git a/Essentials/src/main/java/com/earth2me/essentials/Essentials.java b/Essentials/src/main/java/com/earth2me/essentials/Essentials.java index d6ad1f93e5..000935c55c 100644 --- a/Essentials/src/main/java/com/earth2me/essentials/Essentials.java +++ b/Essentials/src/main/java/com/earth2me/essentials/Essentials.java @@ -71,14 +71,17 @@ import net.ess3.provider.PlayerLocaleProvider; import net.ess3.provider.ProviderListener; import net.ess3.provider.ServerStateProvider; +import net.ess3.provider.TaskSchedulerProvider; import net.ess3.provider.providers.BaseBannerDataProvider; import net.ess3.provider.providers.BaseInventoryViewProvider; import net.ess3.provider.providers.BlockMetaSpawnerItemProvider; import net.ess3.provider.providers.BukkitMaterialTagProvider; import net.ess3.provider.providers.BukkitSpawnerBlockProvider; +import net.ess3.provider.providers.BukkitTaskSchedulerProvider; import net.ess3.provider.providers.BukkitTileEntityProvider; import net.ess3.provider.providers.FixedHeightWorldInfoProvider; import net.ess3.provider.providers.FlatSpawnEggProvider; +import net.ess3.provider.providers.FoliaTaskSchedulerProvider; import net.ess3.provider.providers.LegacyBannerDataProvider; import net.ess3.provider.providers.LegacyBiomeNameProvider; import net.ess3.provider.providers.LegacyPatternTypeProvider; @@ -188,6 +191,7 @@ public class Essentials extends JavaPlugin implements net.ess3.api.IEssentials { private transient RandomTeleport randomTeleport; private transient UpdateChecker updateChecker; private transient AdventureFacet adventureFacet; + private transient TaskSchedulerProvider taskScheduler; static { EconomyLayers.init(); @@ -332,9 +336,6 @@ public void onEnable() { confList.add(jails); execTimer.mark("Init(Jails)"); - EconomyLayers.onEnable(this); - execTimer.mark("Init(EconomyLayers)"); - // Spawner item provider only uses one, but it's here for legacy... providerFactory.registerProvider(BlockMetaSpawnerItemProvider.class); @@ -410,9 +411,16 @@ public void onEnable() { // Tick Count Provider providerFactory.registerProvider(PaperTickCountProvider.class); + // Task Scheduler Provider + providerFactory.registerProvider(BukkitTaskSchedulerProvider.class, FoliaTaskSchedulerProvider.class); + if (!TESTING) { providerFactory.finalizeRegistration(); } + taskScheduler = TESTING ? new BukkitTaskSchedulerProvider(this) : provider(TaskSchedulerProvider.class); + + EconomyLayers.onEnable(this); + execTimer.mark("Init(EconomyLayers)"); // Event Providers if (PaperLib.isPaper()) { @@ -594,7 +602,9 @@ public void onDisable() { EssentialsConfiguration.shutdownExecutor(); PasteUtil.shutdownExecutor(); - getServer().getScheduler().cancelTasks(this); + if (taskScheduler != null) { + taskScheduler.cancelAll(); + } HandlerList.unregisterAll(this); } @@ -916,10 +926,16 @@ public void showError(final CommandSource sender, final Throwable exception, fin } @Override + @Deprecated public BukkitScheduler getScheduler() { return this.getServer().getScheduler(); } + @Override + public TaskSchedulerProvider getTaskScheduler() { + return taskScheduler; + } + @Override public List getJailedPlayers() { return getUsers().getAllUserUUIDs().stream().map(this::getUser).filter(Objects::nonNull).filter(User::isJailed).map(User::getBase).filter(Objects::nonNull).collect(Collectors.toList()); diff --git a/Essentials/src/main/java/com/earth2me/essentials/IEssentials.java b/Essentials/src/main/java/com/earth2me/essentials/IEssentials.java index 48a2aa2b13..1a53d89c24 100644 --- a/Essentials/src/main/java/com/earth2me/essentials/IEssentials.java +++ b/Essentials/src/main/java/com/earth2me/essentials/IEssentials.java @@ -10,6 +10,7 @@ import com.earth2me.essentials.updatecheck.UpdateChecker; import com.earth2me.essentials.userstorage.IUserMap; import net.ess3.provider.Provider; +import net.ess3.provider.TaskSchedulerProvider; import net.essentialsx.api.v2.services.BalanceTop; import net.essentialsx.api.v2.services.mail.MailService; import org.bukkit.Server; @@ -80,8 +81,19 @@ public interface IEssentials extends Plugin { ISettings getSettings(); + /** + * Gets the Bukkit scheduler. + * + * @deprecated The Bukkit scheduler is not supported by Folia, use {@link #getTaskScheduler()}. + */ + @Deprecated BukkitScheduler getScheduler(); + /** + * Gets the scheduler which runs tasks on the thread that owns what they touch, on Folia as well as on Bukkit. + */ + TaskSchedulerProvider getTaskScheduler(); + List getJailedPlayers(); IJails getJails(); @@ -98,16 +110,40 @@ public interface IEssentials extends Plugin { UpdateChecker getUpdateChecker(); + /** + * @deprecated Not supported by Folia, use {@link TaskSchedulerProvider#runAsync(Runnable, long)}. + */ + @Deprecated BukkitTask runTaskAsynchronously(Runnable run); + /** + * @deprecated Not supported by Folia, use {@link TaskSchedulerProvider#runAsync(Runnable, long)}. + */ + @Deprecated BukkitTask runTaskLaterAsynchronously(Runnable run, long delay); + /** + * @deprecated Not supported by Folia, use {@link TaskSchedulerProvider#runAsyncTimer(Runnable, long, long)}. + */ + @Deprecated BukkitTask runTaskTimerAsynchronously(Runnable run, long delay, long period); + /** + * @deprecated Not supported by Folia, use {@link TaskSchedulerProvider#runGlobal(Runnable, long)} or one of the entity and location methods. + */ + @Deprecated int scheduleSyncDelayedTask(Runnable run); + /** + * @deprecated Not supported by Folia, use {@link TaskSchedulerProvider#runGlobal(Runnable, long)} or one of the entity and location methods. + */ + @Deprecated int scheduleSyncDelayedTask(Runnable run, long delay); + /** + * @deprecated Not supported by Folia, use {@link TaskSchedulerProvider#runGlobalTimer(Runnable, long, long)} or one of the entity methods. + */ + @Deprecated int scheduleSyncRepeatingTask(Runnable run, long delay, long period); PermissionsHandler getPermissionsHandler(); diff --git a/Essentials/src/main/java/com/earth2me/essentials/economy/EconomyLayers.java b/Essentials/src/main/java/com/earth2me/essentials/economy/EconomyLayers.java index a4184e5588..510cc63f14 100644 --- a/Essentials/src/main/java/com/earth2me/essentials/economy/EconomyLayers.java +++ b/Essentials/src/main/java/com/earth2me/essentials/economy/EconomyLayers.java @@ -32,7 +32,7 @@ public static void init() { } public static void onEnable(final Essentials ess) { - ess.scheduleSyncDelayedTask(() -> { + ess.getTaskScheduler().runGlobal(() -> { serverStarted = true; for (final Plugin plugin : Bukkit.getPluginManager().getPlugins()) { if (!plugin.isEnabled()) { @@ -45,7 +45,7 @@ public static void onEnable(final Essentials ess) { } onServerLoad(); - }); + }, 0); } public static boolean isServerStarted() { diff --git a/Essentials/src/test/java/com/earth2me/essentials/provider/TaskSchedulerProviderTest.java b/Essentials/src/test/java/com/earth2me/essentials/provider/TaskSchedulerProviderTest.java new file mode 100644 index 0000000000..187600d3fa --- /dev/null +++ b/Essentials/src/test/java/com/earth2me/essentials/provider/TaskSchedulerProviderTest.java @@ -0,0 +1,210 @@ +package com.earth2me.essentials.provider; + +import io.papermc.paper.threadedregions.scheduler.AsyncScheduler; +import io.papermc.paper.threadedregions.scheduler.EntityScheduler; +import io.papermc.paper.threadedregions.scheduler.GlobalRegionScheduler; +import io.papermc.paper.threadedregions.scheduler.RegionScheduler; +import io.papermc.paper.threadedregions.scheduler.ScheduledTask; +import net.ess3.provider.TaskSchedulerProvider; +import net.ess3.provider.providers.BukkitTaskSchedulerProvider; +import net.ess3.provider.providers.FoliaTaskSchedulerProvider; +import org.bukkit.Location; +import org.bukkit.Server; +import org.bukkit.entity.Entity; +import org.bukkit.plugin.Plugin; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockbukkit.mockbukkit.MockBukkit; +import org.mockbukkit.mockbukkit.ServerMock; +import org.mockbukkit.mockbukkit.entity.PlayerMock; +import org.mockbukkit.mockbukkit.plugin.PluginMock; + +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicInteger; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.ArgumentMatchers.isNull; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +public class TaskSchedulerProviderTest { + private ServerMock server; + + @BeforeEach + public void setUp() { + server = MockBukkit.mock(); + } + + @AfterEach + public void tearDown() { + MockBukkit.unmock(); + } + + @Test + public void testBukkitProviderRunsTasksOnTheMainThread() { + final PluginMock plugin = MockBukkit.createMockPlugin(); + final PlayerMock player = server.addPlayer(); + final TaskSchedulerProvider scheduler = new BukkitTaskSchedulerProvider(plugin); + final AtomicInteger runs = new AtomicInteger(); + + scheduler.runGlobal(runs::incrementAndGet, 0); + scheduler.runEntity(player, runs::incrementAndGet, 0); + scheduler.runLocation(player.getLocation(), runs::incrementAndGet, 0); + assertEquals(0, runs.get()); + server.getScheduler().performOneTick(); + assertEquals(3, runs.get()); + + scheduler.runGlobal(runs::incrementAndGet, 2); + server.getScheduler().performOneTick(); + assertEquals(3, runs.get()); + server.getScheduler().performOneTick(); + assertEquals(4, runs.get()); + } + + @Test + public void testBukkitProviderCancelsTasks() { + final PluginMock plugin = MockBukkit.createMockPlugin(); + final TaskSchedulerProvider scheduler = new BukkitTaskSchedulerProvider(plugin); + final AtomicInteger runs = new AtomicInteger(); + + scheduler.runGlobal(runs::incrementAndGet, 1).cancel(); + final TaskSchedulerProvider.Task timer = scheduler.runGlobalTimer(runs::incrementAndGet, 1, 1); + server.getScheduler().performTicks(3); + assertEquals(3, runs.get()); + timer.cancel(); + server.getScheduler().performTicks(3); + assertEquals(3, runs.get()); + + scheduler.runGlobalTimer(runs::incrementAndGet, 1, 1); + scheduler.cancelAll(); + server.getScheduler().performTicks(3); + assertEquals(3, runs.get()); + } + + @Test + public void testExecuteRunsInlineOnTheMainThread() { + final PluginMock plugin = MockBukkit.createMockPlugin(); + final PlayerMock player = server.addPlayer(); + final TaskSchedulerProvider scheduler = new BukkitTaskSchedulerProvider(plugin); + final AtomicInteger runs = new AtomicInteger(); + + scheduler.executeGlobal(runs::incrementAndGet); + scheduler.executeEntity(player, runs::incrementAndGet); + scheduler.executeLocation(player.getLocation(), runs::incrementAndGet); + assertEquals(3, runs.get()); + } + + @Test + public void testFoliaProviderUsesTheSchedulerOfTheThingItTouches() { + final Fixture f = new Fixture(); + final Runnable task = () -> { + }; + final Location location = new Location(null, 0, 0, 0); + + f.provider.runGlobal(task, 0); + verify(f.global).run(eq(f.plugin), any()); + f.provider.runGlobal(task, 5); + verify(f.global).runDelayed(eq(f.plugin), any(), eq(5L)); + f.provider.runGlobalTimer(task, 0, 20); + verify(f.global).runAtFixedRate(eq(f.plugin), any(), eq(1L), eq(20L)); + + f.provider.runEntity(f.entity, task, 0); + verify(f.entityScheduler).run(eq(f.plugin), any(), isNull()); + f.provider.runEntity(f.entity, task, 5); + verify(f.entityScheduler).runDelayed(eq(f.plugin), any(), isNull(), eq(5L)); + f.provider.runEntityTimer(f.entity, task, null, 0, 20); + verify(f.entityScheduler).runAtFixedRate(eq(f.plugin), any(), isNull(), eq(1L), eq(20L)); + + f.provider.runLocation(location, task, 0); + verify(f.region).run(eq(f.plugin), eq(location), any()); + f.provider.runLocation(location, task, 5); + verify(f.region).runDelayed(eq(f.plugin), eq(location), any(), eq(5L)); + + f.provider.runAsync(task, 0); + verify(f.async).runNow(eq(f.plugin), any()); + f.provider.runAsync(task, 5); + verify(f.async).runDelayed(eq(f.plugin), any(), eq(250L), eq(TimeUnit.MILLISECONDS)); + f.provider.runAsyncTimer(task, 20, 40); + verify(f.async).runAtFixedRate(eq(f.plugin), any(), eq(1000L), eq(2000L), eq(TimeUnit.MILLISECONDS)); + } + + @Test + public void testFoliaProviderRunsExecuteInlineOnlyForTheOwningThread() { + final Fixture f = new Fixture(); + final AtomicInteger runs = new AtomicInteger(); + + when(f.server.isOwnedByCurrentRegion(f.entity)).thenReturn(true); + f.provider.executeEntity(f.entity, runs::incrementAndGet); + assertEquals(1, runs.get()); + verify(f.entityScheduler, never()).run(any(), any(), any()); + + when(f.server.isOwnedByCurrentRegion(f.entity)).thenReturn(false); + f.provider.executeEntity(f.entity, runs::incrementAndGet); + assertEquals(1, runs.get()); + verify(f.entityScheduler).run(eq(f.plugin), any(), isNull()); + + when(f.server.isGlobalTickThread()).thenReturn(false); + f.provider.executeGlobal(runs::incrementAndGet); + assertEquals(1, runs.get()); + verify(f.global).run(eq(f.plugin), any()); + } + + @Test + public void testFoliaProviderReportsAnEntityRemovedBeforeSchedulingAsRetired() { + final Fixture f = new Fixture(); + final AtomicInteger retired = new AtomicInteger(); + + when(f.entityScheduler.run(any(), any(), any())).thenReturn(null); + f.provider.runEntity(f.entity, () -> { + }, retired::incrementAndGet, 0).cancel(); + assertEquals(1, retired.get()); + } + + @Test + public void testFoliaProviderCancelsGlobalAndAsyncTasksOnly() { + final Fixture f = new Fixture(); + + f.provider.cancelAll(); + verify(f.global).cancelTasks(f.plugin); + verify(f.async).cancelTasks(f.plugin); + verify(f.region, never()).run(any(Plugin.class), any(Location.class), any()); + } + + private static final class Fixture { + private final Plugin plugin = mock(Plugin.class); + private final Server server = mock(Server.class); + private final GlobalRegionScheduler global = mock(GlobalRegionScheduler.class); + private final RegionScheduler region = mock(RegionScheduler.class); + private final AsyncScheduler async = mock(AsyncScheduler.class); + private final EntityScheduler entityScheduler = mock(EntityScheduler.class); + private final Entity entity = mock(Entity.class); + private final FoliaTaskSchedulerProvider provider; + + private Fixture() { + when(plugin.getServer()).thenReturn(server); + when(server.getGlobalRegionScheduler()).thenReturn(global); + when(server.getRegionScheduler()).thenReturn(region); + when(server.getAsyncScheduler()).thenReturn(async); + when(entity.getScheduler()).thenReturn(entityScheduler); + final ScheduledTask scheduled = mock(ScheduledTask.class); + when(global.run(any(), any())).thenReturn(scheduled); + when(global.runDelayed(any(), any(), anyLong())).thenReturn(scheduled); + when(global.runAtFixedRate(any(), any(), anyLong(), anyLong())).thenReturn(scheduled); + when(entityScheduler.run(any(), any(), any())).thenReturn(scheduled); + when(entityScheduler.runDelayed(any(), any(), any(), anyLong())).thenReturn(scheduled); + when(entityScheduler.runAtFixedRate(any(), any(), any(), anyLong(), anyLong())).thenReturn(scheduled); + when(region.run(any(Plugin.class), any(Location.class), any())).thenReturn(scheduled); + when(region.runDelayed(any(Plugin.class), any(Location.class), any(), anyLong())).thenReturn(scheduled); + when(async.runNow(any(), any())).thenReturn(scheduled); + when(async.runDelayed(any(), any(), anyLong(), any())).thenReturn(scheduled); + when(async.runAtFixedRate(any(), any(), anyLong(), anyLong(), any())).thenReturn(scheduled); + provider = new FoliaTaskSchedulerProvider(plugin); + } + } +} diff --git a/providers/BaseProviders/src/main/java/net/ess3/provider/TaskSchedulerProvider.java b/providers/BaseProviders/src/main/java/net/ess3/provider/TaskSchedulerProvider.java new file mode 100644 index 0000000000..b7c3ea0172 --- /dev/null +++ b/providers/BaseProviders/src/main/java/net/ess3/provider/TaskSchedulerProvider.java @@ -0,0 +1,129 @@ +package net.ess3.provider; + +import org.bukkit.Location; +import org.bukkit.entity.Entity; + +/** + * Runs tasks on the thread which owns the thing the task touches. + *

+ * On Bukkit, Spigot and Paper every game task runs on the main thread, so the global, entity and location methods all + * queue onto it. On Folia, game state is split between regions which tick on their own threads: a task must run on the + * region which owns the entity or location it touches, and anything that is not owned by a region (the world clock, + * the weather, the command map) runs on the global region. + *

+ * Delays and periods are in ticks. A delay of 0 runs the task as soon as possible on its thread, which is not + * necessarily during the current call. + */ +public interface TaskSchedulerProvider extends Provider { + /** + * Gets whether the world is split between regions, so that the current thread may own only part of it. + */ + default boolean isRegionized() { + return false; + } + + /** + * Gets whether the current thread runs global tasks. This is the main thread everywhere but Folia. + */ + boolean isGlobalThread(); + + /** + * Gets whether the current thread is allowed to access the given entity. + */ + boolean isOwnedByCurrentThread(Entity entity); + + /** + * Gets whether the current thread is allowed to access the blocks and entities at the given location. + */ + boolean isOwnedByCurrentThread(Location location); + + /** + * Runs a task on the global thread. + */ + Task runGlobal(Runnable task, long delay); + + Task runGlobalTimer(Runnable task, long delay, long period); + + /** + * Runs a task on the thread which owns an entity, following the entity if it moves to another region. + * + * @param retired run instead of the task if the entity is removed before the task could run, may be null. + */ + Task runEntity(Entity entity, Runnable task, Runnable retired, long delay); + + Task runEntityTimer(Entity entity, Runnable task, Runnable retired, long delay, long period); + + /** + * Runs a task on the thread which owns a location. + */ + Task runLocation(Location location, Runnable task, long delay); + + /** + * Runs a task on a thread which does not own anything, such as for blocking IO. + */ + Task runAsync(Runnable task, long delay); + + Task runAsyncTimer(Runnable task, long delay, long period); + + /** + * Cancels everything this provider has scheduled which can be cancelled in bulk. + */ + void cancelAll(); + + default Task runGlobal(final Runnable task) { + return runGlobal(task, 0); + } + + default Task runAsync(final Runnable task) { + return runAsync(task, 0); + } + + default Task runEntity(final Entity entity, final Runnable task) { + return runEntity(entity, task, null, 0); + } + + default Task runEntity(final Entity entity, final Runnable task, final long delay) { + return runEntity(entity, task, null, delay); + } + + default Task runLocation(final Location location, final Runnable task) { + return runLocation(location, task, 0); + } + + /** + * Runs a task now if the current thread owns the entity, otherwise as soon as possible on the thread which does. + */ + default void executeEntity(final Entity entity, final Runnable task) { + if (isOwnedByCurrentThread(entity)) { + task.run(); + } else { + runEntity(entity, task, 0); + } + } + + /** + * Runs a task now if the current thread owns the location, otherwise as soon as possible on the thread which does. + */ + default void executeLocation(final Location location, final Runnable task) { + if (isOwnedByCurrentThread(location)) { + task.run(); + } else { + runLocation(location, task, 0); + } + } + + /** + * Runs a task now if this is the global thread, otherwise as soon as possible on the global thread. + */ + default void executeGlobal(final Runnable task) { + if (isGlobalThread()) { + task.run(); + } else { + runGlobal(task, 0); + } + } + + interface Task { + void cancel(); + } +} diff --git a/providers/BaseProviders/src/main/java/net/ess3/provider/providers/BukkitTaskSchedulerProvider.java b/providers/BaseProviders/src/main/java/net/ess3/provider/providers/BukkitTaskSchedulerProvider.java new file mode 100644 index 0000000000..60fe5a4c62 --- /dev/null +++ b/providers/BaseProviders/src/main/java/net/ess3/provider/providers/BukkitTaskSchedulerProvider.java @@ -0,0 +1,79 @@ +package net.ess3.provider.providers; + +import net.ess3.provider.TaskSchedulerProvider; +import net.essentialsx.providers.ProviderData; +import org.bukkit.Location; +import org.bukkit.entity.Entity; +import org.bukkit.plugin.Plugin; +import org.bukkit.scheduler.BukkitScheduler; +import org.bukkit.scheduler.BukkitTask; + +@ProviderData(description = "Bukkit Task Scheduler Provider") +public class BukkitTaskSchedulerProvider implements TaskSchedulerProvider { + private final Plugin plugin; + private final BukkitScheduler scheduler; + + public BukkitTaskSchedulerProvider(final Plugin plugin) { + this.plugin = plugin; + this.scheduler = plugin.getServer().getScheduler(); + } + + @Override + public boolean isGlobalThread() { + return plugin.getServer().isPrimaryThread(); + } + + @Override + public boolean isOwnedByCurrentThread(final Entity entity) { + return isGlobalThread(); + } + + @Override + public boolean isOwnedByCurrentThread(final Location location) { + return isGlobalThread(); + } + + @Override + public Task runGlobal(final Runnable task, final long delay) { + final BukkitTask bukkitTask = delay <= 0 ? scheduler.runTask(plugin, task) : scheduler.runTaskLater(plugin, task, delay); + return bukkitTask::cancel; + } + + @Override + public Task runGlobalTimer(final Runnable task, final long delay, final long period) { + final BukkitTask bukkitTask = scheduler.runTaskTimer(plugin, task, delay, period); + return bukkitTask::cancel; + } + + @Override + public Task runEntity(final Entity entity, final Runnable task, final Runnable retired, final long delay) { + return runGlobal(task, delay); + } + + @Override + public Task runEntityTimer(final Entity entity, final Runnable task, final Runnable retired, final long delay, final long period) { + return runGlobalTimer(task, delay, period); + } + + @Override + public Task runLocation(final Location location, final Runnable task, final long delay) { + return runGlobal(task, delay); + } + + @Override + public Task runAsync(final Runnable task, final long delay) { + final BukkitTask bukkitTask = delay <= 0 ? scheduler.runTaskAsynchronously(plugin, task) : scheduler.runTaskLaterAsynchronously(plugin, task, delay); + return bukkitTask::cancel; + } + + @Override + public Task runAsyncTimer(final Runnable task, final long delay, final long period) { + final BukkitTask bukkitTask = scheduler.runTaskTimerAsynchronously(plugin, task, delay, period); + return bukkitTask::cancel; + } + + @Override + public void cancelAll() { + scheduler.cancelTasks(plugin); + } +} diff --git a/providers/PaperProvider/src/main/java/net/ess3/provider/providers/FoliaTaskSchedulerProvider.java b/providers/PaperProvider/src/main/java/net/ess3/provider/providers/FoliaTaskSchedulerProvider.java new file mode 100644 index 0000000000..e392a1894b --- /dev/null +++ b/providers/PaperProvider/src/main/java/net/ess3/provider/providers/FoliaTaskSchedulerProvider.java @@ -0,0 +1,127 @@ +package net.ess3.provider.providers; + +import io.papermc.paper.threadedregions.scheduler.ScheduledTask; +import net.ess3.provider.TaskSchedulerProvider; +import net.essentialsx.providers.ProviderData; +import net.essentialsx.providers.ProviderTest; +import org.bukkit.Location; +import org.bukkit.Server; +import org.bukkit.entity.Entity; +import org.bukkit.plugin.Plugin; + +import java.util.concurrent.TimeUnit; + +@ProviderData(description = "Folia Task Scheduler Provider", weight = 1) +public class FoliaTaskSchedulerProvider implements TaskSchedulerProvider { + private static final Task NOOP = () -> { + }; + + private final Plugin plugin; + private final Server server; + + public FoliaTaskSchedulerProvider(final Plugin plugin) { + this.plugin = plugin; + this.server = plugin.getServer(); + } + + @Override + public boolean isRegionized() { + return true; + } + + @Override + public boolean isGlobalThread() { + return server.isGlobalTickThread(); + } + + @Override + public boolean isOwnedByCurrentThread(final Entity entity) { + return server.isOwnedByCurrentRegion(entity); + } + + @Override + public boolean isOwnedByCurrentThread(final Location location) { + return server.isOwnedByCurrentRegion(location); + } + + @Override + public Task runGlobal(final Runnable task, final long delay) { + if (delay <= 0) { + return wrap(server.getGlobalRegionScheduler().run(plugin, scheduled -> task.run())); + } + return wrap(server.getGlobalRegionScheduler().runDelayed(plugin, scheduled -> task.run(), delay)); + } + + @Override + public Task runGlobalTimer(final Runnable task, final long delay, final long period) { + return wrap(server.getGlobalRegionScheduler().runAtFixedRate(plugin, scheduled -> task.run(), Math.max(delay, 1), period)); + } + + @Override + public Task runEntity(final Entity entity, final Runnable task, final Runnable retired, final long delay) { + final ScheduledTask scheduled; + if (delay <= 0) { + scheduled = entity.getScheduler().run(plugin, t -> task.run(), retired); + } else { + scheduled = entity.getScheduler().runDelayed(plugin, t -> task.run(), retired, delay); + } + return wrapEntity(scheduled, retired); + } + + @Override + public Task runEntityTimer(final Entity entity, final Runnable task, final Runnable retired, final long delay, final long period) { + return wrapEntity(entity.getScheduler().runAtFixedRate(plugin, t -> task.run(), retired, Math.max(delay, 1), period), retired); + } + + @Override + public Task runLocation(final Location location, final Runnable task, final long delay) { + if (delay <= 0) { + return wrap(server.getRegionScheduler().run(plugin, location, scheduled -> task.run())); + } + return wrap(server.getRegionScheduler().runDelayed(plugin, location, scheduled -> task.run(), delay)); + } + + @Override + public Task runAsync(final Runnable task, final long delay) { + if (delay <= 0) { + return wrap(server.getAsyncScheduler().runNow(plugin, scheduled -> task.run())); + } + return wrap(server.getAsyncScheduler().runDelayed(plugin, scheduled -> task.run(), delay * 50, TimeUnit.MILLISECONDS)); + } + + @Override + public Task runAsyncTimer(final Runnable task, final long delay, final long period) { + return wrap(server.getAsyncScheduler().runAtFixedRate(plugin, scheduled -> task.run(), Math.max(delay, 0) * 50, period * 50, TimeUnit.MILLISECONDS)); + } + + @Override + public void cancelAll() { + server.getGlobalRegionScheduler().cancelTasks(plugin); + server.getAsyncScheduler().cancelTasks(plugin); + } + + private static Task wrap(final ScheduledTask scheduled) { + return scheduled == null ? NOOP : scheduled::cancel; + } + + // The entity scheduler returns null, without calling the retired callback, if the entity is already removed + private static Task wrapEntity(final ScheduledTask scheduled, final Runnable retired) { + if (scheduled == null) { + if (retired != null) { + retired.run(); + } + return NOOP; + } + return scheduled::cancel; + } + + @ProviderTest + public static boolean test() { + try { + Class.forName("io.papermc.paper.threadedregions.RegionizedServer"); + return true; + } catch (final ClassNotFoundException ignored) { + return false; + } + } +} From e54fdc3614a86d4cfe2ab5908dbef305edb02db5 Mon Sep 17 00:00:00 2001 From: Lewis Gibson <12851394+lewisgibson@users.noreply.github.com> Date: Thu, 1 Oct 2026 21:45:00 +0100 Subject: [PATCH 03/13] Schedule core plugin tasks through the task scheduler Replace the Bukkit scheduler calls in the plugin core with TaskSchedulerProvider, choosing for each task the thread that owns what it touches: - Tasks that act on a player (join flow, MOTD, inventory refreshes, power tools, flight, AFK activity from chat) run on that player's entity thread. - Timers and one-off tasks that touch no player (backups, command map updates, the economy layer hook) run on the global thread. - Blocking work (IO, user disposal, update checks) runs asynchronously. The once a second EssentialsTimer runs on the global thread and hands each player's AFK, mute and jail checks to that player's own thread, and commands that the console has to dispatch (kit commands, the AFK timeout commands) are dispatched from the global thread. On Bukkit, Spigot and Paper all of this runs inline on the main thread as before. The history behind the TPS average is read by commands on other threads on Folia, so it is now accessed under a lock. Co-authored-by: Raw2d <255907405+Raw2d@users.noreply.github.com> --- .../java/com/earth2me/essentials/Backup.java | 19 ++++++----- .../earth2me/essentials/BalanceTopImpl.java | 2 +- .../com/earth2me/essentials/Essentials.java | 4 +-- .../essentials/EssentialsBlockListener.java | 2 +- .../essentials/EssentialsEntityListener.java | 2 +- .../essentials/EssentialsPlayerListener.java | 33 ++++++++++--------- .../earth2me/essentials/EssentialsTimer.java | 33 +++++++++++-------- .../java/com/earth2me/essentials/Kit.java | 2 +- .../com/earth2me/essentials/Settings.java | 10 +++--- .../java/com/earth2me/essentials/User.java | 6 ++-- .../essentials/commands/Commandcreatekit.java | 2 +- .../essentials/commands/Commandmail.java | 12 +++---- .../commands/essentials/CleanupCommand.java | 2 +- .../commands/essentials/DumpCommand.java | 2 +- .../commands/essentials/HomesCommand.java | 4 +-- .../commands/essentials/UsermapCommand.java | 2 +- .../commands/essentials/VersionCommand.java | 2 +- .../essentials/updatecheck/UpdateChecker.java | 4 +-- 18 files changed, 76 insertions(+), 67 deletions(-) diff --git a/Essentials/src/main/java/com/earth2me/essentials/Backup.java b/Essentials/src/main/java/com/earth2me/essentials/Backup.java index a6831ab3bb..a175e904ac 100644 --- a/Essentials/src/main/java/com/earth2me/essentials/Backup.java +++ b/Essentials/src/main/java/com/earth2me/essentials/Backup.java @@ -1,6 +1,7 @@ package com.earth2me.essentials; import net.ess3.api.IEssentials; +import net.ess3.provider.TaskSchedulerProvider; import org.bukkit.Server; import org.bukkit.command.CommandSender; @@ -18,7 +19,7 @@ public class Backup implements Runnable { private transient final IEssentials ess; private final AtomicBoolean pendingShutdown = new AtomicBoolean(false); private transient boolean running = false; - private transient int taskId = -1; + private transient TaskSchedulerProvider.Task task = null; private transient boolean active = false; private transient CompletableFuture taskLock = null; @@ -26,7 +27,7 @@ public Backup(final IEssentials ess) { this.ess = ess; server = ess.getServer(); if (!ess.getOnlinePlayers().isEmpty() || ess.getSettings().isAlwaysRunBackup()) { - ess.runTaskAsynchronously(this::startTask); + ess.getTaskScheduler().runAsync(this::startTask); } } @@ -36,10 +37,10 @@ public void onPlayerJoin() { public synchronized void stopTask() { running = false; - if (taskId != -1) { - server.getScheduler().cancelTask(taskId); + if (task != null) { + task.cancel(); } - taskId = -1; + task = null; } private synchronized void startTask() { @@ -48,7 +49,7 @@ private synchronized void startTask() { if (interval < 1200) { return; } - taskId = ess.scheduleSyncRepeatingTask(this, interval, interval); + task = ess.getTaskScheduler().runGlobalTimer(this, interval, interval); running = true; } } @@ -84,13 +85,13 @@ public void run() { server.dispatchCommand(cs, "save-all"); server.dispatchCommand(cs, "save-off"); - ess.runTaskAsynchronously(() -> { + ess.getTaskScheduler().runAsync(() -> { try { final ProcessBuilder childBuilder = new ProcessBuilder(command.split(" ")); childBuilder.redirectErrorStream(true); childBuilder.directory(ess.getDataFolder().getParentFile().getParentFile()); final Process child = childBuilder.start(); - ess.runTaskAsynchronously(() -> { + ess.getTaskScheduler().runAsync(() -> { try { try (final BufferedReader reader = new BufferedReader(new InputStreamReader(child.getInputStream()))) { String line; @@ -123,7 +124,7 @@ public void run() { } if (!pendingShutdown.get()) { - ess.scheduleSyncDelayedTask(new BackupEnableSaveTask()); + ess.getTaskScheduler().runGlobal(new BackupEnableSaveTask()); } } }); diff --git a/Essentials/src/main/java/com/earth2me/essentials/BalanceTopImpl.java b/Essentials/src/main/java/com/earth2me/essentials/BalanceTopImpl.java index 2dddd71260..af87c4582c 100644 --- a/Essentials/src/main/java/com/earth2me/essentials/BalanceTopImpl.java +++ b/Essentials/src/main/java/com/earth2me/essentials/BalanceTopImpl.java @@ -72,7 +72,7 @@ public CompletableFuture calculateBalanceTopMapAsync() { return cacheLock; } cacheLock = new CompletableFuture<>(); - ess.runTaskAsynchronously(this::calculateBalanceTopMap); + ess.getTaskScheduler().runAsync(this::calculateBalanceTopMap); return cacheLock; } diff --git a/Essentials/src/main/java/com/earth2me/essentials/Essentials.java b/Essentials/src/main/java/com/earth2me/essentials/Essentials.java index 000935c55c..320959e11d 100644 --- a/Essentials/src/main/java/com/earth2me/essentials/Essentials.java +++ b/Essentials/src/main/java/com/earth2me/essentials/Essentials.java @@ -449,7 +449,7 @@ public void onEnable() { alternativeCommandsHandler = new AlternativeCommandsHandler(this); timer = new EssentialsTimer(this); - scheduleSyncRepeatingTask(timer, 1000, 50); + taskScheduler.runGlobalTimer(timer, 1000, 50); Economy.setEss(this); execTimer.mark("RegHandler"); @@ -460,7 +460,7 @@ public void onEnable() { if (!TESTING) { updateChecker = new UpdateChecker(this); - runTaskAsynchronously(() -> { + taskScheduler.runAsync(() -> { getLogger().log(Level.INFO, getAdventureFacet().miniToLegacy(tlLiteral("versionFetching"))); for (final ComponentHolder component : updateChecker.getVersionMessages(false, true, new CommandSource(this, Bukkit.getConsoleSender()))) { getLogger().log(getSettings().isUpdateCheckEnabled() ? Level.WARNING : Level.INFO, getAdventureFacet().adventureToLegacy(component)); diff --git a/Essentials/src/main/java/com/earth2me/essentials/EssentialsBlockListener.java b/Essentials/src/main/java/com/earth2me/essentials/EssentialsBlockListener.java index 2890bc2a79..429ad1ef2f 100644 --- a/Essentials/src/main/java/com/earth2me/essentials/EssentialsBlockListener.java +++ b/Essentials/src/main/java/com/earth2me/essentials/EssentialsBlockListener.java @@ -44,7 +44,7 @@ public void onBlockPlace(final BlockPlaceEvent event) { final User user = ess.getUser(event.getPlayer()); if (user.hasUnlimited(is) && user.getBase().getGameMode() == GameMode.SURVIVAL) { - ess.scheduleSyncDelayedTask(() -> { + ess.getTaskScheduler().runEntity(user.getBase(), () -> { if (is != null && is.getType() != null && !MaterialUtil.isAir(is.getType())) { final ItemStack cloneIs = is.clone(); cloneIs.setAmount(1); diff --git a/Essentials/src/main/java/com/earth2me/essentials/EssentialsEntityListener.java b/Essentials/src/main/java/com/earth2me/essentials/EssentialsEntityListener.java index 1d1fbff1ee..e0b1e204bf 100644 --- a/Essentials/src/main/java/com/earth2me/essentials/EssentialsEntityListener.java +++ b/Essentials/src/main/java/com/earth2me/essentials/EssentialsEntityListener.java @@ -110,7 +110,7 @@ public void run() { } } - ess.scheduleSyncDelayedTask(new PowerToolInteractTask()); + ess.getTaskScheduler().runEntity(attacker.getBase(), new PowerToolInteractTask()); event.setCancelled(true); return; diff --git a/Essentials/src/main/java/com/earth2me/essentials/EssentialsPlayerListener.java b/Essentials/src/main/java/com/earth2me/essentials/EssentialsPlayerListener.java index 60d3b4c25f..0d46d0f4e8 100644 --- a/Essentials/src/main/java/com/earth2me/essentials/EssentialsPlayerListener.java +++ b/Essentials/src/main/java/com/earth2me/essentials/EssentialsPlayerListener.java @@ -25,6 +25,7 @@ import net.ess3.provider.FormattedCommandAliasProvider; import net.ess3.provider.InventoryViewProvider; import net.ess3.provider.KnownCommandsProvider; +import net.ess3.provider.TaskSchedulerProvider; import net.ess3.provider.TickCountProvider; import net.ess3.provider.providers.BukkitCommandSendListenerProvider; import net.ess3.provider.providers.PaperCommandSendListenerProvider; @@ -94,7 +95,7 @@ public class EssentialsPlayerListener implements Listener { private final transient IEssentials ess; - private final ConcurrentHashMap pendingMotdTasks = new ConcurrentHashMap<>(); + private final ConcurrentHashMap pendingMotdTasks = new ConcurrentHashMap<>(); public EssentialsPlayerListener(final IEssentials parent) { this.ess = parent; @@ -274,9 +275,9 @@ public void onPlayerMove(final PlayerMoveEvent event) { public void onPlayerQuit(final PlayerQuitEvent event) { final User user = ess.getUser(event.getPlayer()); - final Integer pendingId = pendingMotdTasks.remove(user.getUUID()); - if (pendingId != null) { - ess.getScheduler().cancelTask(pendingId); + final TaskSchedulerProvider.Task pendingMotd = pendingMotdTasks.remove(user.getUUID()); + if (pendingMotd != null) { + pendingMotd.cancel(); } if (hideJoinQuitMessages() || ess.getSettings().allowSilentJoinQuit() && user.isAuthorized("essentials.silentquit")) { @@ -393,7 +394,7 @@ private boolean hideJoinQuitMessages() { private void legacyJoinFlow(final PlayerJoinEvent event) { final String joinMessage = event.getJoinMessage(); - ess.runTaskAsynchronously(() -> delayedJoin(event.getPlayer(), joinMessage)); + ess.getTaskScheduler().runAsync(() -> delayedJoin(event.getPlayer(), joinMessage)); if (hideJoinQuitMessages() || ess.getSettings().allowSilentJoinQuit() || ess.getSettings().isCustomJoinMessage()) { event.setJoinMessage(null); @@ -469,13 +470,13 @@ private void joinFlow(final User user, final long currentTime, final String mess joinMessageConsumer.accept(effectiveMessage); } - ess.runTaskAsynchronously(() -> ess.getServer().getPluginManager().callEvent(new AsyncUserDataLoadEvent(user, effectiveMessage, firstJoin))); + ess.getTaskScheduler().runAsync(() -> ess.getServer().getPluginManager().callEvent(new AsyncUserDataLoadEvent(user, effectiveMessage, firstJoin))); if (ess.getSettings().getMotdDelay() >= 0) { final int motdDelay = ess.getSettings().getMotdDelay() / 50; final Runnable motdTask = () -> motdFlow(user); if (motdDelay > 0) { - pendingMotdTasks.put(user.getUUID(), ess.scheduleSyncDelayedTask(motdTask, motdDelay)); + pendingMotdTasks.put(user.getUUID(), ess.getTaskScheduler().runEntity(user.getBase(), motdTask, motdDelay)); } else { motdTask.run(); } @@ -492,7 +493,7 @@ private void joinFlow(final User user, final long currentTime, final String mess } if (user.isAuthorized("essentials.updatecheck")) { - ess.runTaskAsynchronously(() -> { + ess.getTaskScheduler().runAsync(() -> { for (final ComponentHolder component : ess.getUpdateChecker().getVersionMessages(false, false, user.getSource())) { user.sendComponent(component); } @@ -591,7 +592,7 @@ private void delayedJoin(final Player player, final String message) { dUser.updateActivity(false, AfkStatusChangeEvent.Cause.JOIN); dUser.stopTransaction(); - ess.scheduleSyncDelayedTask(() -> { + ess.getTaskScheduler().runEntity(player, () -> { final User user = ess.getUser(player); if (!user.getBase().isOnline()) { @@ -615,7 +616,7 @@ private void updateCompass(final User user) { if (loc == null) { PaperLib.getBedSpawnLocationAsync(user.getBase(), false).thenAccept(location -> { if (location != null) { - user.getBase().setCompassTarget(location); + ess.getTaskScheduler().executeEntity(user.getBase(), () -> user.getBase().setCompassTarget(location)); } }); return; @@ -761,7 +762,7 @@ public void onPlayerBucketEmpty(final PlayerBucketEmptyEvent event) { final User user = ess.getUser(event.getPlayer()); if (user.hasUnlimited(new ItemStack(event.getBucket()))) { event.getItemStack().setType(event.getBucket()); - ess.scheduleSyncDelayedTask(user.getBase()::updateInventory); + ess.getTaskScheduler().runEntity(user.getBase(), user.getBase()::updateInventory); } } @@ -1008,7 +1009,7 @@ public void run() { } } - ess.scheduleSyncDelayedTask(new DelayedClickJumpTask()); + ess.getTaskScheduler().runEntity(user.getBase(), new DelayedClickJumpTask()); } catch (final Exception ex) { if (ess.getSettings().isDebug()) { ess.getLogger().log(Level.WARNING, ex.getMessage(), ex); @@ -1039,7 +1040,7 @@ public void run() { } } - ess.scheduleSyncDelayedTask(new PowerToolUseTask()); + ess.getTaskScheduler().runEntity(user.getBase(), new PowerToolUseTask()); } } @@ -1101,7 +1102,7 @@ public void onInventoryClickEvent(final InventoryClickEvent event) { } if (refreshPlayer != null) { - ess.scheduleSyncDelayedTask(refreshPlayer::updateInventory, 1); + ess.getTaskScheduler().runEntity(refreshPlayer, refreshPlayer::updateInventory, 1); } } @@ -1164,7 +1165,7 @@ public void onInventoryCloseEvent(final InventoryCloseEvent event) { } if (refreshPlayer != null) { - ess.scheduleSyncDelayedTask(refreshPlayer::updateInventory, 1); + ess.getTaskScheduler().runEntity(refreshPlayer, refreshPlayer::updateInventory, 1); } } @@ -1192,7 +1193,7 @@ public void onPlayerGameModeChange(final PlayerGameModeChangeEvent event) { final Player player = event.getPlayer(); if (player.isFlying() && player.getAllowFlight() && user.isAuthorized("essentials.fly")) { // The gamemode change happens after the event, so we need to delay the flight enable - ess.scheduleSyncDelayedTask(() -> { + ess.getTaskScheduler().runEntity(player, () -> { player.setAllowFlight(true); player.setFlying(true); }, 1); diff --git a/Essentials/src/main/java/com/earth2me/essentials/EssentialsTimer.java b/Essentials/src/main/java/com/earth2me/essentials/EssentialsTimer.java index fbc96e1d21..7fe227c7ef 100644 --- a/Essentials/src/main/java/com/earth2me/essentials/EssentialsTimer.java +++ b/Essentials/src/main/java/com/earth2me/essentials/EssentialsTimer.java @@ -35,12 +35,15 @@ public void run() { if (timeSpent == 0) { timeSpent = 1; } - if (history.size() > 10) { - history.remove(); - } final double tps = tickInterval * 1000000.0 / timeSpent; - if (tps <= 21) { - history.add(tps); + // Commands and placeholders on other threads read the history through getAverageTPS() + synchronized (history) { + if (history.size() > 10) { + history.remove(); + } + if (tps <= 21) { + history.add(tps); + } } lastPoll = startTime; int count = 0; @@ -61,7 +64,7 @@ public void run() { final User user = ess.getUser(player); onlineUsers.add(user.getBase().getUniqueId()); user.setLastOnlineActivity(currentTime); - user.checkActivity(); + ess.getTaskScheduler().executeEntity(player, user::checkActivity); } catch (final Exception e) { ess.getLogger().log(Level.WARNING, "EssentialsTimer Error:", e); } @@ -94,19 +97,23 @@ public void run() { iterator.remove(); continue; } - user.checkMuteTimeout(currentTime); - user.checkJailTimeout(currentTime); + ess.getTaskScheduler().executeEntity(user.getBase(), () -> { + user.checkMuteTimeout(currentTime); + user.checkJailTimeout(currentTime); + }); user.resetInvulnerabilityAfterTeleport(); } } public double getAverageTPS() { - double avg = 0; - for (final Double f : history) { - if (f != null) { - avg += f; + synchronized (history) { + double avg = 0; + for (final Double f : history) { + if (f != null) { + avg += f; + } } + return avg / history.size(); } - return avg / history.size(); } } diff --git a/Essentials/src/main/java/com/earth2me/essentials/Kit.java b/Essentials/src/main/java/com/earth2me/essentials/Kit.java index 895b13d822..bd34e5e589 100644 --- a/Essentials/src/main/java/com/earth2me/essentials/Kit.java +++ b/Essentials/src/main/java/com/earth2me/essentials/Kit.java @@ -287,7 +287,7 @@ public boolean expandItems(final User user, final List items) throws Exc } for (final String cmd : commandQueue) { - Bukkit.dispatchCommand(Bukkit.getConsoleSender(), cmd); + ess.getTaskScheduler().executeGlobal(() -> Bukkit.dispatchCommand(Bukkit.getConsoleSender(), cmd)); } if (spew) { diff --git a/Essentials/src/main/java/com/earth2me/essentials/Settings.java b/Essentials/src/main/java/com/earth2me/essentials/Settings.java index 69c8a0b3e1..3876f7a7f8 100644 --- a/Essentials/src/main/java/com/earth2me/essentials/Settings.java +++ b/Essentials/src/main/java/com/earth2me/essentials/Settings.java @@ -852,7 +852,7 @@ public void reloadConfig() { if (reloadCount.get() < 2) { // on startup: add plugins again in case they registered commands with the new API // we need to schedule this task before any of the below tasks using _addAlternativeCommand. - ess.scheduleSyncDelayedTask(() -> { + ess.getTaskScheduler().runGlobal(() -> { for (final Plugin plugin : ess.getServer().getPluginManager().getPlugins()) { if (plugin.isEnabled()) { ess.getAlternativeCommandsHandler().addPlugin(plugin); @@ -878,9 +878,9 @@ public void reloadConfig() { // This is 2 because Settings are reloaded twice in the startup lifecycle if (reloadCount.get() < 2) { - ess.scheduleSyncDelayedTask(() -> _addAlternativeCommand(effectiveAlias, toDisable)); + ess.getTaskScheduler().runGlobal(() -> _addAlternativeCommand(effectiveAlias, toDisable)); } else { - _addAlternativeCommand(effectiveAlias, toDisable); + ess.getTaskScheduler().executeGlobal(() -> _addAlternativeCommand(effectiveAlias, toDisable)); } mapModified = true; } @@ -893,9 +893,9 @@ public void reloadConfig() { ess.getLogger().log(Level.INFO, "Syncing commands"); } if (reloadCount.get() < 2) { - ess.scheduleSyncDelayedTask(syncCommandsProvider::syncCommands); + ess.getTaskScheduler().runGlobal(syncCommandsProvider::syncCommands); } else { - syncCommandsProvider.syncCommands(); + ess.getTaskScheduler().executeGlobal(syncCommandsProvider::syncCommands); } } } diff --git a/Essentials/src/main/java/com/earth2me/essentials/User.java b/Essentials/src/main/java/com/earth2me/essentials/User.java index ab0281910e..6fc504441b 100644 --- a/Essentials/src/main/java/com/earth2me/essentials/User.java +++ b/Essentials/src/main/java/com/earth2me/essentials/User.java @@ -362,7 +362,7 @@ public boolean canAfford(final BigDecimal cost, final boolean permcheck) { } public void dispose() { - ess.runTaskAsynchronously(this::_dispose); + ess.getTaskScheduler().runAsync(this::_dispose); } private void _dispose() { @@ -880,7 +880,7 @@ public void updateActivityOnInteract(final boolean broadcast) { public void updateActivityOnChat(final boolean broadcast) { if (ess.getSettings().cancelAfkOnChat()) { //Chat happens async, make sure we have a sync context - ess.scheduleSyncDelayedTask(() -> updateActivity(broadcast, AfkStatusChangeEvent.Cause.CHAT)); + ess.getTaskScheduler().runEntity(base, () -> updateActivity(broadcast, AfkStatusChangeEvent.Cause.CHAT)); } } @@ -917,7 +917,7 @@ public void checkActivity() { } // Replace placeholders in the command with actual values. final String cmd = command.replace("{USERNAME}", getName()).replace("{KICKTIME}", String.valueOf(kickTime)); - ess.getServer().dispatchCommand(ess.getServer().getConsoleSender(), cmd); + ess.getTaskScheduler().executeGlobal(() -> ess.getServer().dispatchCommand(ess.getServer().getConsoleSender(), cmd)); } } } diff --git a/Essentials/src/main/java/com/earth2me/essentials/commands/Commandcreatekit.java b/Essentials/src/main/java/com/earth2me/essentials/commands/Commandcreatekit.java index 0c5189f82b..a0e85b7820 100644 --- a/Essentials/src/main/java/com/earth2me/essentials/commands/Commandcreatekit.java +++ b/Essentials/src/main/java/com/earth2me/essentials/commands/Commandcreatekit.java @@ -73,7 +73,7 @@ public void run(final Server server, final User user, final String commandLabel, } private void uploadPaste(final CommandSource sender, final String kitName, final long delay, final List list) { - ess.runTaskAsynchronously(() -> { + ess.getTaskScheduler().runAsync(() -> { try { final StringWriter sw = new StringWriter(); final YamlConfigurationLoader loader = YamlConfigurationLoader.builder().sink(() -> new BufferedWriter(sw)).indent(2).nodeStyle(NodeStyle.BLOCK).build(); diff --git a/Essentials/src/main/java/com/earth2me/essentials/commands/Commandmail.java b/Essentials/src/main/java/com/earth2me/essentials/commands/Commandmail.java index 683fce7533..61b3eb9ebc 100644 --- a/Essentials/src/main/java/com/earth2me/essentials/commands/Commandmail.java +++ b/Essentials/src/main/java/com/earth2me/essentials/commands/Commandmail.java @@ -151,7 +151,7 @@ public void run(final Server server, final User user, final String commandLabel, if (!user.isAuthorized("essentials.mail.sendall")) { throw new TranslatableException("noPerm", "essentials.mail.sendall"); } - ess.runTaskAsynchronously(new SendAll(user, + ess.getTaskScheduler().runAsync(new SendAll(user, FormatUtil.formatMessage(user, "essentials.mail", StringUtil.sanitizeString(FormatUtil.stripFormat(getFinalArg(args, 1)))), 0)); user.sendTl("mailSent"); @@ -161,7 +161,7 @@ public void run(final Server server, final User user, final String commandLabel, if (!user.isAuthorized("essentials.mail.sendtempall")) { throw new TranslatableException("noPerm", "essentials.mail.sendtempall"); } - ess.runTaskAsynchronously(new SendAll(user, + ess.getTaskScheduler().runAsync(new SendAll(user, FormatUtil.formatMessage(user, "essentials.mail", StringUtil.sanitizeString(FormatUtil.stripFormat(getFinalArg(args, 2)))), DateUtil.parseDateDiff(args[1], true))); user.sendTl("mailSent"); @@ -207,7 +207,7 @@ public void run(final Server server, final User user, final String commandLabel, throw new TranslatableException("noPerm", "essentials.mail.clearall"); } - ess.runTaskAsynchronously(new ClearAll()); + ess.getTaskScheduler().runAsync(new ClearAll()); user.sendTl("mailClearedAll"); return; @@ -242,7 +242,7 @@ protected void run(final Server server, final CommandSource sender, final String sender.sendTl("mailCleared"); return; } else if (args.length >= 1 && "clearall".equalsIgnoreCase(args[0])){ - ess.runTaskAsynchronously(new ClearAll()); + ess.getTaskScheduler().runAsync(new ClearAll()); sender.sendTl("mailClearedAll"); return; } else if (args.length >= 3 && "send".equalsIgnoreCase(args[0])) { @@ -267,12 +267,12 @@ protected void run(final Server server, final CommandSource sender, final String sender.sendTl("mailSent"); return; } else if (args.length >= 2 && "sendall".equalsIgnoreCase(args[0])) { - ess.runTaskAsynchronously(new SendAll(Console.getInstance(), FormatUtil.replaceFormat(getFinalArg(args, 1)), 0)); + ess.getTaskScheduler().runAsync(new SendAll(Console.getInstance(), FormatUtil.replaceFormat(getFinalArg(args, 1)), 0)); sender.sendTl("mailSent"); return; } else if (args.length >= 3 && "sendtempall".equalsIgnoreCase(args[0])) { final long dateDiff = DateUtil.parseDateDiff(args[1], true); - ess.runTaskAsynchronously(new SendAll(Console.getInstance(), FormatUtil.replaceFormat(getFinalArg(args, 2)), dateDiff)); + ess.getTaskScheduler().runAsync(new SendAll(Console.getInstance(), FormatUtil.replaceFormat(getFinalArg(args, 2)), dateDiff)); sender.sendTl("mailSent"); return; } else if (args.length >= 2) { diff --git a/Essentials/src/main/java/com/earth2me/essentials/commands/essentials/CleanupCommand.java b/Essentials/src/main/java/com/earth2me/essentials/commands/essentials/CleanupCommand.java index b7eba8ff05..bcd617b615 100644 --- a/Essentials/src/main/java/com/earth2me/essentials/commands/essentials/CleanupCommand.java +++ b/Essentials/src/main/java/com/earth2me/essentials/commands/essentials/CleanupCommand.java @@ -34,7 +34,7 @@ protected void run(final CommandSource sender, final String commandLabel, final final double moneyArg = args.length >= 2 ? FloatUtil.parseDouble(args[1].replaceAll("[^0-9.]", "")) : 0; final int homesArg = args.length >= 3 && NumberUtil.isInt(args[2]) ? Integer.parseInt(args[2]) : 0; - ess.runTaskAsynchronously(() -> { + ess.getTaskScheduler().runAsync(() -> { final long currTime = System.currentTimeMillis(); for (final UUID u : ess.getUsers().getAllUserUUIDs()) { final User user = ess.getUsers().loadUncachedUser(u); diff --git a/Essentials/src/main/java/com/earth2me/essentials/commands/essentials/DumpCommand.java b/Essentials/src/main/java/com/earth2me/essentials/commands/essentials/DumpCommand.java index ba5f078e3e..75340ab127 100644 --- a/Essentials/src/main/java/com/earth2me/essentials/commands/essentials/DumpCommand.java +++ b/Essentials/src/main/java/com/earth2me/essentials/commands/essentials/DumpCommand.java @@ -133,7 +133,7 @@ protected void run(CommandSource sender, String commandLabel, String[] args) thr final Map disabledCommandsCopy = new HashMap<>(ess.getAlternativeCommandsHandler().disabledCommands()); // Further operations will be heavy IO - ess.runTaskAsynchronously(() -> { + ess.getTaskScheduler().runAsync(() -> { boolean config = false; boolean discord = false; boolean kits = false; diff --git a/Essentials/src/main/java/com/earth2me/essentials/commands/essentials/HomesCommand.java b/Essentials/src/main/java/com/earth2me/essentials/commands/essentials/HomesCommand.java index 5a84c401c3..a8b689a03c 100644 --- a/Essentials/src/main/java/com/earth2me/essentials/commands/essentials/HomesCommand.java +++ b/Essentials/src/main/java/com/earth2me/essentials/commands/essentials/HomesCommand.java @@ -34,7 +34,7 @@ protected void run(final CommandSource sender, final String commandLabel, final switch (args[0]) { case "fix": sender.sendTl("fixingHomes"); - ess.runTaskAsynchronously(() -> { + ess.getTaskScheduler().runAsync(() -> { for (final UUID u : ess.getUsers().getAllUserUUIDs()) { final User user = ess.getUsers().loadUncachedUser(u); if (user == null) { @@ -63,7 +63,7 @@ protected void run(final CommandSource sender, final String commandLabel, final } else { sender.sendTl("deletingHomes"); } - ess.runTaskAsynchronously(() -> { + ess.getTaskScheduler().runAsync(() -> { for (final UUID u : ess.getUsers().getAllUserUUIDs()) { final User user = ess.getUsers().loadUncachedUser(u); if (user == null) { diff --git a/Essentials/src/main/java/com/earth2me/essentials/commands/essentials/UsermapCommand.java b/Essentials/src/main/java/com/earth2me/essentials/commands/essentials/UsermapCommand.java index a7f80ec8c1..debcc2048b 100644 --- a/Essentials/src/main/java/com/earth2me/essentials/commands/essentials/UsermapCommand.java +++ b/Essentials/src/main/java/com/earth2me/essentials/commands/essentials/UsermapCommand.java @@ -41,7 +41,7 @@ protected void run(final CommandSource sender, final String commandLabel, final sender.sendTl("usermapPurge", String.valueOf(seppuku)); final Set uuids = new HashSet<>(ess.getUsers().getAllUserUUIDs()); - ess.runTaskAsynchronously(() -> { + ess.getTaskScheduler().runAsync(() -> { final File userdataFolder = new File(ess.getDataFolder(), "userdata"); final File backupFolder = new File(ess.getDataFolder(), "userdata-npc-backup-boogaloo-" + System.currentTimeMillis()); diff --git a/Essentials/src/main/java/com/earth2me/essentials/commands/essentials/VersionCommand.java b/Essentials/src/main/java/com/earth2me/essentials/commands/essentials/VersionCommand.java index 7be23713a5..2894f5156b 100644 --- a/Essentials/src/main/java/com/earth2me/essentials/commands/essentials/VersionCommand.java +++ b/Essentials/src/main/java/com/earth2me/essentials/commands/essentials/VersionCommand.java @@ -145,7 +145,7 @@ protected void run(CommandSource sender, String commandLabel, String[] args) thr } sender.sendTl("versionFetching"); - ess.runTaskAsynchronously(() -> { + ess.getTaskScheduler().runAsync(() -> { for (final ComponentHolder component : ess.getUpdateChecker().getVersionMessages(true, true, sender)) { sender.sendComponent(component); } diff --git a/Essentials/src/main/java/com/earth2me/essentials/updatecheck/UpdateChecker.java b/Essentials/src/main/java/com/earth2me/essentials/updatecheck/UpdateChecker.java index 0f596a1e3f..e809729127 100644 --- a/Essentials/src/main/java/com/earth2me/essentials/updatecheck/UpdateChecker.java +++ b/Essentials/src/main/java/com/earth2me/essentials/updatecheck/UpdateChecker.java @@ -76,7 +76,7 @@ public CompletableFuture fetchLatestDev() { return pendingDevFuture; } pendingDevFuture = new CompletableFuture<>(); - ess.runTaskAsynchronously(() -> { + ess.getTaskScheduler().runAsync(() -> { pendingDevFuture.complete(cachedDev = fetchDistance(BRANCH, getVersionIdentifier())); pendingDevFuture = null; lastFetchTime = System.currentTimeMillis(); @@ -92,7 +92,7 @@ public CompletableFuture fetchLatestRelease() { return pendingReleaseFuture; } pendingReleaseFuture = new CompletableFuture<>(); - ess.runTaskAsynchronously(() -> { + ess.getTaskScheduler().runAsync(() -> { catchBlock: try { final HttpURLConnection connection = tryRequestWithFallback(LATEST_RELEASE_URL, LATEST_RELEASE_PROXY_URL); From df687f10a4e94d51adc71de9177a89f10f86b26e Mon Sep 17 00:00:00 2001 From: Lewis Gibson <12851394+lewisgibson@users.noreply.github.com> Date: Thu, 1 Oct 2026 21:45:00 +0100 Subject: [PATCH 04/13] Teleport on the threads that own the player and the destination On Folia the player being teleported, the chunk they are going to and the player who ran the command can all belong to different regions, and none of them may be touched from the wrong thread. - A passenger is ejected on the teleportee's own thread, and the teleport carries on from there instead of blocking the calling thread until the eject has run. If the player leaves before it runs the teleport completes as false. - Safety checks and the teleport itself run on the thread that owns the destination chunk once it has loaded. Without safety checks the teleport is requested through teleportAsync, as Folia has no synchronous teleport. - The search for a safe location treats blocks owned by another region as unsafe, so it carries on in the region it is allowed to read. - The warm-up timer ticks asynchronously and hands the teleport to the teleportee's thread. If the teleportee has left by then the timer stops itself. - Finding a random location reads the surface on the thread that owns the chunk it picked, and the default centre of a random teleport location is worked out by the thread that owns it. None of this changes where tasks run on Bukkit, Spigot and Paper, where every thread check passes on the main thread. Co-authored-by: Raw2d <255907405+Raw2d@users.noreply.github.com> --- .../earth2me/essentials/AsyncTeleport.java | 52 ++++++++++--------- .../essentials/AsyncTimedTeleport.java | 16 +++--- .../earth2me/essentials/RandomTeleport.java | 16 ++++-- .../essentials/utils/LocationUtil.java | 7 +++ .../com/earth2me/essentials/UtilTest.java | 31 +++++++++++ 5 files changed, 89 insertions(+), 33 deletions(-) diff --git a/Essentials/src/main/java/com/earth2me/essentials/AsyncTeleport.java b/Essentials/src/main/java/com/earth2me/essentials/AsyncTeleport.java index b5e687f255..9d1dc14000 100644 --- a/Essentials/src/main/java/com/earth2me/essentials/AsyncTeleport.java +++ b/Essentials/src/main/java/com/earth2me/essentials/AsyncTeleport.java @@ -139,19 +139,6 @@ public void nowUnsafe(Location loc, TeleportCause cause, CompletableFuture taskLock = new CompletableFuture<>(); - Bukkit.getScheduler().runTask(ess, () -> { - runnable.run(); - taskLock.complete(new Object()); - }); - taskLock.get(); - } - protected void nowAsync(final IUser teleportee, final ITarget target, final TeleportCause cause, final CompletableFuture future) { cancel(false); @@ -168,14 +155,23 @@ protected void nowAsync(final IUser teleportee, final ITarget target, final Tele return; } - try { - runOnMain(() -> teleportee.getBase().eject()); //EntityDismountEvent requires a sync context. - } catch (final ExecutionException | InterruptedException e) { - future.completeExceptionally(e); - return; + // EntityDismountEvent requires the thread that owns the teleportee, which is not necessarily this one + final Runnable ejectAndTeleport = () -> { + teleportee.getBase().eject(); + nowAsyncTeleport(teleportee, target, cause, future); + }; + if (ess.getTaskScheduler().isOwnedByCurrentThread(teleportee.getBase())) { + ejectAndTeleport.run(); + } else { + ess.getTaskScheduler().runEntity(teleportee.getBase(), ejectAndTeleport, () -> future.complete(false), 0); } + return; } + nowAsyncTeleport(teleportee, target, cause, future); + } + + private void nowAsyncTeleport(final IUser teleportee, final ITarget target, final TeleportCause cause, final CompletableFuture future) { if (teleportee.isAuthorized("essentials.back.onteleport")) { teleportee.setLastLocation(); } @@ -185,13 +181,12 @@ protected void nowAsync(final IUser teleportee, final ITarget target, final Tele targetLoc.setX(LocationUtil.getXInsideWorldBorder(targetLoc.getWorld(), targetLoc.getBlockX())); targetLoc.setZ(LocationUtil.getZInsideWorldBorder(targetLoc.getWorld(), targetLoc.getBlockZ())); } - PaperLib.getChunkAtAsync(targetLoc.getWorld(), targetLoc.getBlockX() >> 4, targetLoc.getBlockZ() >> 4, true, true).thenAccept(chunk -> { + PaperLib.getChunkAtAsync(targetLoc.getWorld(), targetLoc.getBlockX() >> 4, targetLoc.getBlockZ() >> 4, true, true).thenAccept(chunk -> ess.getTaskScheduler().executeLocation(targetLoc, () -> { Location loc = targetLoc; if (LocationUtil.isBlockUnsafeForUser(ess, teleportee, chunk.getWorld(), loc.getBlockX(), loc.getBlockY(), loc.getBlockZ())) { if (ess.getSettings().isTeleportSafetyEnabled()) { if (ess.getSettings().isForceDisableTeleportSafety()) { - //The chunk we're teleporting to is 100% going to be loaded here, no need to teleport async. - teleportee.getBase().teleport(loc, cause); + teleportToLoadedChunk(teleportee, loc, cause); } else { try { //There's a chance the safer location is outside the loaded chunk so still teleport async here. @@ -207,8 +202,7 @@ protected void nowAsync(final IUser teleportee, final ITarget target, final Tele } } else { if (ess.getSettings().isForceDisableTeleportSafety()) { - //The chunk we're teleporting to is 100% going to be loaded here, no need to teleport async. - teleportee.getBase().teleport(loc, cause); + teleportToLoadedChunk(teleportee, loc, cause); } else { if (ess.getSettings().isTeleportToCenterLocation()) { loc = LocationUtil.getRoundedDestination(loc); @@ -218,12 +212,22 @@ protected void nowAsync(final IUser teleportee, final ITarget target, final Tele } } future.complete(true); - }).exceptionally(th -> { + })).exceptionally(th -> { future.completeExceptionally(th); return null; }); } + private void teleportToLoadedChunk(final IUser teleportee, final Location loc, final TeleportCause cause) { + if (ess.getTaskScheduler().isRegionized()) { + // Folia has no synchronous teleports + PaperLib.teleportAsync(teleportee.getBase(), loc, cause); + return; + } + //The chunk we're teleporting to is 100% going to be loaded here, no need to teleport async. + teleportee.getBase().teleport(loc, cause); + } + @Override public void teleport(final Location loc, final Trade chargeFor, final TeleportCause cause, final CompletableFuture future) { teleport(teleportOwner, new LocationTarget(loc), chargeFor, cause, future); diff --git a/Essentials/src/main/java/com/earth2me/essentials/AsyncTimedTeleport.java b/Essentials/src/main/java/com/earth2me/essentials/AsyncTimedTeleport.java index d1c37423c0..260512cbe3 100644 --- a/Essentials/src/main/java/com/earth2me/essentials/AsyncTimedTeleport.java +++ b/Essentials/src/main/java/com/earth2me/essentials/AsyncTimedTeleport.java @@ -8,6 +8,7 @@ import net.ess3.api.IEssentials; import net.ess3.api.IUser; +import net.ess3.provider.TaskSchedulerProvider; import net.essentialsx.api.v2.events.TeleportWarmupCancelledEvent; import net.essentialsx.api.v2.events.TeleportWarmupCancelledEvent.CancelReason; @@ -31,7 +32,7 @@ public class AsyncTimedTeleport implements Runnable { private final boolean timer_canMove; private final Trade timer_chargeFor; private final TeleportCause timer_cause; - private int timer_task; + private TaskSchedulerProvider.Task timer_task; private double timer_health; AsyncTimedTeleport(final IUser user, final IEssentials ess, final AsyncTeleport teleport, final long delay, final IUser teleportUser, final ITarget target, final Trade chargeFor, final TeleportCause cause, final boolean respawn) { @@ -55,7 +56,7 @@ public class AsyncTimedTeleport implements Runnable { this.timer_respawn = respawn; this.timer_canMove = user.isAuthorized("essentials.teleport.timer.move"); - timer_task = ess.runTaskTimerAsynchronously(this, 20, 20).getTaskId(); + timer_task = ess.getTaskScheduler().runAsyncTimer(this, 20, 20); if (future != null) { this.parentFuture = future; @@ -142,16 +143,19 @@ public void run() { } } - ess.scheduleSyncDelayedTask(new DelayedTeleportTask()); + // If the player has left by now the entity is retired without running the task, so the timer has to stop itself + ess.getTaskScheduler().runEntity(teleportUser.getBase(), new DelayedTeleportTask(), () -> cancelTimer(false), 0); } //If we need to cancelTimer a pending teleportPlayer call this method void cancelTimer(final boolean notifyUser) { - if (timer_task == -1) { + // The timer thread and the thread of the player can both cancel, so read the task once + final TaskSchedulerProvider.Task task = timer_task; + if (task == null) { return; } try { - ess.getServer().getScheduler().cancelTask(timer_task); + task.cancel(); final IUser teleportUser = ess.getUser(this.timer_teleportee); if (teleportUser != null && teleportUser.getBase() != null) { @@ -167,7 +171,7 @@ void cancelTimer(final boolean notifyUser) { } } } finally { - timer_task = -1; + timer_task = null; } } } diff --git a/Essentials/src/main/java/com/earth2me/essentials/RandomTeleport.java b/Essentials/src/main/java/com/earth2me/essentials/RandomTeleport.java index 86513571c4..f16c068e05 100644 --- a/Essentials/src/main/java/com/earth2me/essentials/RandomTeleport.java +++ b/Essentials/src/main/java/com/earth2me/essentials/RandomTeleport.java @@ -61,6 +61,16 @@ public Location getCenter(final String name) { } final Location worldCenter = ess.getServer().getWorlds().get(0).getWorldBorder().getCenter(); + if (!ess.getTaskScheduler().isOwnedByCurrentThread(worldCenter)) { + // The surface can only be read by the thread that owns the chunk, so that thread works out and saves the real height + final Location surface = worldCenter.clone(); + ess.getTaskScheduler().runLocation(surface, () -> { + surface.setY(surface.getWorld().getHighestBlockYAt(surface) + HIGHEST_BLOCK_Y_OFFSET); + setCenter(name, surface); + }); + worldCenter.setY(worldCenter.getWorld().getSpawnLocation().getY()); + return worldCenter; + } worldCenter.setY(worldCenter.getWorld().getHighestBlockYAt(worldCenter) + HIGHEST_BLOCK_Y_OFFSET); setCenter(name, worldCenter); return worldCenter; @@ -156,7 +166,7 @@ public CompletableFuture getRandomLocation(final Location center, fina // Prompts caching random valid locations, up to a maximum number of attempts. public void cacheRandomLocations(final String name) { - ess.getServer().getScheduler().scheduleSyncDelayedTask(ess, () -> { + ess.getTaskScheduler().runGlobal(() -> { for (int i = 0; i < this.getFindAttempts(); ++i) { calculateRandomLocation(getCenter(name), getMinRange(name), getMaxRange(name)).thenAccept(location -> { if (isValidRandomLocation(location)) { @@ -214,14 +224,14 @@ private CompletableFuture calculateRandomLocation(final Location cente 360 * RANDOM.nextFloat() - 180, 0 ); - PaperLib.getChunkAtAsync(location).thenAccept(chunk -> { + PaperLib.getChunkAtAsync(location).thenAccept(chunk -> ess.getTaskScheduler().executeLocation(location, () -> { if (World.Environment.NETHER.equals(center.getWorld().getEnvironment())) { location.setY(getNetherYAt(location)); } else { location.setY(center.getWorld().getHighestBlockYAt(location) + HIGHEST_BLOCK_Y_OFFSET); } future.complete(location); - }); + })); return future; } diff --git a/Essentials/src/main/java/com/earth2me/essentials/utils/LocationUtil.java b/Essentials/src/main/java/com/earth2me/essentials/utils/LocationUtil.java index 18faec97f3..32af388c6b 100644 --- a/Essentials/src/main/java/com/earth2me/essentials/utils/LocationUtil.java +++ b/Essentials/src/main/java/com/earth2me/essentials/utils/LocationUtil.java @@ -4,6 +4,7 @@ import com.earth2me.essentials.IEssentials; import net.ess3.api.IUser; import net.ess3.api.TranslatableException; +import net.ess3.provider.TaskSchedulerProvider; import net.ess3.provider.WorldInfoProvider; import org.bukkit.GameMode; import org.bukkit.Location; @@ -164,6 +165,12 @@ public static boolean isBlockUnsafeForUser(IEssentials ess, final IUser user, fi } public static boolean isBlockUnsafe(IEssentials ess, final World world, final int x, final int y, final int z) { + // The search for a safe location spreads out from the destination, and on Folia a block can only be read by the + // region that owns it. Blocks owned by another region are skipped as unsafe so the search carries on elsewhere. + final TaskSchedulerProvider scheduler = ess.getTaskScheduler(); + if (scheduler.isRegionized() && !scheduler.isOwnedByCurrentThread(new Location(world, x, y, z))) { + return true; + } return isBlockDamaging(world, x, y, z) || isBlockAboveAir(ess, world, x, y, z); } diff --git a/Essentials/src/test/java/com/earth2me/essentials/UtilTest.java b/Essentials/src/test/java/com/earth2me/essentials/UtilTest.java index 2922e56481..f495231c56 100644 --- a/Essentials/src/test/java/com/earth2me/essentials/UtilTest.java +++ b/Essentials/src/test/java/com/earth2me/essentials/UtilTest.java @@ -3,6 +3,7 @@ import com.earth2me.essentials.utils.DateUtil; import com.earth2me.essentials.utils.LocationUtil; import com.earth2me.essentials.utils.VersionUtil; +import net.ess3.provider.TaskSchedulerProvider; import net.ess3.provider.WorldInfoProvider; import org.bukkit.Location; import org.bukkit.Material; @@ -24,6 +25,7 @@ import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertNull; import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.anyInt; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.when; @@ -91,7 +93,35 @@ public void testSafeLocationIgnoresLogicalHeightWhenDisabled() throws Exception assertEquals(128, result.getBlockY()); } + @Test + public void testBlocksOwnedByAnotherRegionAreUnsafe() { + final IEssentials essentials = mock(IEssentials.class); + final WorldInfoProvider worldInfoProvider = mock(WorldInfoProvider.class); + final TaskSchedulerProvider scheduler = mock(TaskSchedulerProvider.class); + final World world = mock(World.class); + final Block solid = mock(Block.class); + final Block hollow = mock(Block.class); + + when(essentials.provider(WorldInfoProvider.class)).thenReturn(worldInfoProvider); + when(essentials.getTaskScheduler()).thenReturn(scheduler); + when(worldInfoProvider.getMaxHeight(world)).thenReturn(256); + when(solid.getType()).thenReturn(Material.BEDROCK); + when(hollow.getType()).thenReturn(Material.LIGHT); + when(world.getBlockAt(anyInt(), anyInt(), anyInt())).thenAnswer(invocation -> (int) invocation.getArgument(1) < 64 ? solid : hollow); + when(scheduler.isOwnedByCurrentThread(any(Location.class))).thenAnswer(invocation -> ((Location) invocation.getArgument(0)).getBlockX() <= 0); + + assertFalse(LocationUtil.isBlockUnsafe(essentials, world, 1, 64, 0), "every block is readable when the world is not split between regions"); + + when(scheduler.isRegionized()).thenReturn(true); + assertFalse(LocationUtil.isBlockUnsafe(essentials, world, 0, 64, 0)); + assertTrue(LocationUtil.isBlockUnsafe(essentials, world, 1, 64, 0)); + } + private Location getSafeDestinationWithLogicalHeightSetting(final boolean considerWorldHeight) throws Exception { + return getSafeDestinationWithLogicalHeightSetting(considerWorldHeight, mock(TaskSchedulerProvider.class)); + } + + private Location getSafeDestinationWithLogicalHeightSetting(final boolean considerWorldHeight, final TaskSchedulerProvider scheduler) throws Exception { final IEssentials essentials = mock(IEssentials.class); final ISettings settings = mock(ISettings.class); final WorldInfoProvider worldInfoProvider = mock(WorldInfoProvider.class); @@ -101,6 +131,7 @@ private Location getSafeDestinationWithLogicalHeightSetting(final boolean consid final Block hollow = mock(Block.class); when(essentials.provider(WorldInfoProvider.class)).thenReturn(worldInfoProvider); + when(essentials.getTaskScheduler()).thenReturn(scheduler); when(essentials.getSettings()).thenReturn(settings); when(settings.isConsiderWorldHeightForTeleportSafety()).thenReturn(considerWorldHeight); when(worldInfoProvider.getMinHeight(world)).thenReturn(0); From acb0b0cc742f34065dd86d421d5f9e1e8592ee5f Mon Sep 17 00:00:00 2001 From: Lewis Gibson <12851394+lewisgibson@users.noreply.github.com> Date: Thu, 1 Oct 2026 21:45:00 +0100 Subject: [PATCH 05/13] Guard the teleport request queue against concurrent access /tpa and /tpahere add to the target's request queue from the requester's thread, while the target reads, expires and removes requests from their own thread and tab completion copies the keys. The queue is a plain LinkedHashMap, so on Folia those accesses can corrupt it or throw a ConcurrentModificationException. Every method that touches the queue now holds the user's monitor, and the pending keys are returned as a copy. Co-authored-by: Raw2d <255907405+Raw2d@users.noreply.github.com> --- .../java/com/earth2me/essentials/User.java | 13 ++- .../essentials/TeleportRequestQueueTest.java | 105 ++++++++++++++++++ 2 files changed, 112 insertions(+), 6 deletions(-) create mode 100644 Essentials/src/test/java/com/earth2me/essentials/TeleportRequestQueueTest.java diff --git a/Essentials/src/main/java/com/earth2me/essentials/User.java b/Essentials/src/main/java/com/earth2me/essentials/User.java index 6fc504441b..ff7f641662 100644 --- a/Essentials/src/main/java/com/earth2me/essentials/User.java +++ b/Essentials/src/main/java/com/earth2me/essentials/User.java @@ -70,6 +70,7 @@ public class User extends UserData implements Comparable, IMessageRecipien private String lastHomeConfirmation; // User teleport variables + // Requesters add to the queue from their own thread while this user reads it from theirs, so it is guarded by this user's monitor private final transient LinkedHashMap teleportRequestQueue = new LinkedHashMap<>(); // User properties @@ -403,7 +404,7 @@ public void setLogoutLocation() { } @Override - public void requestTeleport(final User player, final boolean here) { + public synchronized void requestTeleport(final User player, final boolean here) { final TpaRequest request = teleportRequestQueue.getOrDefault(player.getName(), new TpaRequest(player.getName(), player.getUUID())); request.setTime(System.currentTimeMillis()); request.setHere(here); @@ -420,8 +421,8 @@ public void requestTeleport(final User player, final boolean here) { teleportRequestQueue.put(request.getName(), request); } - public Collection getPendingTpaKeys() { - return teleportRequestQueue.keySet(); + public synchronized Collection getPendingTpaKeys() { + return new ArrayList<>(teleportRequestQueue.keySet()); } @Override @@ -434,7 +435,7 @@ public boolean hasOutstandingTpaRequest(String playerUsername, boolean here) { return request != null && request.isHere() == here; } - public @Nullable TpaRequest getOutstandingTpaRequest(String playerUsername, boolean inform) { + public synchronized @Nullable TpaRequest getOutstandingTpaRequest(String playerUsername, boolean inform) { if (!teleportRequestQueue.containsKey(playerUsername)) { return null; } @@ -451,12 +452,12 @@ public boolean hasOutstandingTpaRequest(String playerUsername, boolean here) { return null; } - public TpaRequest removeTpaRequest(String playerUsername) { + public synchronized TpaRequest removeTpaRequest(String playerUsername) { return teleportRequestQueue.remove(playerUsername); } @Override - public TpaRequest getNextTpaRequest(boolean inform, boolean ignoreExpirations, boolean excludeHere) { + public synchronized TpaRequest getNextTpaRequest(boolean inform, boolean ignoreExpirations, boolean excludeHere) { if (teleportRequestQueue.isEmpty()) { return null; } diff --git a/Essentials/src/test/java/com/earth2me/essentials/TeleportRequestQueueTest.java b/Essentials/src/test/java/com/earth2me/essentials/TeleportRequestQueueTest.java new file mode 100644 index 0000000000..ab90de60a6 --- /dev/null +++ b/Essentials/src/test/java/com/earth2me/essentials/TeleportRequestQueueTest.java @@ -0,0 +1,105 @@ +package com.earth2me.essentials; + +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockbukkit.mockbukkit.MockBukkit; +import org.mockbukkit.mockbukkit.ServerMock; + +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.CopyOnWriteArrayList; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicBoolean; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +public class TeleportRequestQueueTest { + private ServerMock server; + private Essentials ess; + + @BeforeEach + public void setUp() { + server = MockBukkit.mock(); + Essentials.TESTING = true; + ess = MockBukkit.load(Essentials.class); + } + + @AfterEach + public void tearDown() { + MockBukkit.unmock(); + } + + @Test + public void testRequestsAreQueuedAndRemoved() { + final User target = ess.getUser(server.addPlayer("target")); + final User first = ess.getUser(server.addPlayer("first")); + final User second = ess.getUser(server.addPlayer("second")); + + assertNull(target.getNextTpaRequest(false, false, false)); + target.requestTeleport(first, false); + target.requestTeleport(second, true); + + assertEquals(2, target.getPendingTpaKeys().size()); + assertEquals("second", target.getNextTpaRequest(false, false, false).getName()); + assertEquals("first", target.getNextTpaRequest(false, false, true).getName()); + assertTrue(target.hasOutstandingTpaRequest("second", true)); + + assertNotNull(target.removeTpaRequest("second")); + assertNull(target.getOutstandingTpaRequest("second", false)); + assertEquals("first", target.getNextTpaRequest(false, false, false).getName()); + } + + // Requesters queue onto the target from their own thread, and the target reads the queue from theirs + @Test + public void testQueueSurvivesConcurrentRequestsAndReads() throws Exception { + final User target = ess.getUser(server.addPlayer("target")); + final List requesters = new ArrayList<>(); + for (int i = 0; i < 6; i++) { + requesters.add(ess.getUser(server.addPlayer("requester" + i))); + } + + final List failures = new CopyOnWriteArrayList<>(); + final AtomicBoolean writing = new AtomicBoolean(true); + final CountDownLatch writers = new CountDownLatch(requesters.size()); + final List threads = new ArrayList<>(); + for (final User requester : requesters) { + threads.add(new Thread(() -> { + try { + for (int i = 0; i < 3000; i++) { + target.requestTeleport(requester, i % 2 == 0); + } + } catch (final Throwable t) { + failures.add(t); + } finally { + writers.countDown(); + } + })); + } + threads.add(new Thread(() -> { + try { + while (writing.get()) { + target.getNextTpaRequest(false, false, false); + target.getPendingTpaKeys(); + target.hasOutstandingTpaRequest("requester0", false); + target.removeTpaRequest("requester1"); + } + } catch (final Throwable t) { + failures.add(t); + } + })); + + threads.forEach(Thread::start); + assertTrue(writers.await(60, TimeUnit.SECONDS)); + writing.set(false); + for (final Thread thread : threads) { + thread.join(); + } + + assertEquals(new ArrayList(), failures); + } +} From 3f5a3acc058c568a6499d3d63815d0187bd15de8 Mon Sep 17 00:00:00 2001 From: Lewis Gibson <12851394+lewisgibson@users.noreply.github.com> Date: Thu, 1 Oct 2026 21:45:00 +0100 Subject: [PATCH 06/13] Make the reply recipient visible across threads The sender of a message writes the reply recipient and message time of the user they are messaging, which can be on another thread on Folia. Make both fields volatile so /r on the other side sees them. Co-authored-by: Raw2d <255907405+Raw2d@users.noreply.github.com> --- .../essentials/messaging/SimpleMessageRecipient.java | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/Essentials/src/main/java/com/earth2me/essentials/messaging/SimpleMessageRecipient.java b/Essentials/src/main/java/com/earth2me/essentials/messaging/SimpleMessageRecipient.java index d4a2e7473d..39a3a4f3ed 100644 --- a/Essentials/src/main/java/com/earth2me/essentials/messaging/SimpleMessageRecipient.java +++ b/Essentials/src/main/java/com/earth2me/essentials/messaging/SimpleMessageRecipient.java @@ -37,8 +37,9 @@ public class SimpleMessageRecipient implements IMessageRecipient { private final IEssentials ess; private final IMessageRecipient parent; - private long lastMessageMs; - private WeakReference replyRecipient; + // The sender writes these to the recipient they are messaging, which may be on another thread + private volatile long lastMessageMs; + private volatile WeakReference replyRecipient; public SimpleMessageRecipient(final IEssentials ess, final IMessageRecipient parent) { this.ess = ess; @@ -229,7 +230,8 @@ public boolean isReachable() { */ @Override public IMessageRecipient getReplyRecipient() { - return replyRecipient == null ? null : replyRecipient.get(); + final WeakReference recipient = replyRecipient; + return recipient == null ? null : recipient.get(); } /** From 85eb154c550806001119a3c3e6b09f06f4c22134 Mon Sep 17 00:00:00 2001 From: Lewis Gibson <12851394+lewisgibson@users.noreply.github.com> Date: Thu, 1 Oct 2026 21:45:00 +0100 Subject: [PATCH 07/13] Change other players from the thread that owns them in commands that target them Commands such as /heal, /feed, /kill, /gamemode, /lightning, /vanish, /fly and /god apply a change to every player matched by their argument, from the thread of whoever ran the command. On Folia that is not the thread that owns the player being changed, and for example killing a player drops their items from the wrong thread. When the current thread does not own a matched player, EssentialsLoopCommand and EssentialsToggleCommand now make the change on that player's own thread, and an error it raises is shown to the sender from there. On Bukkit, Spigot and Paper every player is owned by the current thread, so the change is made inline and errors propagate as before. --- .../commands/EssentialsLoopCommand.java | 26 ++++- .../commands/EssentialsToggleCommand.java | 20 +++- .../essentials/commands/LoopCommandTest.java | 106 ++++++++++++++++++ .../commands/ToggleCommandTest.java | 98 ++++++++++++++++ 4 files changed, 243 insertions(+), 7 deletions(-) create mode 100644 Essentials/src/test/java/com/earth2me/essentials/commands/LoopCommandTest.java create mode 100644 Essentials/src/test/java/com/earth2me/essentials/commands/ToggleCommandTest.java diff --git a/Essentials/src/main/java/com/earth2me/essentials/commands/EssentialsLoopCommand.java b/Essentials/src/main/java/com/earth2me/essentials/commands/EssentialsLoopCommand.java index 1a4505a7cb..9b305ef85a 100644 --- a/Essentials/src/main/java/com/earth2me/essentials/commands/EssentialsLoopCommand.java +++ b/Essentials/src/main/java/com/earth2me/essentials/commands/EssentialsLoopCommand.java @@ -84,7 +84,7 @@ protected void loopOnlinePlayersConsumer(final Server server, final CommandSourc } if (sender.isPlayer() && (searchTerm.equals("@s") || searchTerm.equals("@p"))) { - userConsumer.accept((User) sender.getUser()); + acceptOnline(sender, (User) sender.getUser(), userConsumer); return; } @@ -95,7 +95,7 @@ protected void loopOnlinePlayersConsumer(final Server server, final CommandSourc if (skipHidden && onlineUser.isHidden(sender.getPlayer()) && onlineUser.isHiddenFrom(sender.getPlayer())) { continue; } - userConsumer.accept(onlineUser); + acceptOnline(sender, onlineUser, userConsumer); } } else if (multipleStringMatches) { if (searchTerm.trim().length() < 2) { @@ -113,7 +113,7 @@ protected void loopOnlinePlayersConsumer(final Server server, final CommandSourc final String displayName = FormatUtil.stripFormat(player.getDisplayName()).toLowerCase(Locale.ENGLISH); if (displayName.contains(matchText)) { foundUser = true; - userConsumer.accept(player); + acceptOnline(sender, player, userConsumer); } } } else { @@ -123,7 +123,7 @@ protected void loopOnlinePlayersConsumer(final Server server, final CommandSourc continue; } foundUser = true; - userConsumer.accept(player); + acceptOnline(sender, player, userConsumer); } } if (!foundUser) { @@ -131,8 +131,24 @@ protected void loopOnlinePlayersConsumer(final Server server, final CommandSourc } } else { final User player = getPlayer(server, sender, searchTerm); - userConsumer.accept(player); + acceptOnline(sender, player, userConsumer); + } + } + + // Changing a player is only allowed from the thread that owns them, which on Folia is not necessarily the sender's. + // When it is not, the change is made from the player's thread and any error is reported to the sender from there. + private void acceptOnline(final CommandSource sender, final User user, final UserConsumer userConsumer) throws NotEnoughArgumentsException, TranslatableException { + if (ess.getTaskScheduler().isOwnedByCurrentThread(user.getBase())) { + userConsumer.accept(user); + return; } + ess.getTaskScheduler().runEntity(user.getBase(), () -> { + try { + userConsumer.accept(user); + } catch (final Exception e) { + showError(sender.getSender(), e, getName()); + } + }); } protected abstract void updatePlayer(Server server, CommandSource sender, User user, String[] args) throws NotEnoughArgumentsException, PlayerExemptException, ChargeException, MaxMoneyException; diff --git a/Essentials/src/main/java/com/earth2me/essentials/commands/EssentialsToggleCommand.java b/Essentials/src/main/java/com/earth2me/essentials/commands/EssentialsToggleCommand.java index 617664024c..380c68ab8d 100644 --- a/Essentials/src/main/java/com/earth2me/essentials/commands/EssentialsToggleCommand.java +++ b/Essentials/src/main/java/com/earth2me/essentials/commands/EssentialsToggleCommand.java @@ -57,9 +57,9 @@ protected void toggleOtherPlayers(final Server server, final CommandSource sende foundUser = true; if (args.length > 1) { final Boolean toggle = matchToggleArgument(args[1]); - togglePlayer(sender, player, toggle); + togglePlayerOnTheirThread(sender, player, toggle); } else { - togglePlayer(sender, player, null); + togglePlayerOnTheirThread(sender, player, null); } } if (!foundUser) { @@ -67,6 +67,22 @@ protected void toggleOtherPlayers(final Server server, final CommandSource sende } } + // Changing a player is only allowed from the thread that owns them, which on Folia is not necessarily the sender's. + // When it is not, the change is made from the player's thread and any error is reported to the sender from there. + private void togglePlayerOnTheirThread(final CommandSource sender, final User user, final Boolean enabled) throws NotEnoughArgumentsException { + if (ess.getTaskScheduler().isOwnedByCurrentThread(user.getBase())) { + togglePlayer(sender, user, enabled); + return; + } + ess.getTaskScheduler().runEntity(user.getBase(), () -> { + try { + togglePlayer(sender, user, enabled); + } catch (final Exception e) { + showError(sender.getSender(), e, getName()); + } + }); + } + // Make sure when implementing this method that all 3 Boolean states are handled, 'null' should toggle the existing state. protected abstract void togglePlayer(CommandSource sender, User user, Boolean enabled) throws NotEnoughArgumentsException; diff --git a/Essentials/src/test/java/com/earth2me/essentials/commands/LoopCommandTest.java b/Essentials/src/test/java/com/earth2me/essentials/commands/LoopCommandTest.java new file mode 100644 index 0000000000..a5eb16784e --- /dev/null +++ b/Essentials/src/test/java/com/earth2me/essentials/commands/LoopCommandTest.java @@ -0,0 +1,106 @@ +package com.earth2me.essentials.commands; + +import com.earth2me.essentials.CommandSource; +import com.earth2me.essentials.Essentials; +import com.earth2me.essentials.User; +import net.ess3.api.IEssentials; +import net.ess3.api.TranslatableException; +import net.ess3.provider.TaskSchedulerProvider; +import org.bukkit.Server; +import org.bukkit.entity.Player; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockbukkit.mockbukkit.MockBukkit; +import org.mockbukkit.mockbukkit.ServerMock; +import org.mockito.ArgumentCaptor; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +public class LoopCommandTest { + private ServerMock server; + private Essentials ess; + private User user; + private Player player; + private IEssentials commandEss; + private TaskSchedulerProvider scheduler; + private LoopCommand command; + private CommandSource sender; + + @BeforeEach + public void setUp() { + server = MockBukkit.mock(); + Essentials.TESTING = true; + ess = MockBukkit.load(Essentials.class); + sender = new CommandSource(ess, server.getConsoleSender()); + + player = mock(Player.class); + user = mock(User.class); + when(user.getBase()).thenReturn(player); + commandEss = mock(IEssentials.class); + scheduler = mock(TaskSchedulerProvider.class); + when(commandEss.getTaskScheduler()).thenReturn(scheduler); + when(commandEss.getOnlineUsers()).thenReturn(Collections.singletonList(user)); + command = new LoopCommand(); + command.setEssentials(commandEss); + } + + @AfterEach + public void tearDown() { + MockBukkit.unmock(); + } + + @Test + public void testChangesThePlayerInlineWhenTheCurrentThreadOwnsThem() throws Exception { + when(scheduler.isOwnedByCurrentThread(player)).thenReturn(true); + + command.loop(server, sender); + + assertEquals(Collections.singletonList(user), command.updated); + verify(scheduler, never()).runEntity(any(), any()); + } + + @Test + public void testChangesThePlayerOnTheirOwnThreadWhenAnotherThreadOwnsThem() throws Exception { + when(scheduler.isOwnedByCurrentThread(player)).thenReturn(false); + + command.loop(server, sender); + + assertEquals(new ArrayList(), command.updated); + final ArgumentCaptor task = ArgumentCaptor.forClass(Runnable.class); + verify(scheduler).runEntity(eq(player), task.capture()); + task.getValue().run(); + assertEquals(Collections.singletonList(user), command.updated); + } + + private static final class LoopCommand extends EssentialsLoopCommand { + private final List updated = new ArrayList<>(); + + private LoopCommand() { + super("test"); + } + + private void loop(final Server server, final CommandSource sender) throws TranslatableException, NotEnoughArgumentsException { + loopOnlinePlayers(server, sender, true, true, "*", new String[0]); + } + + @Override + protected void updatePlayer(final Server server, final CommandSource sender, final User user, final String[] args) { + updated.add(user); + } + + @Override + protected void run(final Server server, final CommandSource sender, final String commandLabel, final String[] args) { + } + } +} diff --git a/Essentials/src/test/java/com/earth2me/essentials/commands/ToggleCommandTest.java b/Essentials/src/test/java/com/earth2me/essentials/commands/ToggleCommandTest.java new file mode 100644 index 0000000000..43a53ab9ec --- /dev/null +++ b/Essentials/src/test/java/com/earth2me/essentials/commands/ToggleCommandTest.java @@ -0,0 +1,98 @@ +package com.earth2me.essentials.commands; + +import com.earth2me.essentials.CommandSource; +import com.earth2me.essentials.Essentials; +import com.earth2me.essentials.User; +import net.ess3.api.IEssentials; +import net.ess3.provider.TaskSchedulerProvider; +import org.bukkit.Server; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockbukkit.mockbukkit.MockBukkit; +import org.mockbukkit.mockbukkit.ServerMock; +import org.mockbukkit.mockbukkit.entity.PlayerMock; +import org.mockito.ArgumentCaptor; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +public class ToggleCommandTest { + private ServerMock server; + private CommandSource sender; + private PlayerMock player; + private User user; + private TaskSchedulerProvider scheduler; + private ToggleCommand command; + + @BeforeEach + public void setUp() { + server = MockBukkit.mock(); + Essentials.TESTING = true; + sender = new CommandSource(MockBukkit.load(Essentials.class), server.getConsoleSender()); + + player = server.addPlayer("target"); + user = mock(User.class); + when(user.getBase()).thenReturn(player); + final IEssentials ess = mock(IEssentials.class); + scheduler = mock(TaskSchedulerProvider.class); + when(ess.getTaskScheduler()).thenReturn(scheduler); + when(ess.getUser(player)).thenReturn(user); + command = new ToggleCommand(); + command.setEssentials(ess); + } + + @AfterEach + public void tearDown() { + MockBukkit.unmock(); + } + + @Test + public void testTogglesAnotherPlayerInlineWhenTheCurrentThreadOwnsThem() throws Exception { + when(scheduler.isOwnedByCurrentThread(player)).thenReturn(true); + + command.toggleOtherPlayers(server, sender, new String[] {"target", "on"}); + + assertEquals(Collections.singletonList(Boolean.TRUE), command.toggled); + verify(scheduler, never()).runEntity(any(), any()); + } + + @Test + public void testTogglesAnotherPlayerOnTheirOwnThreadWhenAnotherThreadOwnsThem() throws Exception { + when(scheduler.isOwnedByCurrentThread(player)).thenReturn(false); + + command.toggleOtherPlayers(server, sender, new String[] {"target", "off"}); + + assertEquals(new ArrayList(), command.toggled); + final ArgumentCaptor task = ArgumentCaptor.forClass(Runnable.class); + verify(scheduler).runEntity(eq(player), task.capture()); + task.getValue().run(); + assertEquals(Collections.singletonList(Boolean.FALSE), command.toggled); + } + + private static final class ToggleCommand extends EssentialsToggleCommand { + private final List toggled = new ArrayList<>(); + + private ToggleCommand() { + super("test", "essentials.test.others"); + } + + @Override + protected void togglePlayer(final CommandSource sender, final User user, final Boolean enabled) { + toggled.add(enabled); + } + + @Override + protected void run(final Server server, final CommandSource sender, final String commandLabel, final String[] args) { + } + } +} From 8fd541ff9f1df731843dbbb30635f5df2370285e Mon Sep 17 00:00:00 2001 From: Lewis Gibson <12851394+lewisgibson@users.noreply.github.com> Date: Thu, 1 Oct 2026 21:45:00 +0100 Subject: [PATCH 08/13] Change the world from the thread that owns it in world commands The world clock and the weather belong to the global thread on Folia, and entities and blocks to the thread of the region they are in. Commands that run from a player's region cannot change either directly. - /time, /weather, /thunder and the time and weather signs set the clock and the weather from the global thread. /time re-applies relative player times from each player's own thread. - /remove visits the loaded chunks from the thread that owns each of them and reports the total once the last chunk is done. - /nuke and /antioch spawn their TNT, and /spawnmob spawns its mobs, from the thread that owns where they land. - /gc reads the tile entity count of a world from the world, since a region cannot read the chunks of other regions on Folia. On Bukkit, Spigot and Paper all of this still runs inline on the main thread. Co-authored-by: Raw2d <255907405+Raw2d@users.noreply.github.com> --- .../com/earth2me/essentials/SpawnMob.java | 13 + .../essentials/commands/Commandantioch.java | 2 +- .../essentials/commands/Commandgc.java | 9 +- .../essentials/commands/Commandnuke.java | 22 +- .../essentials/commands/Commandremove.java | 255 +++++++++--------- .../essentials/commands/Commandthunder.java | 9 +- .../essentials/commands/Commandtime.java | 42 +-- .../essentials/commands/Commandweather.java | 17 +- .../earth2me/essentials/signs/SignTime.java | 6 +- .../essentials/signs/SignWeather.java | 4 +- 10 files changed, 215 insertions(+), 164 deletions(-) diff --git a/Essentials/src/main/java/com/earth2me/essentials/SpawnMob.java b/Essentials/src/main/java/com/earth2me/essentials/SpawnMob.java index 39476c9ece..0784cb13b7 100644 --- a/Essentials/src/main/java/com/earth2me/essentials/SpawnMob.java +++ b/Essentials/src/main/java/com/earth2me/essentials/SpawnMob.java @@ -106,6 +106,19 @@ public static void spawnmob(final IEssentials ess, final Server server, final Co // This method spawns a mob at loc, owned by target public static void spawnmob(final IEssentials ess, final Server server, final CommandSource sender, final User target, final Location loc, final List parts, final List data, int mobCount) throws Exception { + if (!ess.getTaskScheduler().isOwnedByCurrentThread(loc)) { + // Mobs can only be spawned, and the surroundings read, by the thread that owns the location + final int requestedCount = mobCount; + ess.getTaskScheduler().runLocation(loc, () -> { + try { + spawnmob(ess, server, sender, target, loc, parts, data, requestedCount); + } catch (final Exception e) { + ess.showError(sender, e, "spawnmob"); + } + }); + return; + } + final Location sloc = LocationUtil.getSafeDestination(ess, loc); for (final String part : parts) { diff --git a/Essentials/src/main/java/com/earth2me/essentials/commands/Commandantioch.java b/Essentials/src/main/java/com/earth2me/essentials/commands/Commandantioch.java index e5fd889e61..b43761417e 100644 --- a/Essentials/src/main/java/com/earth2me/essentials/commands/Commandantioch.java +++ b/Essentials/src/main/java/com/earth2me/essentials/commands/Commandantioch.java @@ -20,6 +20,6 @@ public void run(final Server server, final User user, final String commandLabel, } final Location loc = LocationUtil.getTarget(user.getBase()); - loc.getWorld().spawn(loc, TNTPrimed.class); + ess.getTaskScheduler().executeLocation(loc, () -> loc.getWorld().spawn(loc, TNTPrimed.class)); } } diff --git a/Essentials/src/main/java/com/earth2me/essentials/commands/Commandgc.java b/Essentials/src/main/java/com/earth2me/essentials/commands/Commandgc.java index b35449507e..393be48e1a 100644 --- a/Essentials/src/main/java/com/earth2me/essentials/commands/Commandgc.java +++ b/Essentials/src/main/java/com/earth2me/essentials/commands/Commandgc.java @@ -52,8 +52,13 @@ protected void run(final Server server, final CommandSource sender, final String int tileEntities = 0; try { - for (final Chunk chunk : w.getLoadedChunks()) { - tileEntities += tileEntityProvider.getTileEntities(chunk).length; + if (ess.getTaskScheduler().isRegionized()) { + // The chunks of other regions cannot be read from here, but they can be counted + tileEntities = w.getTileEntityCount(); + } else { + for (final Chunk chunk : w.getLoadedChunks()) { + tileEntities += tileEntityProvider.getTileEntities(chunk).length; + } } } catch (final java.lang.ClassCastException ex) { ess.getLogger().log(Level.SEVERE, "Corrupted chunk data on world " + w, ex); diff --git a/Essentials/src/main/java/com/earth2me/essentials/commands/Commandnuke.java b/Essentials/src/main/java/com/earth2me/essentials/commands/Commandnuke.java index 58ae8da0f7..e772767f17 100644 --- a/Essentials/src/main/java/com/earth2me/essentials/commands/Commandnuke.java +++ b/Essentials/src/main/java/com/earth2me/essentials/commands/Commandnuke.java @@ -36,16 +36,22 @@ protected void run(final Server server, final CommandSource sender, final String continue; } user.sendTl("nuke"); - final Location loc = user.getLocation(); - final World world = loc.getWorld(); - if (world != null) { - for (int x = -10; x <= 10; x += 5) { - for (int z = -10; z <= 10; z += 5) { - final TNTPrimed entity = world.spawn(new Location(world, loc.getBlockX() + x, world.getHighestBlockYAt(loc) + 64, loc.getBlockZ() + z), TNTPrimed.class); - entity.setMetadata(NUKE_META_KEY, new FixedMetadataValue(ess, true)); + // The player is read on their own thread, and each bomb is spawned by the thread that owns where it falls + ess.getTaskScheduler().executeEntity(user.getBase(), () -> { + final Location loc = user.getLocation(); + final World world = loc.getWorld(); + if (world != null) { + for (int x = -10; x <= 10; x += 5) { + for (int z = -10; z <= 10; z += 5) { + final Location bomb = new Location(world, loc.getBlockX() + x, world.getHighestBlockYAt(loc) + 64, loc.getBlockZ() + z); + ess.getTaskScheduler().executeLocation(bomb, () -> { + final TNTPrimed entity = world.spawn(bomb, TNTPrimed.class); + entity.setMetadata(NUKE_META_KEY, new FixedMetadataValue(ess, true)); + }); + } } } - } + }); } } diff --git a/Essentials/src/main/java/com/earth2me/essentials/commands/Commandremove.java b/Essentials/src/main/java/com/earth2me/essentials/commands/Commandremove.java index 65e7ad9a69..ae93943fc7 100644 --- a/Essentials/src/main/java/com/earth2me/essentials/commands/Commandremove.java +++ b/Essentials/src/main/java/com/earth2me/essentials/commands/Commandremove.java @@ -6,6 +6,7 @@ import com.google.common.collect.Lists; import net.ess3.api.TranslatableException; import org.bukkit.Chunk; +import org.bukkit.Location; import org.bukkit.OfflinePlayer; import org.bukkit.Server; import org.bukkit.World; @@ -36,6 +37,7 @@ import java.util.Collections; import java.util.List; import java.util.Locale; +import java.util.concurrent.atomic.AtomicInteger; // This could be rewritten in a simpler form if we made a mapping of all Entity names to their types (which would also provide possible mod support) @@ -105,11 +107,9 @@ private void parseCommand(final Server server, final CommandSource sender, final removeHandler(sender, types, customTypes, world, radius); } - private void removeHandler(final CommandSource sender, final List types, final List customTypes, final World world, int radius) { - int removed = 0; - if (radius > 0) { - radius *= radius; - } + private void removeHandler(final CommandSource sender, final List types, final List customTypes, final World world, final int radius) { + final AtomicInteger removed = new AtomicInteger(); + final int radiusSquared = radius > 0 ? radius * radius : radius; final ArrayList removeTypes = new ArrayList<>(); final ArrayList customRemoveTypes = new ArrayList<>(); @@ -133,134 +133,145 @@ private void removeHandler(final CommandSource sender, final List types, sender.sendTl("invalidMob"); } - for (final Chunk chunk : world.getLoadedChunks()) { - for (final Entity e : chunk.getEntities()) { - if (radius > 0) { - if (sender.getPlayer().getLocation().distanceSquared(e.getLocation()) > radius) { + final Chunk[] chunks = world.getLoadedChunks(); + if (chunks.length == 0) { + sender.sendTl("removed", 0); + return; + } + final AtomicInteger remainingChunks = new AtomicInteger(chunks.length); + for (final Chunk chunk : chunks) { + // Entities can only be removed by the thread that owns their chunk, which on Folia differs between chunks + ess.getTaskScheduler().executeLocation(new Location(world, chunk.getX() << 4, 0, chunk.getZ() << 4), () -> { + for (final Entity e : chunk.getEntities()) { + if (radiusSquared > 0) { + if (sender.getPlayer().getLocation().distanceSquared(e.getLocation()) > radiusSquared) { + continue; + } + } + if (e instanceof HumanEntity) { continue; } - } - if (e instanceof HumanEntity) { - continue; - } - for (final ToRemove toRemove : removeTypes) { + for (final ToRemove toRemove : removeTypes) { - // We should skip any animals tamed by players unless we are specifially targetting them. - if (e instanceof Tameable && ((Tameable) e).isTamed() && (((Tameable) e).getOwner() instanceof Player || ((Tameable) e).getOwner() instanceof OfflinePlayer) && !removeTypes.contains(ToRemove.TAMED)) { - continue; - } + // We should skip any animals tamed by players unless we are specifially targetting them. + if (e instanceof Tameable && ((Tameable) e).isTamed() && (((Tameable) e).getOwner() instanceof Player || ((Tameable) e).getOwner() instanceof OfflinePlayer) && !removeTypes.contains(ToRemove.TAMED)) { + continue; + } - // We should skip any NAMED animals unless we are specifially targetting them. - if (e instanceof LivingEntity && e.getCustomName() != null && !removeTypes.contains(ToRemove.NAMED)) { - continue; - } + // We should skip any NAMED animals unless we are specifially targetting them. + if (e instanceof LivingEntity && e.getCustomName() != null && !removeTypes.contains(ToRemove.NAMED)) { + continue; + } - switch (toRemove) { - case TAMED: - if (e instanceof Tameable && ((Tameable) e).isTamed()) { - e.remove(); - removed++; - } - break; - case NAMED: - if (e instanceof LivingEntity && e.getCustomName() != null) { - e.remove(); - removed++; - } - break; - case DROPS: - if (e instanceof Item) { - e.remove(); - removed++; - } - break; - case ARROWS: - if (e instanceof Projectile) { - e.remove(); - removed++; - } - break; - case BOATS: - if (e instanceof Boat) { - e.remove(); - removed++; - } - break; - case MINECARTS: - if (e instanceof Minecart) { - e.remove(); - removed++; - } - break; - case XP: - if (e instanceof ExperienceOrb) { - e.remove(); - removed++; - } - break; - case PAINTINGS: - if (e instanceof Painting) { - e.remove(); - removed++; - } - break; - case ITEMFRAMES: - if (e instanceof ItemFrame) { - e.remove(); - removed++; - } - break; - case ENDERCRYSTALS: - if (e instanceof EnderCrystal) { - e.remove(); - removed++; - } - break; - case AMBIENT: - if (e instanceof Flying) { - e.remove(); - removed++; - } - break; - case HOSTILE: - case MONSTERS: - if (e instanceof Monster || e instanceof ComplexLivingEntity || e instanceof Flying || e instanceof Slime) { - e.remove(); - removed++; - } - break; - case PASSIVE: - case ANIMALS: - if (e instanceof Animals || e instanceof NPC || e instanceof Snowman || e instanceof WaterMob || e instanceof Ambient) { - e.remove(); - removed++; - } - break; - case MOBS: - if (e instanceof Animals || e instanceof NPC || e instanceof Snowman || e instanceof WaterMob || e instanceof Monster || e instanceof ComplexLivingEntity || e instanceof Flying || e instanceof Slime || e instanceof Ambient) { - e.remove(); - removed++; - } - break; - case ENTITIES: - case ALL: - e.remove(); - removed++; - break; - case CUSTOM: - for (final Mob type : customRemoveTypes) { - if (e.getType() == type.getType()) { + switch (toRemove) { + case TAMED: + if (e instanceof Tameable && ((Tameable) e).isTamed()) { + e.remove(); + removed.incrementAndGet(); + } + break; + case NAMED: + if (e instanceof LivingEntity && e.getCustomName() != null) { + e.remove(); + removed.incrementAndGet(); + } + break; + case DROPS: + if (e instanceof Item) { e.remove(); - removed++; + removed.incrementAndGet(); } - } - break; + break; + case ARROWS: + if (e instanceof Projectile) { + e.remove(); + removed.incrementAndGet(); + } + break; + case BOATS: + if (e instanceof Boat) { + e.remove(); + removed.incrementAndGet(); + } + break; + case MINECARTS: + if (e instanceof Minecart) { + e.remove(); + removed.incrementAndGet(); + } + break; + case XP: + if (e instanceof ExperienceOrb) { + e.remove(); + removed.incrementAndGet(); + } + break; + case PAINTINGS: + if (e instanceof Painting) { + e.remove(); + removed.incrementAndGet(); + } + break; + case ITEMFRAMES: + if (e instanceof ItemFrame) { + e.remove(); + removed.incrementAndGet(); + } + break; + case ENDERCRYSTALS: + if (e instanceof EnderCrystal) { + e.remove(); + removed.incrementAndGet(); + } + break; + case AMBIENT: + if (e instanceof Flying) { + e.remove(); + removed.incrementAndGet(); + } + break; + case HOSTILE: + case MONSTERS: + if (e instanceof Monster || e instanceof ComplexLivingEntity || e instanceof Flying || e instanceof Slime) { + e.remove(); + removed.incrementAndGet(); + } + break; + case PASSIVE: + case ANIMALS: + if (e instanceof Animals || e instanceof NPC || e instanceof Snowman || e instanceof WaterMob || e instanceof Ambient) { + e.remove(); + removed.incrementAndGet(); + } + break; + case MOBS: + if (e instanceof Animals || e instanceof NPC || e instanceof Snowman || e instanceof WaterMob || e instanceof Monster || e instanceof ComplexLivingEntity || e instanceof Flying || e instanceof Slime || e instanceof Ambient) { + e.remove(); + removed.incrementAndGet(); + } + break; + case ENTITIES: + case ALL: + e.remove(); + removed.incrementAndGet(); + break; + case CUSTOM: + for (final Mob type : customRemoveTypes) { + if (e.getType() == type.getType()) { + e.remove(); + removed.incrementAndGet(); + } + } + break; + } } } - } + if (remainingChunks.decrementAndGet() == 0) { + sender.sendTl("removed", removed.get()); + } + }); } - sender.sendTl("removed", removed); } @Override diff --git a/Essentials/src/main/java/com/earth2me/essentials/commands/Commandthunder.java b/Essentials/src/main/java/com/earth2me/essentials/commands/Commandthunder.java index 09a93328d1..bd1815bb4f 100644 --- a/Essentials/src/main/java/com/earth2me/essentials/commands/Commandthunder.java +++ b/Essentials/src/main/java/com/earth2me/essentials/commands/Commandthunder.java @@ -23,14 +23,17 @@ public void run(final Server server, final User user, final String commandLabel, final World world = user.getWorld(); final boolean setThunder = args[0].equalsIgnoreCase("true"); + // The weather belongs to the global thread on Folia if (args.length == 1) { - world.setThundering(setThunder); + ess.getTaskScheduler().executeGlobal(() -> world.setThundering(setThunder)); user.sendTl("thunder", CommonPlaceholders.enableDisable(user.getSource(), setThunder)); return; } - world.setThundering(setThunder); - world.setThunderDuration(Integer.parseInt(args[1]) * 20); + ess.getTaskScheduler().executeGlobal(() -> { + world.setThundering(setThunder); + world.setThunderDuration(Integer.parseInt(args[1]) * 20); + }); user.sendTl("thunderDuration", CommonPlaceholders.enableDisable(user.getSource(), setThunder), Integer.parseInt(args[1])); } diff --git a/Essentials/src/main/java/com/earth2me/essentials/commands/Commandtime.java b/Essentials/src/main/java/com/earth2me/essentials/commands/Commandtime.java index f4ab381936..59af975cb0 100644 --- a/Essentials/src/main/java/com/earth2me/essentials/commands/Commandtime.java +++ b/Essentials/src/main/java/com/earth2me/essentials/commands/Commandtime.java @@ -83,30 +83,36 @@ public void run(final Server server, final CommandSource sender, final String co } } + final boolean addTime = add; final StringJoiner joiner = new StringJoiner(", "); for (final World world : worlds) { - // Capture intended visible time for players with relative ptime before world time changes - final Map ptimePlayers = new HashMap<>(); - for (final Player player : world.getPlayers()) { - if (player.getPlayerTimeOffset() != 0 && player.isPlayerTimeRelative()) { - ptimePlayers.put(player, player.getPlayerTime()); + joiner.add(world.getName()); + } + + // The world clock belongs to the global thread on Folia, and the visible time of each player to their own thread + ess.getTaskScheduler().executeGlobal(() -> { + for (final World world : worlds) { + // Capture intended visible time for players with relative ptime before world time changes + final Map ptimePlayers = new HashMap<>(); + for (final Player player : world.getPlayers()) { + if (player.getPlayerTimeOffset() != 0 && player.isPlayerTimeRelative()) { + ptimePlayers.put(player, player.getPlayerTime()); + } } - } - long time = world.getTime(); - if (!add) { - time -= time % 24000; - } - world.setTime(time + (add ? 0 : 24000) + timeTick); + long time = world.getTime(); + if (!addTime) { + time -= time % 24000; + } + world.setTime(time + (addTime ? 0 : 24000) + timeTick); - // Re-apply ptime offsets so players maintain their intended visible time - final long newWorldTime = world.getTime(); - for (final Map.Entry entry : ptimePlayers.entrySet()) { - entry.getKey().setPlayerTime(entry.getValue() - newWorldTime, true); + // Re-apply ptime offsets so players maintain their intended visible time + final long newWorldTime = world.getTime(); + for (final Map.Entry entry : ptimePlayers.entrySet()) { + ess.getTaskScheduler().executeEntity(entry.getKey(), () -> entry.getKey().setPlayerTime(entry.getValue() - newWorldTime, true)); + } } - - joiner.add(world.getName()); - } + }); sender.sendTl(add ? "timeWorldAdd" : "timeWorldSet", DescParseTickFormat.formatTicks(timeTick), joiner.toString()); } diff --git a/Essentials/src/main/java/com/earth2me/essentials/commands/Commandweather.java b/Essentials/src/main/java/com/earth2me/essentials/commands/Commandweather.java index 242f030527..50c71dbef1 100644 --- a/Essentials/src/main/java/com/earth2me/essentials/commands/Commandweather.java +++ b/Essentials/src/main/java/com/earth2me/essentials/commands/Commandweather.java @@ -32,13 +32,16 @@ public void run(final Server server, final User user, final String commandLabel, final World world = user.getWorld(); + // The weather belongs to the global thread on Folia if (args.length > 1) { - world.setStorm(isStorm); - world.setWeatherDuration(Integer.parseInt(args[1]) * 20); + ess.getTaskScheduler().executeGlobal(() -> { + world.setStorm(isStorm); + world.setWeatherDuration(Integer.parseInt(args[1]) * 20); + }); user.sendTl(isStorm ? "weatherStormFor" : "weatherSunFor", world.getName(), args[1]); return; } - world.setStorm(isStorm); + ess.getTaskScheduler().executeGlobal(() -> world.setStorm(isStorm)); user.sendTl(isStorm ? "weatherStorm" : "weatherSun", world.getName()); } @@ -55,12 +58,14 @@ protected void run(final Server server, final CommandSource sender, final String } if (args.length > 2) { - world.setStorm(isStorm); - world.setWeatherDuration(Integer.parseInt(args[2]) * 20); + ess.getTaskScheduler().executeGlobal(() -> { + world.setStorm(isStorm); + world.setWeatherDuration(Integer.parseInt(args[2]) * 20); + }); sender.sendTl(isStorm ? "weatherStormFor" : "weatherSunFor", world.getName(), args[2]); return; } - world.setStorm(isStorm); + ess.getTaskScheduler().executeGlobal(() -> world.setStorm(isStorm)); sender.sendTl(isStorm ? "weatherStorm" : "weatherSun", world.getName()); } diff --git a/Essentials/src/main/java/com/earth2me/essentials/signs/SignTime.java b/Essentials/src/main/java/com/earth2me/essentials/signs/SignTime.java index 0a9c460716..e9fff6c818 100644 --- a/Essentials/src/main/java/com/earth2me/essentials/signs/SignTime.java +++ b/Essentials/src/main/java/com/earth2me/essentials/signs/SignTime.java @@ -43,13 +43,15 @@ protected boolean onSignInteract(final ISign sign, final User player, final Stri long time = player.getWorld().getTime(); time -= time % 24000; if ("§2Day".equalsIgnoreCase(timeString)) { - player.getWorld().setTime(time + 24000); + final long newTime = time + 24000; + ess.getTaskScheduler().executeGlobal(() -> player.getWorld().setTime(newTime)); charge.charge(player); Trade.log("Sign", "TimeDay", "Interact", username, null, username, charge, sign.getBlock().getLocation(), player.getMoney(), ess); return true; } if ("§2Night".equalsIgnoreCase(timeString)) { - player.getWorld().setTime(time + 37700); + final long newTime = time + 37700; + ess.getTaskScheduler().executeGlobal(() -> player.getWorld().setTime(newTime)); charge.charge(player); Trade.log("Sign", "TimeNight", "Interact", username, null, username, charge, sign.getBlock().getLocation(), player.getMoney(), ess); return true; diff --git a/Essentials/src/main/java/com/earth2me/essentials/signs/SignWeather.java b/Essentials/src/main/java/com/earth2me/essentials/signs/SignWeather.java index 88d3e1cdab..78f6088a2b 100644 --- a/Essentials/src/main/java/com/earth2me/essentials/signs/SignWeather.java +++ b/Essentials/src/main/java/com/earth2me/essentials/signs/SignWeather.java @@ -45,13 +45,13 @@ protected boolean onSignInteract(final ISign sign, final User player, final Stri charge.isAffordableFor(player); final String weatherString = sign.getLine(1); if ("§2Sun".equalsIgnoreCase(weatherString)) { - player.getWorld().setStorm(false); + ess.getTaskScheduler().executeGlobal(() -> player.getWorld().setStorm(false)); charge.charge(player); Trade.log("Sign", "WeatherSun", "Interact", username, null, username, charge, sign.getBlock().getLocation(), player.getMoney(), ess); return true; } if ("§2Storm".equalsIgnoreCase(weatherString)) { - player.getWorld().setStorm(true); + ess.getTaskScheduler().executeGlobal(() -> player.getWorld().setStorm(true)); charge.charge(player); Trade.log("Sign", "WeatherStorm", "Interact", username, null, username, charge, sign.getBlock().getLocation(), player.getMoney(), ess); return true; From dfa0baea211f047cc844a3480379b9a6a3347cbd Mon Sep 17 00:00:00 2001 From: Lewis Gibson <12851394+lewisgibson@users.noreply.github.com> Date: Thu, 1 Oct 2026 21:45:00 +0100 Subject: [PATCH 09/13] Schedule the remaining command tasks through the task scheduler - /beezooka and /kittycannon remove their projectile from the thread of the entity. - /skull and /sudo hand their result to the thread of the player it applies to. - /balancetop from a command block runs on the global thread. - /seen runs its lookup asynchronously. - /nyan, which used a BukkitRunnable, plays its tune from a global timer and each note from the thread of the player hearing it. Co-authored-by: Raw2d <255907405+Raw2d@users.noreply.github.com> --- .../commands/Commandbalancetop.java | 4 +-- .../essentials/commands/Commandbeezooka.java | 2 +- .../commands/Commandkittycannon.java | 2 +- .../essentials/commands/Commandseen.java | 4 +-- .../essentials/commands/Commandskull.java | 4 +-- .../essentials/commands/Commandsudo.java | 2 +- .../commands/essentials/NyanCommand.java | 25 ++++++++++++++----- 7 files changed, 28 insertions(+), 15 deletions(-) diff --git a/Essentials/src/main/java/com/earth2me/essentials/commands/Commandbalancetop.java b/Essentials/src/main/java/com/earth2me/essentials/commands/Commandbalancetop.java index e11f8a17ab..b37a7580d9 100644 --- a/Essentials/src/main/java/com/earth2me/essentials/commands/Commandbalancetop.java +++ b/Essentials/src/main/java/com/earth2me/essentials/commands/Commandbalancetop.java @@ -44,7 +44,7 @@ private void outputCache(final CommandSource sender, final int page) { new TextPager(cache).showPage(Integer.toString(page), null, "balancetop", sender); }; if (sender.getSender() instanceof BlockCommandSender) { - ess.scheduleSyncDelayedTask(runnable); + ess.getTaskScheduler().runGlobal(runnable); } else { runnable.run(); } @@ -74,7 +74,7 @@ protected void run(final Server server, final CommandSource sender, final String sender.sendTl("orderBalances", ess.getUsers().getUserCount()); } - ess.runTaskAsynchronously(new Viewer(sender, page, force)); + ess.getTaskScheduler().runAsync(new Viewer(sender, page, force)); } @Override diff --git a/Essentials/src/main/java/com/earth2me/essentials/commands/Commandbeezooka.java b/Essentials/src/main/java/com/earth2me/essentials/commands/Commandbeezooka.java index 19e156f664..dff8109f5b 100644 --- a/Essentials/src/main/java/com/earth2me/essentials/commands/Commandbeezooka.java +++ b/Essentials/src/main/java/com/earth2me/essentials/commands/Commandbeezooka.java @@ -23,7 +23,7 @@ protected void run(final Server server, final User user, final String commandLab final Entity bee = Mob.BEE.spawn(user.getWorld(), server, user.getBase().getEyeLocation()); bee.setVelocity(user.getBase().getEyeLocation().getDirection().multiply(2)); - ess.scheduleSyncDelayedTask(() -> { + ess.getTaskScheduler().runEntity(bee, () -> { final Location loc = bee.getLocation(); bee.remove(); loc.getWorld().createExplosion(loc, 0F); diff --git a/Essentials/src/main/java/com/earth2me/essentials/commands/Commandkittycannon.java b/Essentials/src/main/java/com/earth2me/essentials/commands/Commandkittycannon.java index 18397f82f3..2e32865edc 100644 --- a/Essentials/src/main/java/com/earth2me/essentials/commands/Commandkittycannon.java +++ b/Essentials/src/main/java/com/earth2me/essentials/commands/Commandkittycannon.java @@ -49,7 +49,7 @@ private static Entity spawnCat(final Server server, final User user) throws Mob. @Override protected void run(final Server server, final User user, final String commandLabel, final String[] args) throws Exception { final Entity ocelot = Mob.CAT.getType() == null ? spawnOcelot(server, user) : spawnCat(server, user); - ess.scheduleSyncDelayedTask(() -> { + ess.getTaskScheduler().runEntity(ocelot, () -> { final Location loc = ocelot.getLocation(); ocelot.remove(); loc.getWorld().createExplosion(loc, 0F); diff --git a/Essentials/src/main/java/com/earth2me/essentials/commands/Commandseen.java b/Essentials/src/main/java/com/earth2me/essentials/commands/Commandseen.java index a9a851ad6a..c4a060b8e7 100644 --- a/Essentials/src/main/java/com/earth2me/essentials/commands/Commandseen.java +++ b/Essentials/src/main/java/com/earth2me/essentials/commands/Commandseen.java @@ -61,7 +61,7 @@ protected void run(final Server server, final CommandSource sender, final String return; } } - ess.getScheduler().runTaskAsynchronously(ess, new Runnable() { + ess.getTaskScheduler().runAsync(new Runnable() { @Override public void run() { final User userFromBukkit = ess.getUsers().getUser(args[0]); @@ -203,7 +203,7 @@ private void seenOffline(final CommandSource sender, final User user, final bool private void seenIP(final CommandSource sender, final String ipAddress, final String display) { sender.sendTl("runningPlayerMatch", AdventureUtil.parsed(ess.getAdventureFacet().legacyToMini(display))); - ess.runTaskAsynchronously(() -> { + ess.getTaskScheduler().runAsync(() -> { final List matches = new ArrayList<>(); for (final UUID u : ess.getUsers().getAllUserUUIDs()) { final User user = ess.getUsers().loadUncachedUser(u); diff --git a/Essentials/src/main/java/com/earth2me/essentials/commands/Commandskull.java b/Essentials/src/main/java/com/earth2me/essentials/commands/Commandskull.java index b279b477c5..6103245228 100644 --- a/Essentials/src/main/java/com/earth2me/essentials/commands/Commandskull.java +++ b/Essentials/src/main/java/com/earth2me/essentials/commands/Commandskull.java @@ -106,7 +106,7 @@ protected void run(final Server server, final User user, final String commandLab } private void editSkull(final User user, final User receive, final ItemStack stack, final SkullMeta skullMeta, final String owner, final boolean spawn) { - ess.runTaskAsynchronously(() -> { + ess.getTaskScheduler().runAsync(() -> { // Run this stuff async because it causes an HTTP request String shortOwnerName; @@ -159,7 +159,7 @@ private void editSkull(final User user, final User receive, final ItemStack stac final String shortNameFinal = shortOwnerName; - ess.scheduleSyncDelayedTask(() -> { + ess.getTaskScheduler().runEntity(receive.getBase(), () -> { stack.setItemMeta(skullMeta); if (spawn) { Inventories.addItem(receive.getBase(), stack); diff --git a/Essentials/src/main/java/com/earth2me/essentials/commands/Commandsudo.java b/Essentials/src/main/java/com/earth2me/essentials/commands/Commandsudo.java index 861cf6b302..50212ff41b 100644 --- a/Essentials/src/main/java/com/earth2me/essentials/commands/Commandsudo.java +++ b/Essentials/src/main/java/com/earth2me/essentials/commands/Commandsudo.java @@ -53,7 +53,7 @@ public void run() { } } - ess.scheduleSyncDelayedTask(new SudoCommandTask()); + ess.getTaskScheduler().runEntity(user.getBase(), new SudoCommandTask()); } } } diff --git a/Essentials/src/main/java/com/earth2me/essentials/commands/essentials/NyanCommand.java b/Essentials/src/main/java/com/earth2me/essentials/commands/essentials/NyanCommand.java index 9e42fc503e..44ba15d4af 100644 --- a/Essentials/src/main/java/com/earth2me/essentials/commands/essentials/NyanCommand.java +++ b/Essentials/src/main/java/com/earth2me/essentials/commands/essentials/NyanCommand.java @@ -4,9 +4,9 @@ import com.earth2me.essentials.commands.EssentialsTreeNode; import com.earth2me.essentials.utils.RegistryUtil; import com.google.common.collect.ImmutableMap; +import net.ess3.provider.TaskSchedulerProvider; import org.bukkit.Sound; import org.bukkit.entity.Player; -import org.bukkit.scheduler.BukkitRunnable; import java.util.Collection; import java.util.Map; @@ -28,11 +28,11 @@ protected void run(final CommandSource sender, final String commandLabel, final currentTune.cancel(); } - currentTune = new TuneRunnable(NYAN_TUNE, NOTE_HARP, ess::getOnlinePlayers); - currentTune.runTaskTimer(ess, 20, 2); + currentTune = new TuneRunnable(ess.getTaskScheduler(), NYAN_TUNE, NOTE_HARP, ess::getOnlinePlayers); + currentTune.start(); } - private static class TuneRunnable extends BukkitRunnable { + private static class TuneRunnable implements Runnable { private static final Map noteMap = ImmutableMap.builder() .put("1F#", 0.5f) .put("1G", 0.53f) @@ -60,17 +60,30 @@ private static class TuneRunnable extends BukkitRunnable { .put("2F", 1.88f) .build(); + private final TaskSchedulerProvider scheduler; private final String[] tune; private final Sound sound; private final Supplier> players; private int i = 0; + private volatile TaskSchedulerProvider.Task task; - TuneRunnable(final String tuneStr, final Sound sound, final Supplier> players) { + TuneRunnable(final TaskSchedulerProvider scheduler, final String tuneStr, final Sound sound, final Supplier> players) { + this.scheduler = scheduler; this.tune = tuneStr.split(","); this.sound = sound; this.players = players; } + void start() { + task = scheduler.runGlobalTimer(this, 20, 2); + } + + void cancel() { + if (task != null) { + task.cancel(); + } + } + @Override public void run() { final String note = tune[i]; @@ -83,7 +96,7 @@ public void run() { } for (final Player onlinePlayer : players.get()) { - onlinePlayer.playSound(onlinePlayer.getLocation(), sound, 1, noteMap.get(note)); + scheduler.executeEntity(onlinePlayer, () -> onlinePlayer.playSound(onlinePlayer.getLocation(), sound, 1, noteMap.get(note))); } } } From 229905abda181e75f39ef95b3858345f80aa7136 Mon Sep 17 00:00:00 2001 From: Lewis Gibson <12851394+lewisgibson@users.noreply.github.com> Date: Thu, 1 Oct 2026 21:45:00 +0100 Subject: [PATCH 10/13] Schedule EssentialsSpawn tasks on the player's thread Teleporting a new player to the newbie spawn, sending a returning player to the spawn of their group, and announcing and kitting a new player all run on the thread of that player. Co-authored-by: Raw2d <255907405+Raw2d@users.noreply.github.com> --- .../essentials/spawn/EssentialsSpawnPlayerListener.java | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/EssentialsSpawn/src/main/java/com/earth2me/essentials/spawn/EssentialsSpawnPlayerListener.java b/EssentialsSpawn/src/main/java/com/earth2me/essentials/spawn/EssentialsSpawnPlayerListener.java index 3007ba8160..dd2e7d837c 100644 --- a/EssentialsSpawn/src/main/java/com/earth2me/essentials/spawn/EssentialsSpawnPlayerListener.java +++ b/EssentialsSpawn/src/main/java/com/earth2me/essentials/spawn/EssentialsSpawnPlayerListener.java @@ -90,7 +90,7 @@ private void delayedJoin(final Player player, final boolean firstJoin) { final User user = ess.getUser(player); if (ess.getSettings().isUserInSpawnOnJoinGroup(user) && !user.isAuthorized("essentials.spawn-on-join.exempt")) { - ess.scheduleSyncDelayedTask(() -> { + ess.getTaskScheduler().runEntity(player, () -> { final Location spawn = spawns.getSpawn(user.getGroup()); if (spawn == null) { return; @@ -113,10 +113,10 @@ private void delayedJoin(final Player player, final boolean firstJoin) { final boolean spawnRandomly = tryRandomTeleport(user, ess.getSettings().getRandomSpawnLocation()); if (!spawnRandomly && !"none".equalsIgnoreCase(ess.getSettings().getNewbieSpawn())) { - ess.scheduleSyncDelayedTask(new NewPlayerTeleport(user), 1L); + ess.getTaskScheduler().runEntity(player, new NewPlayerTeleport(user), 1L); } - ess.scheduleSyncDelayedTask(() -> { + ess.getTaskScheduler().runEntity(player, () -> { if (!user.getBase().isOnline()) { return; } From 406f0e6ae455e04a5f53edc0b44d81bac0077527 Mon Sep 17 00:00:00 2001 From: Lewis Gibson <12851394+lewisgibson@users.noreply.github.com> Date: Thu, 1 Oct 2026 21:45:00 +0100 Subject: [PATCH 11/13] Schedule EssentialsXDiscord and DiscordLink tasks through the task scheduler - Discord messages, commands run from Discord and slash commands run on the global thread, and chat relayed to Discord on the thread of the player who sent it. - The console relay and the command response buffer tick asynchronously. - Account link status events are called from the global thread, and a player kicked for not being linked is kicked from their own thread. - Role sync ticks asynchronously. Co-authored-by: Raw2d <255907405+Raw2d@users.noreply.github.com> --- .../net/essentialsx/discord/EssentialsDiscord.java | 2 +- .../net/essentialsx/discord/JDADiscordService.java | 8 ++------ .../interactions/InteractionControllerImpl.java | 2 +- .../interactions/commands/ExecuteCommand.java | 2 +- .../interactions/commands/MessageCommand.java | 2 +- .../discord/listeners/BukkitChatListener.java | 2 +- .../listeners/DiscordCommandDispatcher.java | 2 +- .../discord/listeners/EssentialsChatListener.java | 2 +- .../discord/listeners/PaperChatListener.java | 2 +- .../essentialsx/discord/util/ConsoleInjector.java | 10 +++++----- .../discord/util/DiscordCommandSender.java | 7 +++---- .../net/essentialsx/discord/util/DiscordUtil.java | 6 +----- .../discordlink/AccountLinkManager.java | 8 ++------ .../discordlink/listeners/LinkBukkitListener.java | 7 +------ .../discordlink/rolesync/RoleSyncManager.java | 14 +++++++------- 15 files changed, 29 insertions(+), 47 deletions(-) diff --git a/EssentialsDiscord/src/main/java/net/essentialsx/discord/EssentialsDiscord.java b/EssentialsDiscord/src/main/java/net/essentialsx/discord/EssentialsDiscord.java index 47c19f411b..0503c3f480 100644 --- a/EssentialsDiscord/src/main/java/net/essentialsx/discord/EssentialsDiscord.java +++ b/EssentialsDiscord/src/main/java/net/essentialsx/discord/EssentialsDiscord.java @@ -60,7 +60,7 @@ public void onEnable() { jda = new JDADiscordService(this); try { jda.startup(); - ess.scheduleSyncDelayedTask(() -> ((InteractionControllerImpl) jda.getInteractionController()).processBatchRegistration()); + ess.getTaskScheduler().runGlobal(() -> ((InteractionControllerImpl) jda.getInteractionController()).processBatchRegistration()); } catch (Exception e) { getLogger().log(Level.SEVERE, ess.getAdventureFacet().miniToLegacy(tlLiteral("discordErrorLogin", e.getMessage()))); if (ess.getSettings().isDebug()) { diff --git a/EssentialsDiscord/src/main/java/net/essentialsx/discord/JDADiscordService.java b/EssentialsDiscord/src/main/java/net/essentialsx/discord/JDADiscordService.java index dc8bdd2aba..223a0b0b8a 100644 --- a/EssentialsDiscord/src/main/java/net/essentialsx/discord/JDADiscordService.java +++ b/EssentialsDiscord/src/main/java/net/essentialsx/discord/JDADiscordService.java @@ -260,7 +260,7 @@ public void startup() throws LoginException, InterruptedException { logger.log(Level.WARNING, "Error while loading the achievement/advancement listener. You will not receive achievement/advancement notifications on Discord.", e); } - getPlugin().getEss().scheduleSyncDelayedTask(() -> DiscordUtil.dispatchDiscordMessage(JDADiscordService.this, MessageType.DefaultTypes.SERVER_START, getSettings().getStartMessage(), true, null, null, null)); + getPlugin().getEss().getTaskScheduler().runGlobal(() -> DiscordUtil.dispatchDiscordMessage(JDADiscordService.this, MessageType.DefaultTypes.SERVER_START, getSettings().getStartMessage(), true, null, null, null)); Bukkit.getServicesManager().register(DiscordService.class, this, plugin, ServicePriority.Normal); } @@ -289,11 +289,7 @@ public void sendMessage(MessageType type, String message, boolean allowGroupMent logger.warning("Sending message to channel \"" + type.getKey() + "\" which is an unregistered type! If you are a plugin author, you should be registering your MessageType before using them."); } final DiscordMessageEvent event = new DiscordMessageEvent(type, FormatUtil.stripFormat(message), allowGroupMentions); - if (Bukkit.getServer().isPrimaryThread()) { - Bukkit.getPluginManager().callEvent(event); - } else { - Bukkit.getScheduler().runTask(plugin, () -> Bukkit.getPluginManager().callEvent(event)); - } + getPlugin().getEss().getTaskScheduler().executeGlobal(() -> Bukkit.getPluginManager().callEvent(event)); } @Override diff --git a/EssentialsDiscord/src/main/java/net/essentialsx/discord/interactions/InteractionControllerImpl.java b/EssentialsDiscord/src/main/java/net/essentialsx/discord/interactions/InteractionControllerImpl.java index 2658159721..2d5a106451 100644 --- a/EssentialsDiscord/src/main/java/net/essentialsx/discord/interactions/InteractionControllerImpl.java +++ b/EssentialsDiscord/src/main/java/net/essentialsx/discord/interactions/InteractionControllerImpl.java @@ -64,7 +64,7 @@ public void onSlashCommandInteraction(@NotNull SlashCommandInteractionEvent even interactionEvent.replyTl("noAccessCommand"); return; } - jda.getPlugin().getEss().scheduleSyncDelayedTask(() -> command.onCommand(interactionEvent)); + jda.getPlugin().getEss().getTaskScheduler().runGlobal(() -> command.onCommand(interactionEvent)); } @Override diff --git a/EssentialsDiscord/src/main/java/net/essentialsx/discord/interactions/commands/ExecuteCommand.java b/EssentialsDiscord/src/main/java/net/essentialsx/discord/interactions/commands/ExecuteCommand.java index 09e1a9fad8..27050092e2 100644 --- a/EssentialsDiscord/src/main/java/net/essentialsx/discord/interactions/commands/ExecuteCommand.java +++ b/EssentialsDiscord/src/main/java/net/essentialsx/discord/interactions/commands/ExecuteCommand.java @@ -22,7 +22,7 @@ public ExecuteCommand(JDADiscordService jda) { public void onCommand(final InteractionEvent event) { final String command = event.getStringArgument("command"); event.replyTl("discordCommandExecuteReply", command); - Bukkit.getScheduler().runTask(jda.getPlugin(), () -> { + jda.getPlugin().getEss().getTaskScheduler().runGlobal(() -> { try { Bukkit.dispatchCommand(new DiscordCommandSender(jda, Bukkit.getConsoleSender(), message -> event.reply(MessageUtil.sanitizeDiscordMarkdown(message))).getSender(), command); } catch (CommandException e) { diff --git a/EssentialsDiscord/src/main/java/net/essentialsx/discord/interactions/commands/MessageCommand.java b/EssentialsDiscord/src/main/java/net/essentialsx/discord/interactions/commands/MessageCommand.java index f0e7ceec73..b0ec9eaa99 100644 --- a/EssentialsDiscord/src/main/java/net/essentialsx/discord/interactions/commands/MessageCommand.java +++ b/EssentialsDiscord/src/main/java/net/essentialsx/discord/interactions/commands/MessageCommand.java @@ -55,7 +55,7 @@ public void onCommand(InteractionEvent event) { user.sendTl("msgFormat", event.getMember().getName(), AdventureUtil.parsed(user.playerTl("meRecipient")), message); // We use an atomic reference here so that java will garbage collect the recipient final AtomicReference ref = new AtomicReference<>(new DiscordMessageRecipient(event.getMember())); - jda.getPlugin().getEss().runTaskLaterAsynchronously(() -> ref.set(null), 6000); // Expires after 5 minutes + jda.getPlugin().getEss().getTaskScheduler().runAsync(() -> ref.set(null), 6000); // Expires after 5 minutes user.setReplyRecipient(ref.get()); } } diff --git a/EssentialsDiscord/src/main/java/net/essentialsx/discord/listeners/BukkitChatListener.java b/EssentialsDiscord/src/main/java/net/essentialsx/discord/listeners/BukkitChatListener.java index 2ae1c91e0e..393aaf8aa3 100644 --- a/EssentialsDiscord/src/main/java/net/essentialsx/discord/listeners/BukkitChatListener.java +++ b/EssentialsDiscord/src/main/java/net/essentialsx/discord/listeners/BukkitChatListener.java @@ -20,7 +20,7 @@ public BukkitChatListener(JDADiscordService jda) { @EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true) public void onChat(AsyncPlayerChatEvent event) { final Player player = event.getPlayer(); - Bukkit.getScheduler().runTask(jda.getPlugin(), () -> { + jda.getPlugin().getEss().getTaskScheduler().runEntity(player, () -> { final DiscordChatMessageEvent chatEvent = new DiscordChatMessageEvent(event.getPlayer(), event.getMessage(), ChatType.UNKNOWN); chatEvent.setCancelled(!jda.getSettings().isShowAllChat() && !event.getRecipients().containsAll(Bukkit.getOnlinePlayers())); Bukkit.getPluginManager().callEvent(chatEvent); diff --git a/EssentialsDiscord/src/main/java/net/essentialsx/discord/listeners/DiscordCommandDispatcher.java b/EssentialsDiscord/src/main/java/net/essentialsx/discord/listeners/DiscordCommandDispatcher.java index c735aa7bdc..8a1e81f41e 100644 --- a/EssentialsDiscord/src/main/java/net/essentialsx/discord/listeners/DiscordCommandDispatcher.java +++ b/EssentialsDiscord/src/main/java/net/essentialsx/discord/listeners/DiscordCommandDispatcher.java @@ -30,7 +30,7 @@ public void onMessageReceived(@NotNull MessageReceivedEvent event) { } final String command = event.getMessage().getContentRaw(); - Bukkit.getScheduler().runTask(jda.getPlugin(), () -> { + jda.getPlugin().getEss().getTaskScheduler().runGlobal(() -> { try { Bukkit.dispatchCommand(new DiscordCommandSender(jda, Bukkit.getConsoleSender(), message -> event.getMessage().reply(message).queue()).getSender(), command); diff --git a/EssentialsDiscord/src/main/java/net/essentialsx/discord/listeners/EssentialsChatListener.java b/EssentialsDiscord/src/main/java/net/essentialsx/discord/listeners/EssentialsChatListener.java index debd6783df..6b11b87ecf 100644 --- a/EssentialsDiscord/src/main/java/net/essentialsx/discord/listeners/EssentialsChatListener.java +++ b/EssentialsDiscord/src/main/java/net/essentialsx/discord/listeners/EssentialsChatListener.java @@ -31,7 +31,7 @@ public void onGlobalChat(GlobalChatEvent event) { private void processChatEvent(ChatEvent event) { final Player player = event.getPlayer(); - Bukkit.getScheduler().runTask(jda.getPlugin(), () -> { + jda.getPlugin().getEss().getTaskScheduler().runEntity(player, () -> { final DiscordChatMessageEvent chatEvent = new DiscordChatMessageEvent(event.getPlayer(), event.getMessage(), event.getChatType()); Bukkit.getPluginManager().callEvent(chatEvent); diff --git a/EssentialsDiscord/src/main/java/net/essentialsx/discord/listeners/PaperChatListener.java b/EssentialsDiscord/src/main/java/net/essentialsx/discord/listeners/PaperChatListener.java index ed178ba6b2..c42201da0d 100644 --- a/EssentialsDiscord/src/main/java/net/essentialsx/discord/listeners/PaperChatListener.java +++ b/EssentialsDiscord/src/main/java/net/essentialsx/discord/listeners/PaperChatListener.java @@ -23,7 +23,7 @@ public void onChatMonitor(AbstractChatEvent event) { } final Player player = event.getPlayer(); - Bukkit.getScheduler().runTask(jda.getPlugin(), () -> { + jda.getPlugin().getEss().getTaskScheduler().runEntity(player, () -> { final DiscordChatMessageEvent chatEvent = new DiscordChatMessageEvent(event.getPlayer(), event.getMessage(), ChatType.UNKNOWN); chatEvent.setCancelled(!jda.getSettings().isShowAllChat() && !event.recipients().containsAll(Bukkit.getOnlinePlayers())); Bukkit.getPluginManager().callEvent(chatEvent); diff --git a/EssentialsDiscord/src/main/java/net/essentialsx/discord/util/ConsoleInjector.java b/EssentialsDiscord/src/main/java/net/essentialsx/discord/util/ConsoleInjector.java index 266fee0293..d87700b41a 100644 --- a/EssentialsDiscord/src/main/java/net/essentialsx/discord/util/ConsoleInjector.java +++ b/EssentialsDiscord/src/main/java/net/essentialsx/discord/util/ConsoleInjector.java @@ -4,6 +4,7 @@ import com.google.common.base.Splitter; import net.dv8tion.jda.api.entities.Message; import net.dv8tion.jda.api.utils.TimeFormat; +import net.ess3.provider.TaskSchedulerProvider; import net.essentialsx.discord.EssentialsDiscord; import net.essentialsx.discord.JDADiscordService; import org.apache.logging.log4j.LogManager; @@ -11,7 +12,6 @@ import org.apache.logging.log4j.core.Logger; import org.apache.logging.log4j.core.appender.AbstractAppender; import org.apache.logging.log4j.core.config.plugins.Plugin; -import org.bukkit.Bukkit; import java.time.Instant; import java.util.concurrent.ArrayBlockingQueue; @@ -29,14 +29,14 @@ public class ConsoleInjector extends AbstractAppender { private final JDADiscordService jda; private final BlockingQueue messageQueue = new ArrayBlockingQueue<>(QUEUE_CAPACITY); - private final int taskId; + private final TaskSchedulerProvider.Task task; private boolean removed = false; public ConsoleInjector(JDADiscordService jda) { super("EssentialsX-ConsoleInjector", null, null, false); this.jda = jda; ((Logger) LogManager.getRootLogger()).addAppender(this); - taskId = Bukkit.getScheduler().runTaskTimerAsynchronously(jda.getPlugin(), () -> { + task = jda.getPlugin().getEss().getTaskScheduler().runAsyncTimer(() -> { final StringBuilder buffer = new StringBuilder(); String curLine; while ((curLine = messageQueue.peek()) != null) { @@ -50,7 +50,7 @@ public ConsoleInjector(JDADiscordService jda) { if (buffer.length() != 0) { sendMessage(buffer.toString()); } - }, 20, 20 * QUEUE_PROCESS_PERIOD_SECONDS).getTaskId(); + }, 20, 20 * QUEUE_PROCESS_PERIOD_SECONDS); } private void sendMessage(String content) { @@ -108,7 +108,7 @@ public void append(LogEvent event) { public void remove() { ((Logger) LogManager.getRootLogger()).removeAppender(this); - Bukkit.getScheduler().cancelTask(taskId); + task.cancel(); messageQueue.clear(); if (jda.getConsoleWebhook() != null && !jda.getConsoleWebhook().isShutdown()) { jda.getConsoleWebhook().close(); diff --git a/EssentialsDiscord/src/main/java/net/essentialsx/discord/util/DiscordCommandSender.java b/EssentialsDiscord/src/main/java/net/essentialsx/discord/util/DiscordCommandSender.java index f8389c9e43..0a38195355 100644 --- a/EssentialsDiscord/src/main/java/net/essentialsx/discord/util/DiscordCommandSender.java +++ b/EssentialsDiscord/src/main/java/net/essentialsx/discord/util/DiscordCommandSender.java @@ -2,17 +2,16 @@ import com.earth2me.essentials.utils.FormatUtil; import com.earth2me.essentials.utils.VersionUtil; +import net.ess3.provider.TaskSchedulerProvider; import net.ess3.provider.providers.BukkitSenderProvider; import net.ess3.provider.providers.PaperCommandSender; import net.essentialsx.discord.JDADiscordService; -import org.bukkit.Bukkit; import org.bukkit.command.CommandSender; import org.bukkit.command.ConsoleCommandSender; -import org.bukkit.scheduler.BukkitTask; public class DiscordCommandSender { private final CommandSender sender; - private BukkitTask task; + private TaskSchedulerProvider.Task task; private String responseBuffer = ""; private long lastTime = System.currentTimeMillis(); @@ -23,7 +22,7 @@ public DiscordCommandSender(JDADiscordService jda, ConsoleCommandSender sender, }; this.sender = getCustomSender(sender, hook); - task = Bukkit.getScheduler().runTaskTimerAsynchronously(jda.getPlugin(), () -> { + task = jda.getPlugin().getEss().getTaskScheduler().runAsyncTimer(() -> { if (!responseBuffer.isEmpty() && System.currentTimeMillis() - lastTime >= 1000) { callback.onMessage(responseBuffer); responseBuffer = ""; diff --git a/EssentialsDiscord/src/main/java/net/essentialsx/discord/util/DiscordUtil.java b/EssentialsDiscord/src/main/java/net/essentialsx/discord/util/DiscordUtil.java index 8686e65c4c..63069d344b 100644 --- a/EssentialsDiscord/src/main/java/net/essentialsx/discord/util/DiscordUtil.java +++ b/EssentialsDiscord/src/main/java/net/essentialsx/discord/util/DiscordUtil.java @@ -245,10 +245,6 @@ public static void dispatchDiscordMessage(final JDADiscordService jda, final Mes return; } - if (Bukkit.getServer().isPrimaryThread()) { - Bukkit.getPluginManager().callEvent(event); - } else { - Bukkit.getScheduler().runTask(jda.getPlugin(), () -> Bukkit.getPluginManager().callEvent(event)); - } + jda.getPlugin().getEss().getTaskScheduler().executeGlobal(() -> Bukkit.getPluginManager().callEvent(event)); } } diff --git a/EssentialsDiscordLink/src/main/java/net/essentialsx/discordlink/AccountLinkManager.java b/EssentialsDiscordLink/src/main/java/net/essentialsx/discordlink/AccountLinkManager.java index 930bdb2ff2..1f3f281b43 100644 --- a/EssentialsDiscordLink/src/main/java/net/essentialsx/discordlink/AccountLinkManager.java +++ b/EssentialsDiscordLink/src/main/java/net/essentialsx/discordlink/AccountLinkManager.java @@ -133,11 +133,7 @@ public void registerAccount(final UUID uuid, final InteractionMember member, fin } private void ensureSync(final Runnable runnable) { - if (ess.getServer().isPrimaryThread()) { - runnable.run(); - return; - } - ess.getEss().scheduleSyncDelayedTask(runnable); + ess.getEss().getTaskScheduler().executeGlobal(runnable); } private void ensureAsync(final Runnable runnable) { @@ -145,7 +141,7 @@ private void ensureAsync(final Runnable runnable) { runnable.run(); return; } - ess.getEss().runTaskAsynchronously(runnable); + ess.getEss().getTaskScheduler().runAsync(runnable); } private String generateCode() { diff --git a/EssentialsDiscordLink/src/main/java/net/essentialsx/discordlink/listeners/LinkBukkitListener.java b/EssentialsDiscordLink/src/main/java/net/essentialsx/discordlink/listeners/LinkBukkitListener.java index 959a30adf3..daaa8cd5b7 100644 --- a/EssentialsDiscordLink/src/main/java/net/essentialsx/discordlink/listeners/LinkBukkitListener.java +++ b/EssentialsDiscordLink/src/main/java/net/essentialsx/discordlink/listeners/LinkBukkitListener.java @@ -9,7 +9,6 @@ import net.essentialsx.discord.util.MessageUtil; import net.essentialsx.discordlink.DiscordLinkSettings; import net.essentialsx.discordlink.EssentialsDiscordLink; -import org.bukkit.Bukkit; import org.bukkit.event.EventHandler; import org.bukkit.event.EventPriority; import org.bukkit.event.Listener; @@ -160,11 +159,7 @@ public void onUserLinkStatusChange(final DiscordLinkStatusChangeEvent event) { switch (ess.getSettings().getLinkPolicy()) { case KICK: { final Runnable kickTask = () -> event.getUser().getBase().kickPlayer(ess.getEss().getAdventureFacet().miniToLegacy(event.getUser().playerTl("discordLinkLoginKick", "/link " + finalCode, ess.getApi().getInviteUrl()))); - if (Bukkit.isPrimaryThread()) { - kickTask.run(); - } else { - ess.getEss().scheduleSyncDelayedTask(kickTask); - } + ess.getEss().getTaskScheduler().executeEntity(event.getUser().getBase(), kickTask); break; } case FREEZE: { diff --git a/EssentialsDiscordLink/src/main/java/net/essentialsx/discordlink/rolesync/RoleSyncManager.java b/EssentialsDiscordLink/src/main/java/net/essentialsx/discordlink/rolesync/RoleSyncManager.java index 72353afb73..13ea0897da 100644 --- a/EssentialsDiscordLink/src/main/java/net/essentialsx/discordlink/rolesync/RoleSyncManager.java +++ b/EssentialsDiscordLink/src/main/java/net/essentialsx/discordlink/rolesync/RoleSyncManager.java @@ -2,6 +2,7 @@ import com.earth2me.essentials.UUIDPlayer; import com.google.common.collect.BiMap; +import net.ess3.provider.TaskSchedulerProvider; import net.essentialsx.api.v2.events.discordlink.DiscordLinkStatusChangeEvent; import net.essentialsx.api.v2.services.discord.InteractionRole; import net.essentialsx.discordlink.EssentialsDiscordLink; @@ -10,7 +11,6 @@ import org.bukkit.event.EventHandler; import org.bukkit.event.Listener; import org.bukkit.event.player.PlayerJoinEvent; -import org.bukkit.scheduler.BukkitTask; import java.util.ArrayList; import java.util.Collections; @@ -30,14 +30,14 @@ public class RoleSyncManager implements Listener { private final Map groupToRoleMap = new HashMap<>(); private final Map roleIdToGroupMap = new HashMap<>(); private final Semaphore syncSemaphore = new Semaphore(5); - private BukkitTask syncTask; + private TaskSchedulerProvider.Task syncTask; private int syncCursor = 0; public RoleSyncManager(final EssentialsDiscordLink ess) { this.ess = ess; Bukkit.getPluginManager().registerEvents(this, ess); onReload(); - this.syncTask = this.ess.getEss().runTaskTimerAsynchronously(() -> { + this.syncTask = this.ess.getEss().getTaskScheduler().runAsyncTimer(() -> { if (groupToRoleMap.isEmpty() && roleIdToGroupMap.isEmpty()) { return; } @@ -89,7 +89,7 @@ public void sync(final Player player, final String discordId, final Map groups = primaryOnly ? Collections.singletonList(ess.getEss().getPermissionsHandler().getGroup(player)) : ess.getEss().getPermissionsHandler().getGroups(player); - ess.getEss().runTaskAsynchronously(() -> { + ess.getEss().getTaskScheduler().runAsync(() -> { try { if (!syncSemaphore.tryAcquire(5, TimeUnit.SECONDS)) { return; @@ -102,7 +102,7 @@ public void sync(final Player player, final String discordId, final Map unSync(player.getUniqueId(), discordId)); + ess.getEss().getTaskScheduler().runAsync(() -> unSync(player.getUniqueId(), discordId)); } return CompletableFuture.completedFuture(null); } @@ -165,7 +165,7 @@ public void unSync(final UUID uuid, final String discordId) { return; } - ess.getEss().runTaskAsynchronously(() -> { + ess.getEss().getTaskScheduler().runAsync(() -> { try { if (!syncSemaphore.tryAcquire(5, TimeUnit.SECONDS)) { return; @@ -191,7 +191,7 @@ public void unSync(final UUID uuid, final String discordId) { @EventHandler public void onJoin(PlayerJoinEvent event) { - ess.getEss().runTaskAsynchronously(() -> { + ess.getEss().getTaskScheduler().runAsync(() -> { if (ess.getLinkManager().isLinked(event.getPlayer().getUniqueId())) { sync(event.getPlayer().getUniqueId(), ess.getLinkManager().getDiscordId(event.getPlayer().getUniqueId())); } From 2abb74af3d5cf7bf2aeadb9d1fe9f541c3dcc026 Mon Sep 17 00:00:00 2001 From: Lewis Gibson <12851394+lewisgibson@users.noreply.github.com> Date: Thu, 1 Oct 2026 21:45:00 +0100 Subject: [PATCH 12/13] Schedule EssentialsXXMPP tasks on the global thread Presence updates, relayed messages and commands received over XMPP run on the global thread. Co-authored-by: Raw2d <255907405+Raw2d@users.noreply.github.com> --- .../java/com/earth2me/essentials/xmpp/EssentialsXMPP.java | 2 +- .../essentials/xmpp/EssentialsXMPPPlayerListener.java | 7 +++---- .../java/com/earth2me/essentials/xmpp/XMPPManager.java | 7 +++++-- 3 files changed, 9 insertions(+), 7 deletions(-) diff --git a/EssentialsXMPP/src/main/java/com/earth2me/essentials/xmpp/EssentialsXMPP.java b/EssentialsXMPP/src/main/java/com/earth2me/essentials/xmpp/EssentialsXMPP.java index e9a6216e7b..1d492d2e8b 100644 --- a/EssentialsXMPP/src/main/java/com/earth2me/essentials/xmpp/EssentialsXMPP.java +++ b/EssentialsXMPP/src/main/java/com/earth2me/essentials/xmpp/EssentialsXMPP.java @@ -52,7 +52,7 @@ public void onEnable() { pluginManager.registerEvents(playerListener, this); users = new UserManager(this.getDataFolder()); - xmpp = new XMPPManager(this); + xmpp = new XMPPManager(this, ess.getTaskScheduler()); ess.addReloadListener(users); ess.addReloadListener(xmpp); diff --git a/EssentialsXMPP/src/main/java/com/earth2me/essentials/xmpp/EssentialsXMPPPlayerListener.java b/EssentialsXMPP/src/main/java/com/earth2me/essentials/xmpp/EssentialsXMPPPlayerListener.java index 7bb5914631..f227b82e7c 100644 --- a/EssentialsXMPP/src/main/java/com/earth2me/essentials/xmpp/EssentialsXMPPPlayerListener.java +++ b/EssentialsXMPP/src/main/java/com/earth2me/essentials/xmpp/EssentialsXMPPPlayerListener.java @@ -2,7 +2,6 @@ import com.earth2me.essentials.IEssentials; import com.earth2me.essentials.User; -import org.bukkit.Bukkit; import org.bukkit.event.EventHandler; import org.bukkit.event.EventPriority; import org.bukkit.event.Listener; @@ -24,7 +23,7 @@ class EssentialsXMPPPlayerListener implements Listener { public void onPlayerJoin(final PlayerJoinEvent event) { final User user = ess.getUser(event.getPlayer()); - Bukkit.getScheduler().scheduleSyncDelayedTask(ess, EssentialsXMPP::updatePresence); + ess.getTaskScheduler().runGlobal(EssentialsXMPP::updatePresence); sendMessageToSpyUsers("Player " + user.getDisplayName() + " joined the game"); } @@ -39,7 +38,7 @@ public void onPlayerChat(final AsyncPlayerChatEvent event) { public void onPlayerQuit(final PlayerQuitEvent event) { final User user = ess.getUser(event.getPlayer()); - Bukkit.getScheduler().scheduleSyncDelayedTask(ess, EssentialsXMPP::updatePresence); + ess.getTaskScheduler().runGlobal(EssentialsXMPP::updatePresence); sendMessageToSpyUsers("Player " + user.getDisplayName() + " left the game"); } @@ -49,7 +48,7 @@ private void sendMessageToSpyUsers(final String message) { final List users = EssentialsXMPP.getInstance().getSpyUsers(); synchronized (users) { for (final String address : users) { - Bukkit.getScheduler().scheduleSyncDelayedTask(ess, () -> EssentialsXMPP.getInstance().sendMessage(address, message)); + ess.getTaskScheduler().runGlobal(() -> EssentialsXMPP.getInstance().sendMessage(address, message)); } } } catch (final Exception ignored) { diff --git a/EssentialsXMPP/src/main/java/com/earth2me/essentials/xmpp/XMPPManager.java b/EssentialsXMPP/src/main/java/com/earth2me/essentials/xmpp/XMPPManager.java index 0530d4974c..5825f13e98 100644 --- a/EssentialsXMPP/src/main/java/com/earth2me/essentials/xmpp/XMPPManager.java +++ b/EssentialsXMPP/src/main/java/com/earth2me/essentials/xmpp/XMPPManager.java @@ -5,6 +5,7 @@ import com.earth2me.essentials.config.EssentialsConfiguration; import com.earth2me.essentials.utils.FormatUtil; import net.ess3.api.IUser; +import net.ess3.provider.TaskSchedulerProvider; import org.bukkit.entity.Player; import org.jivesoftware.smack.Chat; import org.jivesoftware.smack.ChatManager; @@ -41,6 +42,7 @@ public class XMPPManager extends Handler implements MessageListener, ChatManager private final transient Map chats = Collections.synchronizedMap(new HashMap<>()); private final transient Set logrecords = Collections.synchronizedSet(new HashSet<>()); private final transient IEssentialsXMPP parent; + private final transient TaskSchedulerProvider scheduler; private transient XMPPConnection connection; private transient ChatManager chatManager; private transient List logUsers; @@ -49,9 +51,10 @@ public class XMPPManager extends Handler implements MessageListener, ChatManager private transient Thread loggerThread; private transient boolean threadrunning = true; - XMPPManager(final IEssentialsXMPP parent) { + XMPPManager(final IEssentialsXMPP parent, final TaskSchedulerProvider scheduler) { super(); this.parent = parent; + this.scheduler = scheduler; config = new EssentialsConfiguration(new File(parent.getDataFolder(), "config.yml"), "/config.yml", EssentialsXMPP.class); reloadConfig(); } @@ -328,7 +331,7 @@ private void sendPrivateMessage(final Chat chat, final String message) { private void sendCommand(final Chat chat, final String message) { if (config.getList("op-users", String.class).contains(StringUtils.parseBareAddress(chat.getParticipant()))) { - parent.getServer().getScheduler().runTask(parent, () -> { + scheduler.runGlobal(() -> { try { parent.getServer().dispatchCommand(Console.getInstance().getCommandSender(), message.substring(1)); } catch (final Exception ex) { From 0646e78c9c1ce24726b6938b56accfa88310fe1f Mon Sep 17 00:00:00 2001 From: Lewis Gibson <12851394+lewisgibson@users.noreply.github.com> Date: Thu, 1 Oct 2026 21:45:00 +0100 Subject: [PATCH 13/13] Declare Folia support Folia only loads plugins that set folia-supported in their plugin.yml. Set it on every EssentialsX module, now that all of their scheduled work and thread-bound access goes through the task scheduler, and mention Folia as a supported server in the README. Co-authored-by: Raw2d <255907405+Raw2d@users.noreply.github.com> --- Essentials/src/main/resources/plugin.yml | 1 + EssentialsAntiBuild/src/main/resources/plugin.yml | 1 + EssentialsChat/src/main/resources/plugin.yml | 1 + EssentialsDiscord/src/main/resources/plugin.yml | 1 + EssentialsDiscordLink/src/main/resources/plugin.yml | 1 + EssentialsGeoIP/src/main/resources/plugin.yml | 1 + EssentialsProtect/src/main/resources/plugin.yml | 1 + EssentialsSpawn/src/main/resources/plugin.yml | 1 + EssentialsXMPP/src/main/resources/plugin.yml | 1 + README.md | 4 ++-- 10 files changed, 11 insertions(+), 2 deletions(-) diff --git a/Essentials/src/main/resources/plugin.yml b/Essentials/src/main/resources/plugin.yml index 68496ebbbd..125f0cd15b 100644 --- a/Essentials/src/main/resources/plugin.yml +++ b/Essentials/src/main/resources/plugin.yml @@ -7,6 +7,7 @@ description: Provides an essential, core set of commands for Bukkit. softdepend: [Vault, LuckPerms] authors: [Zenexer, ementalo, Aelux, Brettflan, KimKandor, snowleo, ceulemans, Xeology, KHobbits, md_5, Iaccidentally, drtshock, vemacs, SupaHam, mdcfe, JRoy, pop4959] api-version: "1.13" +folia-supported: true commands: afk: description: Marks you as away-from-keyboard. diff --git a/EssentialsAntiBuild/src/main/resources/plugin.yml b/EssentialsAntiBuild/src/main/resources/plugin.yml index bbfd21dd70..353940add3 100644 --- a/EssentialsAntiBuild/src/main/resources/plugin.yml +++ b/EssentialsAntiBuild/src/main/resources/plugin.yml @@ -7,6 +7,7 @@ description: Provides build protection. authors: [Zenexer, ementalo, Aelux, Brettflan, KimKandor, snowleo, ceulemans, Xeology, KHobbits, Iaccidentally, drtshock, mdcfe] depend: [Essentials] api-version: "1.13" +folia-supported: true permissions: essentials.build: diff --git a/EssentialsChat/src/main/resources/plugin.yml b/EssentialsChat/src/main/resources/plugin.yml index a6b5103403..13034630aa 100644 --- a/EssentialsChat/src/main/resources/plugin.yml +++ b/EssentialsChat/src/main/resources/plugin.yml @@ -7,6 +7,7 @@ description: Provides chat control features for Essentials. Requires Permission authors: [Zenexer, ementalo, Aelux, Brettflan, KimKandor, snowleo, ceulemans, Xeology, KHobbits, md_5, Okamosy, Iaccidentally, mdcfe, JRoy, triagonal] depend: [Essentials] api-version: 1.13 +folia-supported: true commands: toggleshout: description: Toggles whether you are talking in shout mode diff --git a/EssentialsDiscord/src/main/resources/plugin.yml b/EssentialsDiscord/src/main/resources/plugin.yml index ed6cb51d71..d903782d36 100644 --- a/EssentialsDiscord/src/main/resources/plugin.yml +++ b/EssentialsDiscord/src/main/resources/plugin.yml @@ -8,6 +8,7 @@ authors: [mdcfe, JRoy, pop4959, Glare] depend: [Essentials] softdepend: [EssentialsChat, PlaceholderAPI] api-version: 1.13 +folia-supported: true commands: discordbroadcast: description: Broadcasts a message to the specified Discord channel. diff --git a/EssentialsDiscordLink/src/main/resources/plugin.yml b/EssentialsDiscordLink/src/main/resources/plugin.yml index ff92febcbc..2aa180da0d 100644 --- a/EssentialsDiscordLink/src/main/resources/plugin.yml +++ b/EssentialsDiscordLink/src/main/resources/plugin.yml @@ -7,6 +7,7 @@ description: EssentialsX Discord addon which allows you link your Minecraft and authors: [JRoy] depend: [EssentialsDiscord] api-version: 1.13 +folia-supported: true commands: link: description: Generates a code to link your Minecraft account to Discord. diff --git a/EssentialsGeoIP/src/main/resources/plugin.yml b/EssentialsGeoIP/src/main/resources/plugin.yml index 5a675bfc6a..3ab63ee5a7 100644 --- a/EssentialsGeoIP/src/main/resources/plugin.yml +++ b/EssentialsGeoIP/src/main/resources/plugin.yml @@ -7,6 +7,7 @@ description: Shows the country or city of a user on login and /whois. authors: [Zenexer, ementalo, Aelux, Brettflan, KimKandor, snowleo, ceulemans, Xeology, kjiang, pop4959] depend: [Essentials] api-version: 1.13 +folia-supported: true permissions: essentials.geoip.show: description: Shows the country or city of a user on login and /whois. diff --git a/EssentialsProtect/src/main/resources/plugin.yml b/EssentialsProtect/src/main/resources/plugin.yml index 027249cc9d..8254af8dbf 100644 --- a/EssentialsProtect/src/main/resources/plugin.yml +++ b/EssentialsProtect/src/main/resources/plugin.yml @@ -7,6 +7,7 @@ description: Provides protection for various parts of the world. authors: [Zenexer, ementalo, Aelux, Brettflan, KimKandor, snowleo, ceulemans, Xeology, KHobbits, drtshock] softdepend: [Essentials] api-version: 1.13 +folia-supported: true permissions: essentials.protect.entitytarget.bypass: description: Allows the bearer to be targeted by entities diff --git a/EssentialsSpawn/src/main/resources/plugin.yml b/EssentialsSpawn/src/main/resources/plugin.yml index 389ab58fde..397811fc38 100644 --- a/EssentialsSpawn/src/main/resources/plugin.yml +++ b/EssentialsSpawn/src/main/resources/plugin.yml @@ -7,6 +7,7 @@ description: Provides spawn control commands, utilizing Essentials. authors: [Zenexer, ementalo, Aelux, Brettflan, KimKandor, snowleo, ceulemans, Xeology, KHobbits, SupaHam, mdcfe, DoNotSpamPls, JRoy] depend: [Essentials] api-version: 1.13 +folia-supported: true commands: setspawn: description: Sets the spawn point to your current position. diff --git a/EssentialsXMPP/src/main/resources/plugin.yml b/EssentialsXMPP/src/main/resources/plugin.yml index e267ef5e04..20253d017a 100644 --- a/EssentialsXMPP/src/main/resources/plugin.yml +++ b/EssentialsXMPP/src/main/resources/plugin.yml @@ -7,6 +7,7 @@ description: Provides XMPP communication. authors: [snowleo, SupaHam, mdcfe] depend: [Essentials] api-version: 1.13 +folia-supported: true commands: setxmpp: description: Sets your XMPP address. diff --git a/README.md b/README.md index c2a145a788..01e9bdb2fe 100644 --- a/README.md +++ b/README.md @@ -23,8 +23,8 @@ Essentials or Spigot-Essentials. [For more details, see the wiki.](https://essen If you're coming from the original Essentials plugin, EssentialsX is a drop-in replacement for Essentials. It does, however, have some new requirements: -* **EssentialsX requires CraftBukkit, Spigot or Paper to run.** Other server software may work, but these are not tested - by the team and we may not be able to help with any issues that occur. +* **EssentialsX requires CraftBukkit, Spigot, Paper or Folia to run.** Other server software may work, but these are not + tested by the team and we may not be able to help with any issues that occur. * **EssentialsX currently supports Minecraft versions 1.8.8, 1.9.4, 1.10.2, 1.11.2, 1.12.2, 1.13.2, 1.14.4, 1.15.2, 1.16.5, 1.17.1, 1.18.2, 1.19.4, 1.20.6, 1.21.11, 26.1.2, 26.2, and 26.3.** * **EssentialsX currently requires Java 8 or higher.** We recommend using the latest Java version supported by your