PaperMC / PaperMC/Paper

Repeating `ScheduledTask` stays `CANCELLED_RUNNING` after its callback finishes

Open
#14,107 0 comments 0 reactions 0 assignees View on GitHub

Nobody has claimed this yet.

status: needs triage version: 26.2
Dominant language
Java
Stars
12.7k
Forks
3.5k
Avg merge
3d 13h
Merged PRs (30d)
11

Description

Expected behavior

When a repeating ScheduledTask is cancelled while it is running, I would expect it to report CANCELLED_RUNNING until the callback finishes and CANCELLED afterwards.

Observed/Actual behavior

Future runs are cancelled correctly, but the task never leaves CANCELLED_RUNNING. Calling cancel() later also returns NEXT_RUNS_CANCELLED_ALREADY, even though the callback has already finished

I reproduced this with a repeating global-region task on Paper 26.2-83. The callback only ran once, but the state was still CANCELLED_RUNNING one and two seconds after it returned.

Log from the reproduction:

[21:36:47 INFO]: [ScheduledTaskStateRepro] callback-start run=1 state-before-cancel=RUNNING
[21:36:47 INFO]: [ScheduledTaskStateRepro] cancel-during-callback=NEXT_RUNS_CANCELLED
[21:36:47 INFO]: [ScheduledTaskStateRepro] state-after-cancel-in-callback=CANCELLED_RUNNING
[21:36:47 INFO]: [ScheduledTaskStateRepro] callback-returning run=1
[21:36:48 INFO]: [ScheduledTaskStateRepro] follow-up-after-callback callback-runs=1
[21:36:48 INFO]: [ScheduledTaskStateRepro] state-after-callback=CANCELLED_RUNNING
[21:36:48 INFO]: [ScheduledTaskStateRepro] cancel-after-callback=NEXT_RUNS_CANCELLED_ALREADY
[21:36:49 INFO]: [ScheduledTaskStateRepro] second-follow-up callback-runs=1 state=CANCELLED_RUNNING
Steps/models to reproduce
  1. Install a plugin that does this:

    private int callbackRuns;
    
    @Override
    public void onEnable() {
        var scheduler = this.getServer().getGlobalRegionScheduler();
        var logger = this.getLogger();
        scheduler.runAtFixedRate(this, task -> {
            int run = ++this.callbackRuns;
            logger.info("callback-start run=" + run + " state-before-cancel=" + task.getExecutionState());
            logger.info("cancel-during-callback=" + task.cancel());
            logger.info("state-after-cancel-in-callback=" + task.getExecutionState());
    
            scheduler.runDelayed(this, _ -> {
                logger.info("follow-up-after-callback callback-runs=" + this.callbackRuns);
                logger.info("state-after-callback=" + task.getExecutionState());
                logger.info("cancel-after-callback=" + task.cancel());
    
                scheduler.runDelayed(
                    this,
                    _ -> logger.info("second-follow-up callback-runs=" + this.callbackRuns + " state=" + task.getExecutionState()),
                    20L
                );
            }, 20L);
    
            logger.info("callback-returning run=" + run);
        }, 1L, 200L);
    }
    
  2. Start a fresh Paper server and wait two seconds after startup completes

  3. Check the log after the delayed tasks run

Plugin and Datapack List
> plugins
[23:03:55 INFO]: ℹ Server Plugins (1):
[23:03:55 INFO]: Bukkit Plugins:
[23:03:55 INFO]:  - ScheduledTaskStateRepro
> datapack list
[23:03:57 INFO]: There are 3 data pack(s) enabled: [vanilla (built-in)], [file/bukkit (world)], [paper (built-in)]
[23:03:57 INFO]: There are no more data packs available
Paper version
> version
[23:01:41 INFO]: Checking version, please wait...
[23:01:42 INFO]: This server is running Paper version 26.2-83-main@f9fb80b (2026-07-26T16:57:41Z) (Implementing API version 26.2.build.83-stable)
You are running the latest version
Other

I also checked the scheduler implementations, and the global, async, and entity schedulers all have the same missing state transition: they stop rescheduling when a running task is cancelled, but don't change the state from executing-cancelled to cancelled when the callback finishes

Contributor guide

Open the contributing guide

First steps

  1. Read the whole issue, then the project's contributing guide.
  2. Comment on the issue to say you are picking it up — it saves two people doing the same work.
  3. Fork the repository and make your change on a branch.
  4. Open a pull request that references the issue number.

Research direction

Start by tracing the global, async, and entity scheduler implementations, especially the path taken when a running repeating task is cancelled and its callback returns. Reproduce the issue with the supplied plugin and verify that the task reports CANCELLED after the callback while future runs remain cancelled.

Written by the indexing model from the issue text.

Assessment

Tech stack
java
Domain
backend-api-design
Issue type
Bug
Difficulty
3/5
Estimated time
1-2 days
Activity status
Quiet
Clarity
Mostly clear
Newbie friendliness
65/100

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.