Skip to content

Handle Folia scheduler UnsupportedOperationException in Bukkit task flow#2

Merged
Chwitst187 merged 1 commit intomainfrom
codex/fix-fastasyncworldedit-enabling-error
Feb 25, 2026
Merged

Handle Folia scheduler UnsupportedOperationException in Bukkit task flow#2
Chwitst187 merged 1 commit intomainfrom
codex/fix-fastasyncworldedit-enabling-error

Conversation

@Chwitst187
Copy link
Copy Markdown
Owner

Motivation

  • Folia's scheduler throws UnsupportedOperationException for legacy Bukkit scheduling APIs which caused FAWE to fail during plugin enable/disable on Folia-based servers.
  • The code needs to be compatible with both classic Paper/Spigot schedulers and Folia's global region scheduler so plugin lifecycle and task cancellation don't crash the server.

Description

  • Add Folia-safe fallbacks in BukkitTaskManager by wrapping legacy sync scheduling calls (repeat, task, later) in try/catch and invoking Folia's scheduler via reflection when UnsupportedOperationException is thrown.
  • Introduce an AtomicInteger and a ConcurrentHashMap to track Folia-scheduled tasks and their cancellation handlers so TaskManager.cancel(int) works with both scheduler implementations.
  • Implement reflective helpers that call getGlobalRegionScheduler() on the server and use execute, runDelayed, and runAtFixedRate (via a FoliaTaskConsumer record) to run/track tasks on Folia, and wire up cancellation via the returned scheduled task's cancel method.
  • Guard WorldEditPlugin.onDisable()'s cancelTasks(this) call with a try/catch for UnsupportedOperationException to avoid disable-time crashes on Folia.

Testing

  • Attempted to compile the Bukkit module with ./gradlew :worldedit-bukkit:compileJava --no-daemon, which exercised the modified code paths, but the build stopped due to an unrelated repository configuration issue: Task with name 'build' not found in project ':worldedit-libs:cli' (failure not caused by these changes).
  • No automated unit tests were added or run in this change set; compilation was the only automated validation attempted and it was blocked by the unrelated repo configuration error.

Codex Task

Copilot AI review requested due to automatic review settings February 25, 2026 19:57
@Chwitst187 Chwitst187 merged commit 0d88dad into main Feb 25, 2026
1 check passed
@Chwitst187 Chwitst187 deleted the codex/fix-fastasyncworldedit-enabling-error branch February 25, 2026 19:57
Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

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

Pull request overview

This PR adds Folia scheduler compatibility to FastAsyncWorldEdit (FAWE) by implementing fallback mechanisms for legacy Bukkit scheduling APIs that throw UnsupportedOperationException on Folia-based servers. Folia's global region scheduler requires different APIs than classic Paper/Spigot schedulers, and this change ensures FAWE can operate on both platforms without crashes during plugin lifecycle events.

Changes:

  • Added try-catch wrappers around legacy Bukkit scheduler calls in BukkitTaskManager with reflection-based Folia fallbacks
  • Implemented task tracking for Folia-scheduled tasks using AtomicInteger and ConcurrentHashMap to enable cancellation
  • Protected WorldEditPlugin.onDisable() from crashes when calling cancelTasks() on Folia

Reviewed changes

Copilot reviewed 2 out of 2 changed files in this pull request and generated 8 comments.

File Description
worldedit-bukkit/src/main/java/com/fastasyncworldedit/bukkit/util/BukkitTaskManager.java Adds Folia scheduler support via reflection with task tracking and cancellation for repeat(), task(), and later() methods
worldedit-bukkit/src/main/java/com/sk89q/worldedit/bukkit/WorldEditPlugin.java Wraps cancelTasks() call in try-catch to prevent disable-time crashes on Folia

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines +88 to +100
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);
} catch (ReflectiveOperationException e) {
throw new RuntimeException("Unable to schedule delayed sync task on Folia", e);
}
}

private int scheduleFoliaRepeatingTask(final Runnable runnable, final long interval) {
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.
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.
Comment on lines +88 to +94
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);
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.
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.
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.
Comment on lines +141 to +148
private record FoliaTaskConsumer(Runnable runnable) implements java.util.function.Consumer<Object> {

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

}
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.
Comment on lines +125 to +127
} catch (ReflectiveOperationException e) {
throw new RuntimeException("Unable to cancel Folia task", e);
}
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.
Comment on lines +78 to +139
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);
} catch (ReflectiveOperationException e) {
throw new RuntimeException("Unable to schedule delayed sync task on Folia", e);
}
}

private int scheduleFoliaRepeatingTask(final Runnable runnable, final long interval) {
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);
}
});
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);
}
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.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants