Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -2,21 +2,32 @@

import com.fastasyncworldedit.core.util.TaskManager;
import org.bukkit.Bukkit;
import org.bukkit.Server;
import org.bukkit.plugin.Plugin;

import javax.annotation.Nonnull;
import java.lang.reflect.Method;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.atomic.AtomicInteger;

public class BukkitTaskManager extends TaskManager {

private final Plugin plugin;
private final AtomicInteger foliaTaskCounter = new AtomicInteger();
Copy link

Copilot AI Feb 25, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Starting the AtomicInteger at 0 and decrementing means the first Folia task will get ID -1, which is used as a sentinel value meaning "no task" in the cancel method (line 68). This will cause the first Folia task to be uncancellable. Consider initializing foliaTaskCounter with a value like Integer.MIN_VALUE + 1 to ensure task IDs stay far away from -1, or use incrementAndGet() starting from a large negative base to generate unique negative IDs that don't conflict with -1.

Suggested change
private final AtomicInteger foliaTaskCounter = new AtomicInteger();
private final AtomicInteger foliaTaskCounter = new AtomicInteger(Integer.MIN_VALUE + 1);

Copilot uses AI. Check for mistakes.
private final Map<Integer, Runnable> foliaTaskCancels = new ConcurrentHashMap<>();

public BukkitTaskManager(final Plugin plugin) {
this.plugin = plugin;
}

@Override
public int repeat(@Nonnull final Runnable runnable, final int interval) {
return this.plugin.getServer().getScheduler().scheduleSyncRepeatingTask(this.plugin, runnable, interval, interval);
try {
return this.plugin.getServer().getScheduler().scheduleSyncRepeatingTask(this.plugin, runnable, interval, interval);
} catch (UnsupportedOperationException ignored) {
return scheduleFoliaRepeatingTask(runnable, interval);
}
}

@Override
Expand All @@ -31,12 +42,20 @@ public void async(@Nonnull final Runnable runnable) {

@Override
public void task(@Nonnull final Runnable runnable) {
this.plugin.getServer().getScheduler().runTask(this.plugin, runnable).getTaskId();
try {
this.plugin.getServer().getScheduler().runTask(this.plugin, runnable).getTaskId();
} catch (UnsupportedOperationException ignored) {
executeFoliaNow(runnable);
}
}

@Override
public void later(@Nonnull final Runnable runnable, final int delay) {
this.plugin.getServer().getScheduler().runTaskLater(this.plugin, runnable, delay).getTaskId();
try {
this.plugin.getServer().getScheduler().runTaskLater(this.plugin, runnable, delay).getTaskId();
} catch (UnsupportedOperationException ignored) {
scheduleFoliaDelayedTask(runnable, delay);
}
}

@Override
Expand All @@ -47,8 +66,85 @@ public void laterAsync(@Nonnull final Runnable runnable, final int delay) {
@Override
public void cancel(final int task) {
if (task != -1) {
Bukkit.getScheduler().cancelTask(task);
Runnable foliaCancel = foliaTaskCancels.remove(task);
if (foliaCancel != null) {
foliaCancel.run();
} else {
Bukkit.getScheduler().cancelTask(task);
Copy link

Copilot AI Feb 25, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The cancel method attempts to cancel a Bukkit task even when no Folia cancel handler exists, but it doesn't handle the UnsupportedOperationException that Bukkit.getScheduler().cancelTask() may throw on Folia. If a Bukkit task ID is passed but Folia is the active scheduler, this will throw an exception. Wrap the cancelTask call in a try-catch block to handle this scenario gracefully.

Suggested change
Bukkit.getScheduler().cancelTask(task);
try {
Bukkit.getScheduler().cancelTask(task);
} catch (UnsupportedOperationException ignored) {
// Environment (e.g., Folia) does not support Bukkit scheduler cancel
}

Copilot uses AI. Check for mistakes.
}
}
}

private void executeFoliaNow(final Runnable runnable) {
try {
Object globalRegionScheduler = getGlobalRegionScheduler();
Method execute = globalRegionScheduler.getClass().getMethod("execute", Plugin.class, Runnable.class);
execute.invoke(globalRegionScheduler, this.plugin, runnable);
} catch (ReflectiveOperationException e) {
throw new RuntimeException("Unable to schedule sync task on Folia", e);
}
}

private void scheduleFoliaDelayedTask(final Runnable runnable, final long delay) {
try {
Object globalRegionScheduler = getGlobalRegionScheduler();
Method runDelayed = globalRegionScheduler.getClass()
.getMethod("runDelayed", Plugin.class, java.util.function.Consumer.class, long.class);
Object scheduledTask = runDelayed.invoke(globalRegionScheduler, this.plugin, new FoliaTaskConsumer(runnable), delay);
storeFoliaTaskCancel(scheduledTask);
Comment on lines +88 to +94
Copy link

Copilot AI Feb 25, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The scheduleFoliaDelayedTask method doesn't return the task ID from storeFoliaTaskCancel, but the later method's return signature expects an int. This means delayed Folia tasks cannot be cancelled because no task ID is returned to the caller. The storeFoliaTaskCancel call on line 94 should have its return value returned from this method.

Suggested change
private void scheduleFoliaDelayedTask(final Runnable runnable, final long delay) {
try {
Object globalRegionScheduler = getGlobalRegionScheduler();
Method runDelayed = globalRegionScheduler.getClass()
.getMethod("runDelayed", Plugin.class, java.util.function.Consumer.class, long.class);
Object scheduledTask = runDelayed.invoke(globalRegionScheduler, this.plugin, new FoliaTaskConsumer(runnable), delay);
storeFoliaTaskCancel(scheduledTask);
private int scheduleFoliaDelayedTask(final Runnable runnable, final long delay) {
try {
Object globalRegionScheduler = getGlobalRegionScheduler();
Method runDelayed = globalRegionScheduler.getClass()
.getMethod("runDelayed", Plugin.class, java.util.function.Consumer.class, long.class);
Object scheduledTask = runDelayed.invoke(globalRegionScheduler, this.plugin, new FoliaTaskConsumer(runnable), delay);
return storeFoliaTaskCancel(scheduledTask);

Copilot uses AI. Check for mistakes.
} catch (ReflectiveOperationException e) {
throw new RuntimeException("Unable to schedule delayed sync task on Folia", e);
}
}

private int scheduleFoliaRepeatingTask(final Runnable runnable, final long interval) {
Comment on lines +88 to +100
Copy link

Copilot AI Feb 25, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The scheduleFoliaDelayedTask and scheduleFoliaRepeatingTask methods accept parameters typed as long (delay, interval), but the calling methods (later, repeat) pass int parameters. While Java will auto-widen int to long, this type inconsistency could cause confusion. Consider keeping the parameter types consistent with the parent class method signatures (int) or explicitly casting at the call site to document the conversion.

Copilot uses AI. Check for mistakes.
try {
Object globalRegionScheduler = getGlobalRegionScheduler();
Method runAtFixedRate = globalRegionScheduler.getClass()
.getMethod("runAtFixedRate", Plugin.class, java.util.function.Consumer.class, long.class, long.class);
Object scheduledTask = runAtFixedRate.invoke(
globalRegionScheduler,
this.plugin,
new FoliaTaskConsumer(runnable),
interval,
interval
);
return storeFoliaTaskCancel(scheduledTask);
} catch (ReflectiveOperationException e) {
throw new RuntimeException("Unable to schedule repeating sync task on Folia", e);
}
}

private int storeFoliaTaskCancel(final Object scheduledTask) {
try {
Method cancel = scheduledTask.getClass().getMethod("cancel");
int taskId = foliaTaskCounter.decrementAndGet();
foliaTaskCancels.put(taskId, () -> {
try {
cancel.invoke(scheduledTask);
} catch (ReflectiveOperationException e) {
throw new RuntimeException("Unable to cancel Folia task", e);
}
Comment on lines +125 to +127
Copy link

Copilot AI Feb 25, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The RuntimeException thrown when cancelling a Folia task fails will propagate to the caller of cancel(), potentially causing unexpected crashes. Since cancel() is typically called during cleanup operations (like in onDisable), this could prevent proper shutdown. Consider logging the error instead of throwing, or wrapping it in a try-catch block at the call site to ensure cleanup continues even if cancellation fails.

Copilot uses AI. Check for mistakes.
});
return taskId;
} catch (ReflectiveOperationException e) {
throw new RuntimeException("Unable to wire Folia task cancellation", e);
}
}

private Object getGlobalRegionScheduler() throws ReflectiveOperationException {
Server server = this.plugin.getServer();
Method method = server.getClass().getMethod("getGlobalRegionScheduler");
return method.invoke(server);
}
Comment on lines +78 to +139
Copy link

Copilot AI Feb 25, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The reflection methods (getMethod and invoke) are called on every task schedule operation, which could impact performance for frequently scheduled tasks. Consider caching the Method objects (execute, runDelayed, runAtFixedRate, cancel) as class fields after the first successful lookup to avoid repeated reflection overhead.

Copilot uses AI. Check for mistakes.

private record FoliaTaskConsumer(Runnable runnable) implements java.util.function.Consumer<Object> {

@Override
public void accept(final Object ignored) {
runnable.run();
}

}
Comment on lines +141 to +148
Copy link

Copilot AI Feb 25, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The foliaTaskCancels map can accumulate entries for tasks that complete naturally without being explicitly cancelled. When a Folia task finishes on its own (non-repeating tasks), the cancel handler remains in the map indefinitely, causing a memory leak. Consider having the FoliaTaskConsumer remove its entry from foliaTaskCancels after the runnable completes, or implement a cleanup mechanism for completed tasks.

Copilot uses AI. Check for mistakes.

}
Original file line number Diff line number Diff line change
Expand Up @@ -457,7 +457,11 @@ public void onDisable() {
if (config != null) {
config.unload();
}
this.getServer().getScheduler().cancelTasks(this);
try {
this.getServer().getScheduler().cancelTasks(this);
} catch (UnsupportedOperationException ignored) {
// Folia does not support legacy scheduler cancellation for all task types.
Copy link

Copilot AI Feb 25, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

While catching UnsupportedOperationException here prevents crashes on Folia, silently ignoring the exception means that tasks scheduled through the legacy Bukkit scheduler won't be cancelled during plugin shutdown. This could lead to tasks continuing to run after the plugin is disabled. Consider logging a warning when this fallback is triggered, or implementing a Folia-compatible cancellation mechanism that tracks and cancels all tasks (both Bukkit and Folia) during shutdown.

Suggested change
// Folia does not support legacy scheduler cancellation for all task types.
// Folia does not support legacy scheduler cancellation for all task types.
LOGGER.warn("Legacy Bukkit scheduler task cancellation is not supported on this server (likely Folia). "
+ "Any tasks scheduled via the legacy Bukkit scheduler may not have been cancelled during shutdown.");

Copilot uses AI. Check for mistakes.
}
}

/**
Expand Down
Loading